Public release: C2PA write support.

GitOrigin-RevId: 24834eac456c5fadc48965b4a7a69447fc3a2327
Change-Id: I8617ef3b9b5e003e1313a2f3569d23760673d8c9
diff --git a/.bazelversion b/.bazelversion
new file mode 100644
index 0000000..deeb3d6
--- /dev/null
+++ b/.bazelversion
@@ -0,0 +1 @@
+9.2.0
diff --git a/MODULE.bazel b/MODULE.bazel
index 0c82647..8568f24 100644
--- a/MODULE.bazel
+++ b/MODULE.bazel
@@ -14,19 +14,22 @@
 #
 
 module(
-    name = "c2pa",
+    name = "credentio",
     version = "0.1.0",
 )
 
+# apple_support must come before rules_cc so that its Apple C++ toolchain takes
+# precedence on macOS.
+bazel_dep(name = "apple_support", version = "2.9.1")
 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 = "protobuf", version = "36.2")
+bazel_dep(name = "rules_cc", version = "0.2.25")
 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 = "boringssl", version = "0.20260813.0")
+bazel_dep(name = "tink_cc", version = "2.9.1")
 bazel_dep(name = "re2", version = "2025-11-05.bcr.1")
 
 # Add rules_android and its dependencies
@@ -51,18 +54,24 @@
 
 register_toolchains("@androidsdk//:sdk-toolchain", "@androidsdk//:all")
 
-http_archive = use_repo_rule("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")
+# libcppbor has no Bazel module or release archive, so it is fetched directly
+# from AOSP. It is pinned to a commit and fetched over git rather than via the
+# gitiles "+archive" endpoint: that endpoint builds tarballs on demand (it
+# returns 503 under load) and its output is not byte-reproducible, so it can
+# neither be verified with a sha256 nor cached by Bazel.
+git_repository = use_repo_rule("@bazel_tools//tools/build_defs/repo:git.bzl", "git_repository")
 
-http_archive(
+git_repository(
     name = "libcppbor",
     build_file = "@//:external/libcppbor.BUILD",
-    urls = ["https://android.googlesource.com/platform/system/libcppbor/+archive/refs/heads/main.tar.gz"],
+    commit = "ef9626806649d45cd1b5dd692695eae82aff5542",
+    remote = "https://android.googlesource.com/platform/system/libcppbor",
 )
 
 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"],
+    strip_prefix = "boringssl-0.20260813.0",
+    urls = ["https://github.com/google/boringssl/archive/0.20260813.0.tar.gz"],
 )
diff --git a/README.md b/README.md
index 3588b6b..a8805b6 100644
--- a/README.md
+++ b/README.md
@@ -8,25 +8,66 @@
 
 ## Supported Media Formats
 
-The library supports C2PA provenance extraction and validation for files with
-the following extensions:
+The library supports C2PA provenance validation (extracting and verifying an
+embedded C2PA manifest store) and generation (embedding a newly generated and
+signed C2PA manifest store) for the following container formats:
 
 <!-- mdformat off(no multiline table cells) -->
 
-Category        | Extensions
-:-------------- | :---------
-**Image**       | `.avif`, `.dng`, `.gif`, `.heic`, `.heif`, `.jpeg`, `.jpg`, `.png`, `.tif`, `.tiff`, `.webp`
-**Video/Audio** | `.avi`, `.m4a`, `.mov`, `.mp3`, `.mp4`, `.wav`, `.flac`
-**Document**    | `.pdf`, `.docx`, `.pptx`, `.xlsx`
+Format                       | Extensions                          | Validation | Generation
+:--------------------------- | :---------------------------------- | :--------- | :---------
+JPEG                         | `.jpg`, `.jpeg`                     | Yes        | [Yes](formats/jpeg/writer.h)
+PNG                          | `.png`                              | Yes        | [Yes](formats/png/writer.h)
+MP4                          | `.mp4`                              | Yes        | [Yes](formats/bmff/writer.h)
+fMP4 (fragmented MP4)        |                                     | No         | No
+RIFF (e.g., WebP, WAV, AVI)  | `.webp`, `.wav`, `.avi`             | Yes        | [Yes](formats/riff/writer.h)
+TIFF (e.g., TIFF, DNG)       | `.tif`, `.tiff`, `.dng`             | Yes        | [Yes](formats/tiff/writer.h)
+AVIF                         | `.avif`                             | Yes        | [Yes](formats/bmff/writer.h)
+M4A                          | `.m4a`                              | Yes        | [Yes](formats/bmff/writer.h)
+HEIF, HEIC                   | `.heif`, `.heic`                    | Yes        | [Yes](formats/bmff/writer.h)
+MOV                          | `.mov`                              | Yes        | [Yes](formats/bmff/writer.h)
+GIF                          | `.gif`                              | Yes        | [Yes](formats/gif/writer.h)
+MP3                          | `.mp3`                              | Yes        | [Yes](formats/id3/writer.h)
+FLAC                         | `.flac`                             | Yes        | [Yes](formats/id3/writer.h)
+PDF                          | `.pdf`                              | Yes        | [Yes](formats/pdf/writer.h)
+SVG                          |                                     | No         | No
+OGG Vorbis                   |                                     | No         | No
+Text                         |                                     | No         | No
+Live stream video            |                                     | No         | No
+ZIP (e.g., DOCX, PPTX, XLSX) | `.zip`, `.docx`, `.pptx`, `.xlsx`   | Yes        | [Yes](formats/zip/writer.h)
+Fonts                        |                                     | No         | No
 
 <!-- mdformat on -->
 
 ## Prerequisites
 
-*   Clang
-*   Bazel
+*   Clang with libc++ (tested with Clang 19). `.bazelrc` builds with `clang` and
+    `clang++` against libc++, so both must be on `PATH` and the libc++
+    development packages must be installed.
+*   [Bazelisk](https://github.com/bazelbuild/bazelisk), installed as `bazel`. It
+    downloads the Bazel version pinned in `.bazelversion`.
 *   Git
 
+On Ubuntu, CI sets these up as follows:
+
+```shell
+# Clang 19, including libc++, from the LLVM apt repository.
+sudo apt-get install -y lsb-release wget software-properties-common gnupg
+wget https://apt.llvm.org/llvm.sh
+chmod +x llvm.sh
+sudo ./llvm.sh 19 all
+sudo update-alternatives --install /usr/bin/clang clang /usr/bin/clang-19 100
+sudo update-alternatives --install /usr/bin/clang++ clang++ /usr/bin/clang++-19 100
+
+# Bazelisk.
+curl -sLO https://github.com/bazelbuild/bazelisk/releases/latest/download/bazelisk-linux-amd64
+chmod +x bazelisk-linux-amd64
+sudo mv bazelisk-linux-amd64 /usr/local/bin/bazel
+```
+
+Bazel caches the detected C++ toolchain, so after installing or changing the
+compiler or libc++, run `bazel clean --expunge` before building again.
+
 ## Dependencies
 
 *   [Abseil (`abseil-cpp`)](https://abseil.io/)
@@ -40,22 +81,29 @@
 
 ## Quickstart
 
-Credentio includes a fast standalone command-line tool for inspecting and
-verifying C2PA metadata embedded in media assets.
+Credentio includes fast standalone command-line tools for inspecting, verifying,
+and writing C2PA metadata embedded in media assets.
 
-#### Building the CLI
+### Building the CLIs
+
+Build the tools once:
 
 ```bash
-bazel build tools:c2pa_validate
+bazel build tools:c2pa_validate tools:asset_write
 ```
 
+The binaries are written to `bazel-bin/tools/c2pa_validate` and
+`bazel-bin/tools/asset_write`.
+
+### Validating C2PA Metadata
+
 #### 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 -- \
+bazel-bin/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
@@ -66,6 +114,90 @@
 > 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)).
 
+### Writing C2PA Metadata
+
+The `asset_write` tool generates a C2PA manifest store, signs it with the
+provided claim signer key, and embeds it into a copy of the input asset. The
+media type is inferred from the input file extension.
+
+#### Creating Test Keys and Certificates
+
+`asset_write` needs an EC P-256 key and certificate for the claim signer, and
+optionally another pair for the Time Stamping Authority (TSA). For local
+testing, [`tools/create_test_pki.sh`](tools/create_test_pki.sh) creates a
+private root CA and issues both certificates from it. It requires OpenSSL 1.1.1
+or later:
+
+```bash
+tools/create_test_pki.sh test_pki
+```
+
+The script refuses to overwrite existing keys. It produces:
+
+*   `claim_signer_key.pem` and `claim_signer_cert.pem`: pass to
+    `--claim_signer_key` and `--claim_signer_cert`.
+*   `tsa_key.pem` and `tsa_cert.pem`: pass to `--tsa_key` and `--tsa_cert`.
+*   `root_ca_cert.pem`: pass to `--claim_signer_trust` and `--tsa_trust` when
+    validating.
+
+#### Writing a Manifest Store
+
+```bash
+bazel-bin/tools/asset_write \
+  --input=/path/to/input.jpg \
+  --output=/path/to/output.jpg \
+  --claim_signer_key=/path/to/claim_signer_key.pem \
+  --claim_signer_cert=/path/to/claim_signer_cert_chain.pem \
+  --tsa_key=/path/to/tsa_key.pem \
+  --tsa_cert=/path/to/tsa_cert.pem
+```
+
+Flags:
+
+*   `--claim_signer_key`: PEM-encoded EC P-256 private key of the claim signer.
+*   `--claim_signer_cert`: PEM-encoded claim signer certificate chain, leaf
+    first.
+*   `--claim_signer_ocsp` (optional): DER-encoded OCSP response for the claim
+    signer certificate.
+*   `--tsa_key`, `--tsa_cert` (optional): PEM-encoded EC P-256 private key and
+    certificate used to timestamp the claim signature locally. If omitted,
+    timestamping is disabled.
+*   `--disable_timestamping` (optional): Disables timestamping.
+*   `--request_txtpb` (optional): Text-format
+    [`GenerateManifestStoreRequest`](proto/generator.proto) describing the
+    manifest to generate. Defaults to a single `c2pa.created` action.
+
+#### End-to-End Example
+
+Using the files from
+[Creating Test Keys and Certificates](#creating-test-keys-and-certificates),
+sign an asset and validate it against the test root CA. Run these commands from
+the repository root:
+
+```bash
+tools/create_test_pki.sh test_pki
+
+bazel-bin/tools/asset_write \
+  --input=/path/to/input.jpg \
+  --output=signed.jpg \
+  --claim_signer_key=test_pki/claim_signer_key.pem \
+  --claim_signer_cert=test_pki/claim_signer_cert.pem \
+  --tsa_key=test_pki/tsa_key.pem \
+  --tsa_cert=test_pki/tsa_cert.pem
+
+bazel-bin/tools/c2pa_validate \
+  --asset=signed.jpg \
+  --claim_signer_trust=test_pki/root_ca_cert.pem \
+  --tsa_trust=test_pki/root_ca_cert.pem
+```
+
+The validation result should report `claimSignature.validated`,
+`signingCredential.trusted` and `timeStamp.trusted` with no failures.
+
+> **Note:** `asset_write` signs with locally supplied keys and is intended for
+> development and testing. Manifest stores it produces are only trusted by
+> validators that trust the supplied certificates.
+
 ## Building & Testing
 
 To build the entire project:
diff --git a/assertion/assertion_encoder.cc b/assertion/assertion_encoder.cc
index 70b0524..2599280 100644
--- a/assertion/assertion_encoder.cc
+++ b/assertion/assertion_encoder.cc
@@ -90,13 +90,9 @@
   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;
+const AssertionEncoder& GetAssertionEncoder() {
+  static const absl::NoDestructor<StandardAssertionEncoder> encoder;
+  return *encoder;
 }
 
 }  // namespace credentio
diff --git a/assertion/assertion_encoder.h b/assertion/assertion_encoder.h
index 1a964eb..822bb04 100644
--- a/assertion/assertion_encoder.h
+++ b/assertion/assertion_encoder.h
@@ -49,7 +49,7 @@
 };
 
 // Returns a singleton StandardAssertionEncoder.
-const AssertionEncoder& GetAssertionEncoder(bool skip_validity_checks_for_test);
+const AssertionEncoder& GetAssertionEncoder();
 
 }  // namespace credentio
 
diff --git a/assertion/assertion_encoder_test.cc b/assertion/assertion_encoder_test.cc
index 46759a4..1faa065 100644
--- a/assertion/assertion_encoder_test.cc
+++ b/assertion/assertion_encoder_test.cc
@@ -328,20 +328,8 @@
   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);
+TEST(GetAssertionEncoderTest, ReturnsSameInstance) {
+  EXPECT_EQ(&GetAssertionEncoder(), &GetAssertionEncoder());
 }
 
 absl::StatusOr<Assertion> ParseAssertionBox(absl::string_view assertion_data) {
@@ -365,8 +353,7 @@
 }
 
 TEST(AssertionEncoderTest, ToJumbf) {
-  StandardAssertionEncoder encoder(
-      /*skip_validity_checks_for_test=*/false);
+  StandardAssertionEncoder encoder;
   auto assertion = ParseTextProtoOrDie<Assertion>(R"pb(
     label: "c2pa.actions.v2"
     actions {
diff --git a/assertion/parse_assertion_test.cc b/assertion/parse_assertion_test.cc
index 2d327ad..37c434c 100644
--- a/assertion/parse_assertion_test.cc
+++ b/assertion/parse_assertion_test.cc
@@ -901,7 +901,7 @@
     }
   )pb");
   credentio::AiDisclosureAssertion actual;
-  ASSERT_THAT(credentio::ParseAssertion(cbor, &actual), IsOk());
+  ABSL_ASSERT_OK(credentio::ParseAssertion(cbor, &actual));
   EXPECT_THAT(actual, EqualsProto(expected));
 }
 
@@ -933,7 +933,7 @@
         }
       )pb");
   credentio::EnvironmentalSustainabilityAssertion actual;
-  ASSERT_THAT(credentio::ParseAssertion(cbor, &actual), IsOk());
+  ABSL_ASSERT_OK(credentio::ParseAssertion(cbor, &actual));
   EXPECT_THAT(actual, EqualsProto(expected));
 }
 
@@ -977,7 +977,7 @@
         }
       )pb");
   credentio::RepositoryReceiptAssertion actual;
-  ASSERT_THAT(credentio::ParseAssertion(json, &actual), IsOk());
+  ABSL_ASSERT_OK(credentio::ParseAssertion(json, &actual));
   EXPECT_THAT(actual, EqualsProto(expected));
 }
 
diff --git a/bindings/BUILD b/bindings/BUILD
index 995fea2..72d7bbb 100644
--- a/bindings/BUILD
+++ b/bindings/BUILD
@@ -144,6 +144,19 @@
     ],
 )
 
+cc_test(
+    name = "bmff_exclusion_checker_test",
+    srcs = ["bmff_exclusion_checker_test.cc"],
+    deps = [
+        ":bmff_exclusion_checker",
+        "//crypto:algorithms",
+        "//formats/bmff:hasher",
+        "//proto:bmff_based_hash_assertion_cc_proto",
+        "//testing:proto_test_utils",
+        "@googletest//:gtest_main",
+    ],
+)
+
 cc_library(
     name = "merkle_validator",
     srcs = ["merkle_validator.cc"],
@@ -227,6 +240,7 @@
         "@abseil-cpp//absl/log",
         "@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",
@@ -436,3 +450,34 @@
         "@riegeli//riegeli/bytes:reader",
     ],
 )
+
+cc_test(
+    name = "data_hash_hard_binding_validator_test",
+    srcs = ["data_hash_hard_binding_validator_test.cc"],
+    deps = [
+        ":data_hash_hard_binding_validator",
+        "//formats:asset_byte_info",
+        "//proto:assertion_cc_proto",
+        "//proto:bmff_based_hash_assertion_cc_proto",
+        "//proto:boxes_hash_assertion_cc_proto",
+        "//proto:data_hash_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",
+        "//utils:dual_status_tracker",
+        "//utils:status_tracker",
+        "@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",
+        "@protobuf//:differencer",
+        "@riegeli//riegeli/base:object",
+        "@riegeli//riegeli/base:types",
+        "@riegeli//riegeli/bytes:reader",
+        "@riegeli//riegeli/bytes:string_reader",
+    ],
+)
diff --git a/bindings/bmff_exclusion_checker_test.cc b/bindings/bmff_exclusion_checker_test.cc
new file mode 100644
index 0000000..e0bf5e8
--- /dev/null
+++ b/bindings/bmff_exclusion_checker_test.cc
@@ -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.
+//
+
+#include "bindings/bmff_exclusion_checker.h"
+
+#include <string>
+
+#include "crypto/algorithms.h"
+#include "formats/bmff/hasher.h"
+#include "gtest/gtest.h"
+#include "proto/bmff_based_hash_assertion.pb.h"
+#include "testing/proto_test_utils.h"
+
+namespace credentio {
+namespace {
+
+using ::credentio_testing::ParseTextProtoOrDie;
+
+TEST(NoAdditionalExclusionsTest, NoExclusions) {
+  BmffBasedHashAssertion assertion;
+  EXPECT_FALSE(HasAdditionalExclusions(assertion));
+}
+
+TEST(NoAdditionalExclusionsTest, OnlyRequiredExclusions) {
+  BmffBasedHashAssertion assertion =
+      GetBmffHashTemplate(HashAlgorithm::kSha256);
+
+  EXPECT_FALSE(HasAdditionalExclusions(assertion));
+}
+
+TEST(NoAdditionalExclusionsTest, WithSkipExclusion) {
+  auto assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb(
+    exclusions { xpath: "/skip" }
+  )pb");
+
+  EXPECT_FALSE(HasAdditionalExclusions(assertion));
+}
+
+TEST(AdditionalExclusionsTest, MerkleMapExclusionWithoutMerkleMaps) {
+  BmffBasedHashAssertion assertion =
+      GetBmffHashTemplate(HashAlgorithm::kSha256);
+  auto exclusion = ParseTextProtoOrDie<BmffRange>(R"pb(
+    xpath: "/mdat"
+    subsets { offset: 16 length: 0 }
+  )pb");
+  *assertion.add_exclusions() = exclusion;
+
+  EXPECT_TRUE(HasAdditionalExclusions(assertion));
+}
+
+TEST(NoAdditionalExclusionsTest, WithMerkleMaps) {
+  BmffBasedHashAssertion assertion =
+      GetBmffHashTemplate(HashAlgorithm::kSha256);
+  auto exclusion = ParseTextProtoOrDie<BmffRange>(R"pb(
+    xpath: "/mdat"
+    subsets { offset: 16 length: 0 }
+  )pb");
+  *assertion.add_exclusions() = exclusion;
+  auto merkle = ParseTextProtoOrDie<BmffMerkle>(R"pb(
+    unique_id: 1
+    local_id: 1
+    count: 1
+    alg: "sha256"
+    init_hash: "\001\002\003"
+    hashes: "\004\005\006"
+    fixed_block_size: 1
+  )pb");
+  *assertion.add_merkles() = merkle;
+
+  EXPECT_FALSE(HasAdditionalExclusions(assertion));
+}
+
+TEST(NoAdditionalExclusionsTest, ExclusionIsContainedWithinRequired) {
+  auto assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb(
+    exclusions { xpath: "/free[1]" }
+  )pb");
+
+  EXPECT_FALSE(HasAdditionalExclusions(assertion));
+}
+
+TEST(AdditionalExclusionsTest, ExclusionIsNotGuaranteedToBeContained) {
+  auto assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb(
+    exclusions { xpath: "/uuid/data" }
+  )pb");
+
+  EXPECT_TRUE(HasAdditionalExclusions(assertion));
+}
+
+TEST(AdditionalExclusionsTest, WhenHasDifferentData) {
+  auto assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb(
+    exclusions {
+      xpath: "/uuid"
+      data { offset: 0 value: "\001\002\003" }
+    }
+  )pb");
+
+  EXPECT_TRUE(HasAdditionalExclusions(assertion));
+}
+
+TEST(AdditionalExclusionsTest, WhenHasDifferentSubsets) {
+  auto assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb(
+    exclusions {
+      xpath: "/mdat"
+      subsets { offset: 10 length: 4 }
+    }
+  )pb");
+
+  EXPECT_TRUE(HasAdditionalExclusions(assertion));
+}
+
+TEST(AdditionalExclusionsTest, RequiredExclusionWithSubsets) {
+  auto assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb(
+    exclusions {
+      xpath: "/uuid"
+      data {
+        offset: 8
+        value: "\330\376\303\326\033\016H<\222\227X(\207~\304\201"
+      }
+      subsets { offset: 0 length: 128 }
+    }
+  )pb");
+
+  EXPECT_TRUE(HasAdditionalExclusions(assertion));
+}
+
+TEST(AdditionalExclusionsTest, FtypExclusionWithSubsets) {
+  auto assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb(
+    exclusions {
+      xpath: "/ftyp"
+      subsets { offset: 0 length: 8 }
+    }
+  )pb");
+
+  EXPECT_TRUE(HasAdditionalExclusions(assertion));
+}
+
+TEST(AdditionalExclusionsTest, MfraExclusionWithSubsets) {
+  auto assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb(
+    exclusions {
+      xpath: "/mfra"
+      subsets { offset: 0 length: 8 }
+    }
+  )pb");
+
+  EXPECT_TRUE(HasAdditionalExclusions(assertion));
+}
+
+TEST(AdditionalExclusionsTest, FtypExclusionWithPathExtension) {
+  auto assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb(
+    exclusions { xpath: "/ftyp/extra" }
+  )pb");
+
+  EXPECT_TRUE(HasAdditionalExclusions(assertion));
+}
+
+TEST(AdditionalExclusionsTest, MfraExclusionWithPathExtension) {
+  auto assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb(
+    exclusions { xpath: "/mfra/extra" }
+  )pb");
+
+  EXPECT_TRUE(HasAdditionalExclusions(assertion));
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/bindings/data_hash_hard_binding_validator_test.cc b/bindings/data_hash_hard_binding_validator_test.cc
new file mode 100644
index 0000000..cd4fcb6
--- /dev/null
+++ b/bindings/data_hash_hard_binding_validator_test.cc
@@ -0,0 +1,666 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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 <cstddef>
+#include <memory>
+#include <optional>
+#include <string>
+#include <type_traits>
+#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 "formats/asset_byte_info.h"
+#include "gmock/gmock.h"
+#include "google/protobuf/util/message_differencer.h"
+#include "gtest/gtest.h"
+#include "proto/assertion.pb.h"
+#include "proto/bmff_based_hash_assertion.pb.h"
+#include "proto/boxes_hash_assertion.pb.h"
+#include "proto/data_hash_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 "riegeli/base/object.h"
+#include "riegeli/base/types.h"
+#include "riegeli/bytes/reader.h"
+#include "riegeli/bytes/string_reader.h"
+#include "testing/proto_test_utils.h"
+#include "utils/dual_status_tracker.h"
+#include "utils/status_tracker.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::Pointee;
+using ::testing::TestWithParam;
+
+// Matches a `ValidationResultProto` against `expected` (a proto or a text
+// proto), ignoring the assertions of all manifests.
+MATCHER_P(EqualsProtoIgnoringAssertions, expected, "") {
+  using Proto = std::decay_t<decltype(arg)>;
+  Proto expected_proto;
+  if constexpr (std::is_convertible_v<decltype(expected), absl::string_view>) {
+    expected_proto = ParseTextProtoOrDie(expected);
+  } else {
+    expected_proto = expected;
+  }
+  google::protobuf::util::MessageDifferencer differencer;
+  differencer.IgnoreField(
+      Manifest::descriptor()->FindFieldByName("assertions"));
+  std::string diff;
+  differencer.ReportDifferencesToString(&diff);
+  if (!differencer.Compare(expected_proto, arg)) {
+    *result_listener << "\nDifference:\n" << diff;
+    return false;
+  }
+  return true;
+}
+
+// A custom reader that supports size but fails on read.
+class FailingReader : public riegeli::Reader {
+ public:
+  explicit FailingReader(riegeli::Closed) : Reader(riegeli::kClosed) {}
+
+  FailingReader() = default;
+
+  bool PullSlow(size_t min_length, size_t recommended_length) override {
+    return Fail(absl::InternalError("Simulated read error"));
+  }
+
+  bool SupportsSize() override { return true; }
+  std::optional<riegeli::Position> SizeImpl() override { return 10; }
+};
+
+struct DataHashValidatorTestCase {
+  std::string test_name;
+  std::string contents;
+  DataHashAssertion data_hash_assertion;
+  std::optional<std::string> hash;
+  AssetByteInfo asset_byte_info;
+  ValidationStatusSet expected_validation_result;
+};
+using DataHashValidatorTest = TestWithParam<DataHashValidatorTestCase>;
+
+// ------------------------------------------------
+
+TEST(DataHashHardBindingValidatorTest, MissingAssertion) {
+  auto input = riegeli::StringReader<std::string>("");
+  auto partial_result = std::make_unique<
+      PartialValidationResultProto>(ParseTextProtoOrDie(R"pb(
+    active_manifest: {
+      label: "urn:uuid:1234"
+      assertions: {
+        label: "another_data_hash_assertion"
+        data_hash: { alg: "sha256" hash: "\x11\x22\x33" pad: "\x00\x00" }
+      }
+    }
+    hard_binding_uri: "self#jumbf=/c2pa/urn:uuid:1234/c2pa.assertions/data_hash_assertion"
+  )pb"));
+
+  EXPECT_THAT(DataHashHardBindingValidator().Validate(
+                  input, AssetByteInfo(), std::move(partial_result)),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("missing assertion")));
+}
+
+TEST(DataHashHardBindingValidatorTest, NotDataHashAssertion) {
+  auto input = riegeli::StringReader<std::string>("");
+  auto partial_result = std::make_unique<
+      PartialValidationResultProto>(ParseTextProtoOrDie(R"pb(
+    active_manifest: {
+      label: "urn:uuid:1234"
+      assertions: {
+        label: "bmff_based_hash_assertion"
+        bmff_based_hash: {
+          alg: "some alg",
+          name: "Example `c2pa.hash.bmff.v2` assertion"
+          hash: "\x11\x22\x33'"
+        }
+      }
+    }
+    hard_binding_uri: "self#jumbf=/c2pa/urn:uuid:1234/c2pa.assertions/bmff_based_hash_assertion"
+  )pb"));
+
+  EXPECT_THAT(DataHashHardBindingValidator().Validate(
+                  input, AssetByteInfo(), std::move(partial_result)),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("not a data hash assertion")));
+}
+
+TEST(DataHashHardBindingValidatorTest, InvalidAlgorithm) {
+  auto input = riegeli::StringReader<std::string>("");
+
+  auto partial_validation_result = std::make_unique<
+      PartialValidationResultProto>(ParseTextProtoOrDie(R"pb(
+    active_manifest {
+      label: "manifest_label"
+      assertions {
+        label: "data_hash_assertion"
+        data_hash { hash: "\x11\x22\x33" alg: "some algo" }
+      }
+    }
+    hard_binding_uri: "self#jumbf=/c2pa/manifest_label/c2pa.assertions/data_hash_assertion"
+  )pb"));
+  EXPECT_THAT(
+      DataHashHardBindingValidator().Validate(
+          input, AssetByteInfo(), std::move(partial_validation_result)),
+      IsOkAndHolds(Pointee(EqualsProtoIgnoringAssertions(R"pb(
+        active_manifest {
+          label: "manifest_label"
+          validation {
+            failures {
+              code: "algorithm.unsupported"
+              url: "self#jumbf=/c2pa/manifest_label/c2pa.assertions/data_hash_assertion"
+            }
+          }
+        }
+      )pb"))));
+}
+
+TEST(DataHashHardBindingValidatorTest, ClosedReaderRecordsGeneralError) {
+  auto input = riegeli::StringReader<std::string>("");
+  EXPECT_TRUE(input.Close());
+
+  auto partial_validation_result = std::make_unique<
+      PartialValidationResultProto>(ParseTextProtoOrDie(R"pb(
+    active_manifest {
+      label: "manifest_label"
+      assertions {
+        label: "data_hash_assertion"
+        data_hash { hash: "123" alg: "sha256" }
+      }
+    }
+    hard_binding_uri: "self#jumbf=/c2pa/manifest_label/c2pa.assertions/data_hash_assertion"
+  )pb"));
+  EXPECT_THAT(
+      DataHashHardBindingValidator().Validate(
+          input, AssetByteInfo(), std::move(partial_validation_result)),
+      IsOkAndHolds(Pointee(EqualsProtoIgnoringAssertions(R"pb(
+        active_manifest {
+          label: "manifest_label"
+          validation {
+            failures {
+              code: "com.google.internal.error"
+              url: "self#jumbf=/c2pa/manifest_label/c2pa.assertions/data_hash_assertion"
+              explanation: "Asset size cannot be determined"
+            }
+          }
+        }
+      )pb"))));
+}
+
+TEST(DataHashHardBindingValidatorTest, FailingReaderRecordsGeneralError) {
+  FailingReader input;
+
+  auto partial_validation_result = std::make_unique<
+      PartialValidationResultProto>(ParseTextProtoOrDie(R"pb(
+    active_manifest {
+      label: "manifest_label"
+      assertions {
+        label: "data_hash_assertion"
+        data_hash { hash: "123" alg: "sha256" }
+      }
+    }
+    hard_binding_uri: "self#jumbf=/c2pa/manifest_label/c2pa.assertions/data_hash_assertion"
+  )pb"));
+  EXPECT_THAT(
+      DataHashHardBindingValidator().Validate(
+          input, AssetByteInfo(), std::move(partial_validation_result)),
+      IsOkAndHolds(Pointee(EqualsProtoIgnoringAssertions(R"pb(
+        active_manifest {
+          label: "manifest_label"
+          validation {
+            failures {
+              code: "general.error"
+              url: "self#jumbf=/c2pa/manifest_label/c2pa.assertions/data_hash_assertion"
+              explanation: "Simulated read error; at byte 0"
+            }
+          }
+        }
+      )pb"))));
+}
+
+TEST(DataHashHardBindingValidatorTest, NoManifestStoreLocation) {
+  auto input =
+      riegeli::StringReader<std::string>("adding_some_data_to_the_input");
+
+  auto partial_validation_result = std::make_unique<
+      PartialValidationResultProto>(ParseTextProtoOrDie(R"pb(
+    active_manifest {
+      label: "manifest_label"
+      assertions {
+        label: "data_hash_assertion"
+        data_hash {
+          hash: "\x11\x22\x33"
+          alg: "sha256"
+          exclusions: { start: 1, length: 4 },
+        }
+      }
+    }
+    hard_binding_uri: "self#jumbf=/c2pa/manifest_label/c2pa.assertions/data_hash_assertion"
+  )pb"));
+  absl::StatusOr<std::unique_ptr<StatusTracker>> tracker =
+      DualStatusTracker::FromPartialValidationResult(
+          partial_validation_result.get());
+  ABSL_ASSERT_OK(tracker);
+  DataHashHardBindingValidator().Validate(
+      input, std::nullopt,
+      partial_validation_result->active_manifest().assertions(0).data_hash(),
+      partial_validation_result->hard_binding_uri(), **tracker,
+      /*assertion_in_ingredient_manifest=*/false);
+
+  EXPECT_THAT(
+      partial_validation_result->active_manifest().validation(),
+      EqualsProto(R"pb(
+        failures {
+          code: "assertion.dataHash.mismatch"
+          url: "self#jumbf=/c2pa/manifest_label/c2pa.assertions/data_hash_assertion"
+        }
+      )pb"));
+}
+
+TEST(DataHashHardBindingValidatorTest, TestTruncatedWindow) {
+  auto input = riegeli::StringReader<std::string>("---ab___cd----------");
+
+  auto partial_validation_result = std::make_unique<
+      PartialValidationResultProto>(ParseTextProtoOrDie(R"pb(
+    active_manifest {
+      label: "manifest_label"
+      assertions {
+        label: "data_hash_assertion"
+        data_hash {
+          hash: "\x11\x22\x33"
+          alg: "sha256"
+          exclusions: { start: 2, length: 3 },
+        }
+      }
+    }
+    hard_binding_uri: "self#jumbf=/c2pa/manifest_label/c2pa.assertions/data_hash_assertion"
+  )pb"));
+
+  absl::Base64Unescape("iNQmb9TmM40TuEX88olXnSCciXgjuSF9o+Fhk28DFYk=",
+                       partial_validation_result->mutable_active_manifest()
+                           ->mutable_assertions(0)
+                           ->mutable_data_hash()
+                           ->mutable_hash());
+  absl::Base64Unescape("0000",
+                       partial_validation_result->mutable_active_manifest()
+                           ->mutable_assertions(0)
+                           ->mutable_data_hash()
+                           ->mutable_pad());
+
+  absl::StatusOr<std::unique_ptr<StatusTracker>> tracker =
+      DualStatusTracker::FromPartialValidationResult(
+          partial_validation_result.get());
+  ABSL_ASSERT_OK(tracker);
+  DataHashHardBindingValidator().Validate(
+      input, std::nullopt,
+      partial_validation_result->active_manifest().assertions(0).data_hash(),
+      partial_validation_result->hard_binding_uri(), **tracker,
+      /*assertion_in_ingredient_manifest=*/false, /*start_offset=*/3,
+      /*end_offset=*/10);
+
+  EXPECT_THAT(
+      partial_validation_result->active_manifest().validation(),
+      EqualsProto(R"pb(
+        successes {
+          code: "assertion.dataHash.match"
+          url: "self#jumbf=/c2pa/manifest_label/c2pa.assertions/data_hash_assertion"
+        }
+      )pb"));
+}
+
+TEST(DataHashHardBindingValidatorTest, NegativeOffsetAdjustment) {
+  auto input =
+      riegeli::StringReader<std::string>("012345678901234567890123456789");
+
+  auto partial_validation_result = std::make_unique<
+      PartialValidationResultProto>(ParseTextProtoOrDie(R"pb(
+    active_manifest {
+      label: "manifest_label"
+      assertions {
+        label: "data_hash_assertion"
+        data_hash {
+          hash: "\x11\x22\x33"
+          alg: "sha256"
+          exclusions: { start: 10,
+                        length: 10 }           # This will set offset_adjustment
+          exclusions: { start: 1, length: 1 }  # This should trigger the failure
+        }
+      }
+    }
+    hard_binding_uri: "self#jumbf=/c2pa/manifest_label/c2pa.assertions/data_hash_assertion"
+  )pb"));
+
+  absl::StatusOr<std::unique_ptr<StatusTracker>> tracker =
+      DualStatusTracker::FromPartialValidationResult(
+          partial_validation_result.get());
+  ABSL_ASSERT_OK(tracker);
+  DataHashHardBindingValidator().Validate(
+      input, /*manifest_store_location=*/{{.offset = 20, .length = 5}},
+      partial_validation_result->active_manifest().assertions(0).data_hash(),
+      partial_validation_result->hard_binding_uri(), **tracker,
+      /*assertion_in_ingredient_manifest=*/true, /*start_offset=*/10,
+      /*end_offset=*/30);
+
+  EXPECT_THAT(
+      partial_validation_result->active_manifest().validation(),
+      EqualsProto(R"pb(
+        failures {
+          code: "assertion.dataHash.malformed"
+          url: "self#jumbf=/c2pa/manifest_label/c2pa.assertions/data_hash_assertion"
+        }
+      )pb"));
+}
+
+TEST_P(DataHashValidatorTest, ValidateBindingHashData) {
+  const DataHashValidatorTestCase& test_case = GetParam();
+  auto input = riegeli::StringReader<>(test_case.contents);
+
+  auto partial_validation_result = std::make_unique<
+      PartialValidationResultProto>(ParseTextProtoOrDie(R"pb(
+    active_manifest {
+      label: "manifest_label"
+      assertions { label: "data_hash_assertion" }
+    }
+    hard_binding_uri: "self#jumbf=/c2pa/manifest_label/c2pa.assertions/data_hash_assertion"
+  )pb"));
+  partial_validation_result->mutable_active_manifest()
+      ->mutable_assertions(0)
+      ->mutable_data_hash()
+      ->MergeFrom(test_case.data_hash_assertion);
+  if (test_case.hash.has_value()) {
+    absl::Base64Unescape(*test_case.hash,
+                         partial_validation_result->mutable_active_manifest()
+                             ->mutable_assertions(0)
+                             ->mutable_data_hash()
+                             ->mutable_hash());
+    absl::Base64Unescape("0000",
+                         partial_validation_result->mutable_active_manifest()
+                             ->mutable_assertions(0)
+                             ->mutable_data_hash()
+                             ->mutable_pad());
+  }
+  AssetByteInfo asset_byte_info = test_case.asset_byte_info;
+  ValidationResultProto expected_validation_result = ParseTextProtoOrDie(R"pb(
+    active_manifest {
+      label: "manifest_label"
+      validation {}
+    }
+  )pb");
+  *expected_validation_result.mutable_active_manifest()->mutable_validation() =
+      test_case.expected_validation_result;
+  EXPECT_THAT(DataHashHardBindingValidator().Validate(
+                  input, asset_byte_info, std::move(partial_validation_result)),
+              IsOkAndHolds(Pointee(
+                  EqualsProtoIgnoringAssertions(expected_validation_result))));
+}
+
+INSTANTIATE_TEST_SUITE_P(
+    DataHashHardBindingDataHashValidatorTest, DataHashValidatorTest,
+    testing::ValuesIn<DataHashValidatorTestCase>({
+        {
+            .test_name = "Sha256",
+            .contents = "abcd",
+            .data_hash_assertion = ParseTextProtoOrDie(R"pb(
+              hash: "\x11\x22\x33"
+              alg: "sha256"
+            )pb"),
+            .hash = "iNQmb9TmM40TuEX88olXnSCciXgjuSF9o+Fhk28DFYk=",
+            .asset_byte_info = AssetByteInfo(),
+            .expected_validation_result = ParseTextProtoOrDie(R"pb(
+              successes {
+                code: "assertion.dataHash.match"
+                url: "self#jumbf=/c2pa/manifest_label/c2pa.assertions/data_hash_assertion"
+              }
+            )pb"),
+        },
+        {
+            .test_name = "Sha384",
+            .contents = "abcd",
+            .data_hash_assertion = ParseTextProtoOrDie(R"pb(
+              hash: "\x11\x22\x33"
+              alg: "sha384"
+            )pb"),
+            .hash = "EWWzQG/"
+                    "wtSo9JHIfeFRiyiJ2yfRUoRbCsrogFxp5BepaAmaC62WcTV8RXDY6o8eb",
+            .asset_byte_info = AssetByteInfo(),
+            .expected_validation_result = ParseTextProtoOrDie(R"pb(
+              successes {
+                code: "assertion.dataHash.match"
+                url: "self#jumbf=/c2pa/manifest_label/c2pa.assertions/data_hash_assertion"
+              }
+            )pb"),
+        },
+        {
+            .test_name = "Sha512",
+            .contents = "abcd",
+            .data_hash_assertion = ParseTextProtoOrDie(R"pb(
+              hash: "\x11\x22\x33"
+              alg: "sha512"
+            )pb"),
+            .hash = "2AIvIGCtbv0perc9zFNVybIUBUsNF3ahNqZp0mp9OxT3OqDQ6/"
+                    "8Z7jMzaPAWS2QZqW2knj5IF1Pn6Wtxa9zLbw==",
+            .asset_byte_info = AssetByteInfo(),
+            .expected_validation_result = ParseTextProtoOrDie(R"pb(
+              successes {
+                code: "assertion.dataHash.match"
+                url: "self#jumbf=/c2pa/manifest_label/c2pa.assertions/data_hash_assertion"
+              }
+            )pb"),
+        },
+        {
+            .test_name = "NoExclusionRange",
+            .contents = "",
+            .data_hash_assertion = ParseTextProtoOrDie(R"pb(
+              hash: "\x11\x22\x33"
+              alg: "sha256"
+              pad: "\x00\x00"
+            )pb"),
+            .hash = std::nullopt,
+            .asset_byte_info = AssetByteInfo(),
+            .expected_validation_result = ParseTextProtoOrDie(R"pb(
+              failures {
+                code: "assertion.dataHash.mismatch"
+                url: "self#jumbf=/c2pa/manifest_label/c2pa.assertions/data_hash_assertion"
+              }
+            )pb"),
+        },
+        {
+            .test_name = "ExcludesManifestStore",
+            .contents = "a___bcd",
+            .data_hash_assertion = ParseTextProtoOrDie(R"pb(
+              hash: "\x11\x22\x33",
+              alg: "sha256",
+              exclusions: { start: 1, length: 3 },
+            )pb"),
+            .hash = "iNQmb9TmM40TuEX88olXnSCciXgjuSF9o+Fhk28DFYk=",
+            .asset_byte_info = {.manifest_store_location = {.offset = 1,
+                                                            .length = 3}},
+            .expected_validation_result = ParseTextProtoOrDie(R"pb(
+              successes {
+                code: "assertion.dataHash.match"
+                url: "self#jumbf=/c2pa/manifest_label/c2pa.assertions/data_hash_assertion"
+              }
+            )pb"),
+        },
+        {
+            .test_name = "ExcludesManifestStoreAndAdditionalExclusions",
+            .contents = "a-b______c-d",
+            .data_hash_assertion = ParseTextProtoOrDie(R"pb(
+              hash: "\x11\x22\x33",
+              alg: "sha256",
+              pad: "\x00\x00",
+              exclusions: { start: 1, length: 1 }
+              exclusions: { start: 3, length: 6 }
+              exclusions: { start: 10, length: 1 }
+            )pb"),
+            .hash = "iNQmb9TmM40TuEX88olXnSCciXgjuSF9o+Fhk28DFYk=",
+            .asset_byte_info = {.manifest_store_location = {.offset = 3,
+                                                            .length = 6}},
+            .expected_validation_result = ParseTextProtoOrDie(R"pb(
+              successes {
+                code: "assertion.dataHash.match"
+                url: "self#jumbf=/c2pa/manifest_label/c2pa.assertions/data_hash_assertion"
+              }
+              informationals {
+                code: "assertion.dataHash.additionalExclusionsPresent"
+                url: "self#jumbf=/c2pa/manifest_label/c2pa.assertions/data_hash_assertion"
+              }
+            )pb"),
+        },
+        {
+            .test_name = "NegativeStartOffset",
+            .contents = "ab______cd",
+            .data_hash_assertion = ParseTextProtoOrDie(R"pb(
+              hash: "\x11\x22\x33",
+              alg: "sha256",
+              pad: "\x00\x00",
+              exclusions: { start: -1, length: 10 }
+            )pb"),
+            .hash = std::nullopt,
+            .asset_byte_info = {.manifest_store_location = {.offset = 2,
+                                                            .length = 6}},
+            .expected_validation_result = ParseTextProtoOrDie(R"pb(
+              failures {
+                code: "assertion.dataHash.malformed"
+                url: "self#jumbf=/c2pa/manifest_label/c2pa.assertions/data_hash_assertion"
+              }
+            )pb"),
+        },
+        {
+            .test_name = "NegativeLength",
+            .contents = "ab______cd",
+            .data_hash_assertion = ParseTextProtoOrDie(R"pb(
+              hash: "\x11\x22\x33",
+              alg: "sha256",
+              pad: "\x00\x00",
+              exclusions: { start: 1, length: -10 }
+            )pb"),
+            .hash = std::nullopt,
+            .asset_byte_info = {.manifest_store_location = {.offset = 2,
+                                                            .length = 6}},
+            .expected_validation_result = ParseTextProtoOrDie(R"pb(
+              failures {
+                code: "assertion.dataHash.malformed"
+                url: "self#jumbf=/c2pa/manifest_label/c2pa.assertions/data_hash_assertion"
+              }
+            )pb"),
+        },
+        {
+            .test_name = "EndsPastEndOfAsset",
+            .contents = "ab______cd",
+            .data_hash_assertion = ParseTextProtoOrDie(R"pb(
+              hash: "\x11\x22\x33",
+              alg: "sha256",
+              pad: "\x00\x00",
+              exclusions: { start: 2, length: 6 }
+              exclusions: { start: 9, length: 5 }
+            )pb"),
+            .hash = std::nullopt,
+            .asset_byte_info = {.manifest_store_location = {.offset = 2,
+                                                            .length = 6}},
+            .expected_validation_result = ParseTextProtoOrDie(R"pb(
+              failures {
+                code: "assertion.dataHash.mismatch"
+                url: "self#jumbf=/c2pa/manifest_label/c2pa.assertions/data_hash_assertion"
+              }
+            )pb"),
+        },
+        {
+            .test_name = "OverlappingExclusions",
+            .contents = "ab______cd",
+            .data_hash_assertion = ParseTextProtoOrDie(R"pb(
+              hash: "\x11\x22\x33",
+              alg: "sha256",
+              pad: "\x00\x00",
+              exclusions: { start: 1, length: 4 }
+              exclusions: { start: 2, length: 6 }
+            )pb"),
+            .hash = std::nullopt,
+            .asset_byte_info = {.manifest_store_location = {.offset = 2,
+                                                            .length = 6}},
+            .expected_validation_result = ParseTextProtoOrDie(R"pb(
+              failures {
+                code: "assertion.dataHash.malformed"
+                url: "self#jumbf=/c2pa/manifest_label/c2pa.assertions/data_hash_assertion"
+              }
+            )pb"),
+        },
+        {
+            .test_name = "BackToBackExclusions",
+            .contents = "ab--______cd",
+            .data_hash_assertion = ParseTextProtoOrDie(R"pb(
+              hash: "\x11\x22\x33",
+              alg: "sha256",
+              pad: "\x00\x00",
+              exclusions: { start: 2, length: 1 }
+              exclusions: { start: 3, length: 1 }
+              exclusions: { start: 4, length: 6 }
+            )pb"),
+            .hash = "iNQmb9TmM40TuEX88olXnSCciXgjuSF9o+Fhk28DFYk=",
+            .asset_byte_info = {.manifest_store_location = {.offset = 4,
+                                                            .length = 6}},
+            .expected_validation_result = ParseTextProtoOrDie(R"pb(
+              successes {
+                code: "assertion.dataHash.match"
+                url: "self#jumbf=/c2pa/manifest_label/c2pa.assertions/data_hash_assertion"
+              }
+              informationals {
+                code: "assertion.dataHash.additionalExclusionsPresent"
+                url: "self#jumbf=/c2pa/manifest_label/c2pa.assertions/data_hash_assertion"
+              }
+            )pb"),
+        },
+        {
+            .test_name = "ExclusionsNotInOrder",
+            .contents = "ab-c______d",
+            .data_hash_assertion = ParseTextProtoOrDie(R"pb(
+              hash: "\x11\x22\x33",
+              alg: "sha256",
+              pad: "\x00\x00",
+              exclusions: { start: 3, length: 1 }
+              exclusions: { start: 2, length: 1 }
+              exclusions: { start: 4, length: 6 }
+            )pb"),
+            .hash = "iNQmb9TmM40TuEX88olXnSCciXgjuSF9o+Fhk28DFYk=",
+            .asset_byte_info = {.manifest_store_location = {.offset = 4,
+                                                            .length = 6}},
+            .expected_validation_result = ParseTextProtoOrDie(R"pb(
+              failures {
+                code: "assertion.dataHash.malformed"
+                url: "self#jumbf=/c2pa/manifest_label/c2pa.assertions/data_hash_assertion"
+              }
+            )pb"),
+        },
+    }),
+    [](const testing::TestParamInfo<DataHashValidatorTest::ParamType>& info) {
+      return info.param.test_name;
+    });
+
+}  // namespace
+}  // namespace credentio
diff --git a/bindings/input_hasher_test.cc b/bindings/input_hasher_test.cc
index 9839bc5..1291fbf 100644
--- a/bindings/input_hasher_test.cc
+++ b/bindings/input_hasher_test.cc
@@ -76,14 +76,14 @@
   EXPECT_CALL(*mock_hasher, Digest()).WillOnce(Return("mock_digest"));
 
   auto input_hasher = InputHasher::Create(std::move(mock_hasher));
-  ASSERT_THAT(input_hasher, IsOk());
+  ABSL_ASSERT_OK(input_hasher);
   (*input_hasher)->Update("test");
   EXPECT_THAT((*input_hasher)->Digest(), Eq("mock_digest"));
 }
 
 TEST(InputHasherTest, UpdateStringView) {
   auto hasher = InputHasher::Create("sha256");
-  ASSERT_THAT(hasher, IsOk());
+  ABSL_ASSERT_OK(hasher);
   (*hasher)->Update("hello");
   (*hasher)->Update(" ");
   (*hasher)->Update("world");
@@ -94,7 +94,7 @@
 
 TEST(InputHasherTest, UpdateRiegeliEmpty) {
   auto hasher = InputHasher::Create("sha256");
-  ASSERT_THAT(hasher, IsOk());
+  ABSL_ASSERT_OK(hasher);
   std::string data = "";
   riegeli::StringReader reader(data);
   EXPECT_THAT((*hasher)->Update(reader, 0, 0), IsOk());
@@ -105,7 +105,7 @@
 
 TEST(InputHasherTest, UpdateRiegeliSmall) {
   auto hasher = InputHasher::Create("sha256");
-  ASSERT_THAT(hasher, IsOk());
+  ABSL_ASSERT_OK(hasher);
   std::string data = "small data";
   riegeli::StringReader reader(data);
   EXPECT_THAT((*hasher)->Update(reader, 0, data.length()), IsOk());
@@ -116,7 +116,7 @@
 
 TEST(InputHasherTest, UpdateRiegeliLarge) {
   auto hasher = InputHasher::Create("sha256");
-  ASSERT_THAT(hasher, IsOk());
+  ABSL_ASSERT_OK(hasher);
   std::string data(20000, 'a');  // Larger than default chunk size
   riegeli::StringReader reader(data);
   EXPECT_THAT((*hasher)->Update(reader, 0, data.length()), IsOk());
@@ -127,7 +127,7 @@
 
 TEST(InputHasherTest, UpdateRiegeliWithOffset) {
   auto hasher = InputHasher::Create("sha256");
-  ASSERT_THAT(hasher, IsOk());
+  ABSL_ASSERT_OK(hasher);
   std::string data = "prefix_and_data";
   riegeli::StringReader reader(data);
   EXPECT_THAT((*hasher)->Update(reader, 7, 3), IsOk());  // "and"
@@ -138,7 +138,7 @@
 
 TEST(InputHasherTest, UpdateRiegeliMultiple) {
   auto hasher = InputHasher::Create("sha256");
-  ASSERT_THAT(hasher, IsOk());
+  ABSL_ASSERT_OK(hasher);
   std::string data1 = "first part";
   std::string data2 = " second part";
 
@@ -156,7 +156,7 @@
 
 TEST(InputHasherTest, UpdateRiegeliClosedReaderFails) {
   auto hasher = InputHasher::Create("sha256");
-  ASSERT_THAT(hasher, IsOk());
+  ABSL_ASSERT_OK(hasher);
   riegeli::StringReader reader("test");
   reader.Close();
 
@@ -167,7 +167,7 @@
 
 TEST(InputHasherTest, UpdateRiegeliOffsetOutOfRange) {
   auto hasher = InputHasher::Create("sha256");
-  ASSERT_THAT(hasher, IsOk());
+  ABSL_ASSERT_OK(hasher);
   riegeli::StringReader reader("test");
 
   EXPECT_THAT(
@@ -177,7 +177,7 @@
 
 TEST(InputHasherTest, UpdateRiegeliLengthExceedsSourceFails) {
   auto hasher = InputHasher::Create("sha256");
-  ASSERT_THAT(hasher, IsOk());
+  ABSL_ASSERT_OK(hasher);
   riegeli::StringReader reader("test");
 
   EXPECT_THAT((*hasher)->Update(reader, 0, 100),
diff --git a/bindings/merkle_validator.cc b/bindings/merkle_validator.cc
index dc04cbc..c359e64 100644
--- a/bindings/merkle_validator.cc
+++ b/bindings/merkle_validator.cc
@@ -237,24 +237,52 @@
 
     std::string computed_hash = std::move(leaf_hash.value());
 
-    uint64_t running_row_index = i;
     if (!auxiliary_merkle_maps.empty()) {
-      BmffMerkleMap auxiliary = auxiliary_merkle_maps[i];
-      int64_t location = auxiliary.location();
-      uint64_t lvl = 0;
-      int64_t trailing_boundary = (merkle.count() - 1) & ~1LL;
-      for (const auto& hash : auxiliary.hashes()) {
-        bool sibling_on_left = (running_row_index % 2 == 1) ||
-                               (location >= trailing_boundary && lvl >= 1);
-        if (sibling_on_left) {
-          ABSL_ASSIGN_OR_RETURN(computed_hash,
-                                JoinHashes(**factory, hash, computed_hash));
-        } else {
-          ABSL_ASSIGN_OR_RETURN(computed_hash,
-                                JoinHashes(**factory, computed_hash, hash));
+      const BmffMerkleMap& auxiliary = auxiliary_merkle_maps[i];
+      int64_t current_index = auxiliary.location();
+      int64_t current_count = merkle.count();
+      int proof_index = 0;
+
+      for (int64_t r = 0; r < derived_data->delta_rows; ++r) {
+        bool has_sibling = false;
+        bool sibling_on_left = false;
+
+        if (current_index % 2 == 1) {
+          has_sibling = true;
+          sibling_on_left = true;
+        } else if (current_index + 1 < current_count) {
+          has_sibling = true;
+          sibling_on_left = false;
         }
-        running_row_index >>= 1;
-        ++lvl;
+
+        if (has_sibling) {
+          if (proof_index >= auxiliary.hashes_size()) {
+            tracker.RecordFailure(
+                FailureStatusCode::kAssertionBmffHashMalformed,
+                {.url = assertion_uri_,
+                 .explanation = "missing auxiliary proof hash"});
+            return absl::InvalidArgumentError("missing auxiliary proof hash");
+          }
+          absl::string_view hash = auxiliary.hashes(proof_index++);
+          if (sibling_on_left) {
+            ABSL_ASSIGN_OR_RETURN(computed_hash,
+                                  JoinHashes(**factory, hash, computed_hash));
+          } else {
+            ABSL_ASSIGN_OR_RETURN(computed_hash,
+                                  JoinHashes(**factory, computed_hash, hash));
+          }
+        }
+
+        current_index >>= 1;
+        current_count = (current_count + 1) >> 1;
+      }
+
+      if (proof_index != auxiliary.hashes_size()) {
+        tracker.RecordFailure(
+            FailureStatusCode::kAssertionBmffHashMalformed,
+            {.url = assertion_uri_,
+             .explanation = "surplus auxiliary proof hashes"});
+        return absl::InvalidArgumentError("surplus auxiliary proof hashes");
       }
     }
 
diff --git a/bindings/merkle_validator_test.cc b/bindings/merkle_validator_test.cc
index 009133e..4f43c6b 100644
--- a/bindings/merkle_validator_test.cc
+++ b/bindings/merkle_validator_test.cc
@@ -17,6 +17,7 @@
 
 #include <algorithm>
 #include <cctype>
+#include <cstddef>
 #include <memory>
 #include <string>
 #include <utility>
@@ -25,8 +26,11 @@
 #include "absl/log/log.h"
 #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/str_format.h"
+#include "absl/strings/str_join.h"
 #include "absl/strings/string_view.h"
 #include "constants/status_codes.h"
 #include "crypto/algorithms.h"
@@ -170,7 +174,7 @@
   void SetUp() override {
     auto input_reader =
         std::make_unique<riegeli::StringReader<>>(GetParam().file_contents);
-    ASSERT_THAT(input_reader->status(), IsOk());
+    ABSL_ASSERT_OK(input_reader->status());
     input_ = std::move(input_reader);
 
     factory_ = std::make_unique<JoiningHasherFactoryProvider>();
@@ -259,6 +263,72 @@
                       "fragmented merkle validations are not supported yet"}},
         },
         MerkleValidatorTestCase{
+            .name = "NonPowerOfTwoSuccess",
+            .file_contents = []() -> std::string {
+              std::string aux_box_1 = cbor::FromJson(R"json({
+  "hashes": [ "b64'Yg=='", "b64'Y2Q='", "b64'ZQ=='" ],
+  "localId": 1,
+  "location": 0,
+  "uniqueId": 1
+})json");
+              std::string aux_box_2 = cbor::FromJson(R"json({
+  "hashes": [ "b64'YQ=='", "b64'Y2Q='", "b64'ZQ=='" ],
+  "localId": 1,
+  "location": 1,
+  "uniqueId": 1
+})json");
+              std::string aux_box_3 = cbor::FromJson(R"json({
+  "hashes": [ "b64'ZA=='", "b64'YWI='", "b64'ZQ=='" ],
+  "localId": 1,
+  "location": 2,
+  "uniqueId": 1
+})json");
+              std::string aux_box_4 = cbor::FromJson(R"json({
+  "hashes": [ "b64'Yw=='", "b64'YWI='", "b64'ZQ=='" ],
+  "localId": 1,
+  "location": 3,
+  "uniqueId": 1
+})json");
+              std::string aux_box_5 = cbor::FromJson(R"json({
+  "hashes": [ "b64'YWJjZA=='" ],
+  "localId": 1,
+  "location": 4,
+  "uniqueId": 1
+})json");
+
+              return absl::StrCat(
+                  credentio_testing::Box("mdat", "abcde"),
+                  credentio_testing::Box(
+                      "uuid",
+                      credentio_testing::C2paBoxPayload("merkle", aux_box_1)),
+                  credentio_testing::Box(
+                      "uuid",
+                      credentio_testing::C2paBoxPayload("merkle", aux_box_2)),
+                  credentio_testing::Box(
+                      "uuid",
+                      credentio_testing::C2paBoxPayload("merkle", aux_box_3)),
+                  credentio_testing::Box(
+                      "uuid",
+                      credentio_testing::C2paBoxPayload("merkle", aux_box_4)),
+                  credentio_testing::Box(
+                      "uuid",
+                      credentio_testing::C2paBoxPayload("merkle", aux_box_5)));
+            }(),
+            .assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb(
+              alg: "sha256"
+              hash: "unused"
+              merkles {
+                unique_id: 1
+                local_id: 1
+                count: 5
+                fixed_block_size: 1
+                hashes: "abcde"
+              }
+            )pb"),
+            .expected_status = absl::OkStatus(),
+            .expected_failures = {},
+        },
+        MerkleValidatorTestCase{
             .name = "FailsHashesBelowLeafs",
             .file_contents = credentio_testing::Box("mdat", "abcdef"),
             .assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb(
@@ -590,7 +660,7 @@
 TEST(DefaultHasherFactoryProviderTest, CreateSha256) {
   DefaultHasherFactoryProvider provider;
   auto factory = provider.Create("sha256");
-  ASSERT_THAT(factory, IsOk());
+  ABSL_ASSERT_OK(factory);
   EXPECT_THAT(*factory, NotNull());
   EXPECT_EQ((*factory)->algorithm(), HashAlgorithm::kSha256);
 }
@@ -598,7 +668,7 @@
 TEST(DefaultHasherFactoryProviderTest, CreateSha384) {
   DefaultHasherFactoryProvider provider;
   auto factory = provider.Create("sha384");
-  ASSERT_THAT(factory, IsOk());
+  ABSL_ASSERT_OK(factory);
   EXPECT_THAT(*factory, NotNull());
   EXPECT_EQ((*factory)->algorithm(), HashAlgorithm::kSha384);
 }
@@ -606,7 +676,7 @@
 TEST(DefaultHasherFactoryProviderTest, CreateSha512) {
   DefaultHasherFactoryProvider provider;
   auto factory = provider.Create("sha512");
-  ASSERT_THAT(factory, IsOk());
+  ABSL_ASSERT_OK(factory);
   EXPECT_THAT(*factory, NotNull());
   EXPECT_EQ((*factory)->algorithm(), HashAlgorithm::kSha512);
 }
@@ -617,5 +687,135 @@
               StatusIs(absl::StatusCode::kInvalidArgument));
 }
 
+void TestTreeValidationPermutations(int leaf_count, int delta_rows) {
+  DefaultHasherFactoryProvider provider;
+  auto factory_status = provider.Create("sha256");
+  ABSL_ASSERT_OK(factory_status);
+  auto factory = std::move(*factory_status);
+
+  constexpr int kBlockSize = 4;
+
+  std::string mdat_payload;
+  mdat_payload.reserve(leaf_count * kBlockSize);
+  for (int i = 0; i < leaf_count; ++i) {
+    mdat_payload.append(absl::StrFormat("%04d", i));
+  }
+
+  std::vector<std::vector<std::string>> rows;
+  std::vector<std::string> leaves;
+  leaves.reserve(leaf_count);
+  for (int i = 0; i < leaf_count; ++i) {
+    auto hasher = factory->Create();
+    ABSL_ASSERT_OK(hasher);
+    (*hasher)->Update(mdat_payload.substr(i * kBlockSize, kBlockSize));
+    leaves.push_back((*hasher)->Digest());
+  }
+
+  rows.push_back(std::move(leaves));
+  for (int r = 0; r < delta_rows; ++r) {
+    const auto& current_row = rows.back();
+    if (current_row.size() <= 1) break;
+    std::vector<std::string> next_row;
+    size_t i = 0;
+    while (i < current_row.size()) {
+      if (i + 1 < current_row.size()) {
+        auto hasher = factory->Create();
+        ABSL_ASSERT_OK(hasher);
+        (*hasher)->Update(current_row[i]);
+        (*hasher)->Update(current_row[i + 1]);
+        next_row.push_back((*hasher)->Digest());
+        i += 2;
+      } else {
+        next_row.push_back(current_row[i]);
+        i += 1;
+      }
+    }
+    rows.push_back(std::move(next_row));
+  }
+
+  int actual_delta_rows = rows.size() - 1;
+  const std::vector<std::string>& target_row = rows.back();
+
+  std::string aux_boxes;
+  if (actual_delta_rows > 0) {
+    for (int leaf_idx = 0; leaf_idx < leaf_count; ++leaf_idx) {
+      std::vector<std::string> proof_hashes;
+      int curr_idx = leaf_idx;
+      for (int r = 0; r < actual_delta_rows; ++r) {
+        const auto& row = rows[r];
+        if (curr_idx % 2 == 1) {
+          proof_hashes.push_back(row[curr_idx - 1]);
+        } else if (curr_idx + 1 < row.size()) {
+          proof_hashes.push_back(row[curr_idx + 1]);
+        }
+        curr_idx /= 2;
+      }
+
+      std::vector<std::string> hash_entries;
+      for (const auto& h : proof_hashes) {
+        hash_entries.push_back(
+            absl::StrCat("\"b64'", absl::Base64Escape(h), "'\""));
+      }
+      std::string hashes_field =
+          hash_entries.empty()
+              ? ""
+              : absl::StrCat("\"hashes\": [", absl::StrJoin(hash_entries, ", "),
+                             "],");
+
+      std::string json = absl::StrFormat(
+          R"json({
+  %s
+  "localId": 1,
+  "location": %d,
+  "uniqueId": 1
+})json",
+          hashes_field, leaf_idx);
+
+      std::string cbor_bytes = cbor::FromJson(json);
+      absl::StrAppend(&aux_boxes, credentio_testing::Box(
+                                      "uuid", credentio_testing::C2paBoxPayload(
+                                                  "merkle", cbor_bytes)));
+    }
+  }
+
+  std::string file_contents =
+      absl::StrCat(credentio_testing::Box("mdat", mdat_payload), aux_boxes);
+
+  BmffBasedHashAssertion assertion;
+  assertion.set_alg("sha256");
+  assertion.set_hash("unused");
+  auto* merkle = assertion.add_merkles();
+  merkle->set_unique_id(1);
+  merkle->set_local_id(1);
+  merkle->set_count(leaf_count);
+  merkle->set_fixed_block_size(kBlockSize);
+  for (const auto& h : target_row) {
+    merkle->add_hashes(h);
+  }
+
+  riegeli::StringReader reader(file_contents);
+  MerkleValidator validator(reader, assertion, "assertion_uri");
+  TrackingStatusTracker tracker;
+  auto status = validator.Validate(tracker);
+  EXPECT_THAT(status, IsOk()) << "Failed for leaf_count=" << leaf_count
+                              << ", delta_rows=" << delta_rows;
+  EXPECT_THAT(tracker.failures_, IsEmpty())
+      << "Failures recorded for leaf_count=" << leaf_count
+      << ", delta_rows=" << delta_rows;
+}
+
+TEST(MerkleValidatorTest, ValidatesMerkleTreePermutations) {
+  const std::vector<int> leaf_counts = {1, 2, 3, 4, 5, 7, 8, 15, 26, 100};
+  const std::vector<int> delta_rows_list = {0, 1, 2, 3, 10};
+
+  for (int leaf_count : leaf_counts) {
+    for (int delta_rows : delta_rows_list) {
+      SCOPED_TRACE(absl::StrFormat("leaf_count: %d, delta_rows: %d", leaf_count,
+                                   delta_rows));
+      TestTreeValidationPermutations(leaf_count, delta_rows);
+    }
+  }
+}
+
 }  // namespace
 }  // namespace credentio
diff --git a/cbor/cbor_generator.cc b/cbor/cbor_generator.cc
index 2faba71..e838419 100644
--- a/cbor/cbor_generator.cc
+++ b/cbor/cbor_generator.cc
@@ -28,6 +28,7 @@
 #include "absl/strings/string_view.h"
 #include "cbor/tags.pb.h"
 #include "google/protobuf/compiler/code_generator.h"
+#include "google/protobuf/compiler/cpp/names.h"
 #include "google/protobuf/descriptor.h"
 #include "google/protobuf/io/printer.h"
 #include "google/protobuf/io/zero_copy_stream.h"
@@ -36,7 +37,7 @@
 namespace {
 
 std::string QualifiedCppName(const google::protobuf::Descriptor* descriptor) {
-  return absl::StrReplaceAll(descriptor->full_name(), {{".", "::"}});
+  return google::protobuf::compiler::cpp::QualifiedClassName(descriptor);
 }
 
 absl::string_view StripProtoExtension(absl::string_view filename) {
diff --git a/cbor/cbor_generator.h b/cbor/cbor_generator.h
index 389ab10..3fbc935 100644
--- a/cbor/cbor_generator.h
+++ b/cbor/cbor_generator.h
@@ -42,7 +42,7 @@
   }
 
   google::protobuf::Edition GetMaximumEdition() const override {
-    return google::protobuf::Edition::EDITION_2024;
+    return google::protobuf::Edition::EDITION_2026;
   }
 };
 
diff --git a/cbor/parse_test.cc b/cbor/parse_test.cc
index f620b15..90e9e5e 100644
--- a/cbor/parse_test.cc
+++ b/cbor/parse_test.cc
@@ -44,20 +44,6 @@
 using ::testing::IsFalse;
 using ::testing::IsTrue;
 
-#ifndef ASSERT_OK_AND_ASSIGN
-#define ASSERT_OK_AND_ASSIGN_CONCAT2(x, y) x##y
-#define ASSERT_OK_AND_ASSIGN_CONCAT(x, y) ASSERT_OK_AND_ASSIGN_CONCAT2(x, y)
-
-#define ASSERT_OK_AND_ASSIGN(lhs, rexpr) \
-  ASSERT_OK_AND_ASSIGN_IMPL(lhs, rexpr, __COUNTER__)
-
-#define ASSERT_OK_AND_ASSIGN_IMPL(lhs, rexpr, id)             \
-  auto ASSERT_OK_AND_ASSIGN_CONCAT(status_or_, id) = (rexpr); \
-  ASSERT_THAT(ASSERT_OK_AND_ASSIGN_CONCAT(status_or_, id),    \
-              ::absl_testing::IsOk());                        \
-  lhs = std::move(*ASSERT_OK_AND_ASSIGN_CONCAT(status_or_, id))
-#endif
-
 TEST(ParseTest, Okay) {
   auto cbor = FromJson(R"json({
       "str": "Google",
@@ -175,11 +161,10 @@
   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));
+  absl::StatusOr<cbor::MapView> map = result->AsMap();
+  ABSL_ASSERT_OK(map);
+  EXPECT_THAT(map->GetString("when"), IsOkAndHolds("2026-05-19T00:00:00Z"));
+  EXPECT_THAT(map->GetUint64("epoch"), IsOkAndHolds(1684454400));
 }
 
 TEST(ParseTest, OptionalString) {
@@ -398,10 +383,13 @@
 
 TEST(ParseTest, TrailingBytesIgnored) {
   auto cbor = FromJson(R"json({"vendor": "Google", "schema": 0})json");
-  ASSERT_OK_AND_ASSIGN(auto result, cbor::Parse(cbor + "trailing bytes"));
-  ASSERT_OK_AND_ASSIGN(auto result_map, result->AsMap());
-  EXPECT_THAT(result_map.GetString("vendor"), IsOkAndHolds("Google"));
-  EXPECT_THAT(result_map.GetUint64("schema"), IsOkAndHolds(0));
+  absl::StatusOr<std::unique_ptr<cbor::ParseResult>> result =
+      cbor::Parse(cbor + "trailing bytes");
+  ABSL_ASSERT_OK(result);
+  absl::StatusOr<cbor::MapView> result_map = (*result)->AsMap();
+  ABSL_ASSERT_OK(result_map);
+  EXPECT_THAT(result_map->GetString("vendor"), IsOkAndHolds("Google"));
+  EXPECT_THAT(result_map->GetUint64("schema"), IsOkAndHolds(0));
 }
 
 void CheckMap(const cbor::MapView& map, absl::string_view str_key,
diff --git a/cbor/test/BUILD b/cbor/test/BUILD
index 1ff1ff3..a61b28b 100644
--- a/cbor/test/BUILD
+++ b/cbor/test/BUILD
@@ -30,11 +30,6 @@
     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"],
@@ -45,11 +40,6 @@
 )
 
 cc_proto_library(
-    name = "external_skip_cc_proto",
-    deps = [":external_skip_proto"],
-)
-
-cc_proto_library(
     name = "external_cc_proto",
     deps = [":external_proto"],
 )
@@ -105,10 +95,34 @@
     ],
 )
 
+proto_library(
+    name = "custom_namespace_proto",
+    srcs = ["custom_namespace.proto"],
+    option_deps = ["@protobuf//src/google/protobuf:cpp_options_proto"],
+    deps = [
+        "//cbor:tags_proto",
+    ],
+)
+
+cc_proto_library(
+    name = "custom_namespace_cc_proto",
+    deps = [":custom_namespace_proto"],
+)
+
+cc_cbor_proto_library(
+    name = "custom_namespace_cbor_proto",
+    srcs = [":custom_namespace_proto"],
+    deps = [
+        ":custom_namespace_cc_proto",
+    ],
+)
+
 cc_test(
     name = "cbor_generator_test",
     srcs = ["cbor_generator_test.cc"],
     deps = [
+        ":custom_namespace_cbor_proto",
+        ":custom_namespace_cc_proto",
         ":external_cc_proto",
         ":migrated_cbor_proto",
         ":test_cbor_proto",
diff --git a/cbor/test/cbor_generator_test.cc b/cbor/test/cbor_generator_test.cc
index 1779716..61f0cb3 100644
--- a/cbor/test/cbor_generator_test.cc
+++ b/cbor/test/cbor_generator_test.cc
@@ -21,6 +21,8 @@
 #include "absl/status/status_matchers.h"
 #include "cbor/cbor.h"
 #include "cbor/options.h"
+#include "cbor/test/custom_namespace.cbor.h"
+#include "cbor/test/custom_namespace.pb.h"
 #include "cbor/test/external.pb.h"
 #include "cbor/test/migrated.cbor.h"  // IWYU pragma: keep
 #include "cbor/test/test.cbor.h"
@@ -34,6 +36,28 @@
 
 using ::testing::Eq;
 
+TEST(CborGeneratorTest, CustomNamespaceRoundTrip) {
+  custom_cbor::test::CustomNamespaceMessage msg;
+  msg.set_name("custom_name");
+  msg.set_version(42);
+
+  // 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
+  custom_cbor::test::CustomNamespaceMessage 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(), "custom_name");
+  EXPECT_EQ(parsed_msg.version(), 42);
+}
+
 TEST(CborGeneratorTest, RoundTrip) {
   TestMessage msg;
   msg.set_name("hello");
diff --git a/cbor/test/custom_namespace.proto b/cbor/test/custom_namespace.proto
new file mode 100644
index 0000000..3af81fa
--- /dev/null
+++ b/cbor/test/custom_namespace.proto
@@ -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.
+//
+
+edition = "2026";
+
+package cbor_test;
+
+import "cbor/tags.proto";
+
+import option "google/protobuf/cpp_options.proto";
+
+option (pb.file.cpp).namespace = "custom_cbor::test";
+
+message CustomNamespaceMessage {
+  string name = 1 [(cbor.tags) = { name: "Name" }];
+  int32 version = 2 [(cbor.tags) = { name: "Version" }];
+}
diff --git a/cbor/test/test.proto b/cbor/test/test.proto
index ff51b3d..6f54634 100644
--- a/cbor/test/test.proto
+++ b/cbor/test/test.proto
@@ -13,7 +13,7 @@
 // limitations under the License.
 //
 
-edition = "2024";
+edition = "2026";
 
 package cbor_test;
 
diff --git a/claim/BUILD b/claim/BUILD
index 82d2821..a945a02 100644
--- a/claim/BUILD
+++ b/claim/BUILD
@@ -77,7 +77,6 @@
         "//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",
@@ -86,7 +85,7 @@
         "//proto:signature_info_cc_proto",
         "//uuid",
         "//validator:tracker",
-        "@abseil-cpp//absl/log",
+        "@abseil-cpp//absl/log:absl_log",
         "@abseil-cpp//absl/log:die_if_null",
         "@abseil-cpp//absl/status",
         "@abseil-cpp//absl/status:statusor",
@@ -123,3 +122,408 @@
         "@googletest//:gtest_main",
     ],
 )
+
+cc_library(
+    name = "signer",
+    hdrs = ["signer.h"],
+    deps = [
+        "//async:async_context",
+        "@abseil-cpp//absl/functional:any_invocable",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings:string_view",
+    ],
+)
+
+cc_library(
+    name = "local_signer",
+    srcs = ["local_signer.cc"],
+    hdrs = ["local_signer.h"],
+    deps = [
+        ":signer",
+        "//async:async_context",
+        "//cose:sig_structure",
+        "//cose:timestamp_creator",
+        "//crypto:crypto_write_handler",
+        "//crypto:hash",
+        "//utils:cbor_padding",
+        "@abseil-cpp//absl/base:nullability",
+        "@abseil-cpp//absl/functional:any_invocable",
+        "@abseil-cpp//absl/log: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",
+        "@abseil-cpp//absl/time",
+    ],
+)
+
+cc_test(
+    name = "local_signer_test",
+    srcs = ["local_signer_test.cc"],
+    deps = [
+        ":local_signer",
+        ":signer",
+        "//async:async_context",
+        "//cose:sig_structure",
+        "//cose:verifier",
+        "//crypto:algorithms",
+        "//crypto:crypto_read_handler",
+        "//crypto:crypto_write_handler",
+        "//crypto:timestamp_write_handler",
+        "//crypto/default:default_crypto_read_handler",
+        "//crypto/default:hasher",
+        "//proto:cose_verification_result_cc_proto",
+        "//proto:signature_info_cc_proto",
+        "//testing:create_test_crypto_write_handler",
+        "//testing:local_key_crypto_write_handler",
+        "//testing:proto_test_utils",
+        "@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:string_view",
+        "@abseil-cpp//absl/synchronization",
+        "@googletest//:gtest_main",
+        "@protobuf//:differencer",
+    ],
+)
+
+cc_library(
+    name = "manifest_store_builder",
+    srcs = ["manifest_store_builder.cc"],
+    hdrs = ["manifest_store_builder.h"],
+    deps = [
+        ":manifest_store_view",
+        "//constants:labels",
+        "//crypto:hash",
+        "//jumbf:box",
+        "//jumbf:box_builder",
+        "//jumbf:encode_params",
+        "//jumbf:utils",
+        "//proto:hashed_uri_cc_proto",
+        "@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:cord",
+        "@abseil-cpp//absl/strings:string_view",
+    ],
+)
+
+cc_test(
+    name = "manifest_store_builder_test",
+    srcs = ["manifest_store_builder_test.cc"],
+    deps = [
+        ":manifest_store_builder",
+        "//assertion:assertion_encoder",
+        "//constants:labels",
+        "//crypto:algorithms",
+        "//crypto:hash",
+        "//crypto/default:hasher",
+        "//jumbf:box",
+        "//jumbf:box_builder",
+        "//jumbf:encode_params",
+        "//jumbf:parse",
+        "//jumbf:test_utils",
+        "//proto:assertion_cc_proto",
+        "//proto:hashed_uri_cc_proto",
+        "//testing:proto_test_utils",
+        "//uuid",
+        "@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:cord",
+        "@abseil-cpp//absl/strings:string_view",
+        "@googletest//:gtest_main",
+    ],
+)
+
+cc_library(
+    name = "manifest_box",
+    hdrs = ["manifest_box.h"],
+    deps = [
+        ":manifest_store_builder",
+        "//proto:assertion_cc_proto",
+        "//proto:generator_cc_proto",
+    ],
+)
+
+cc_library(
+    name = "manifest_box_encoder",
+    srcs = ["manifest_box_encoder.cc"],
+    hdrs = ["manifest_box_encoder.h"],
+    deps = [
+        ":claim_cbor_encoder",
+        ":manifest_box",
+        ":signer",
+        "//assertion:assertion_encoder",
+        "//assertion:assertion_referencer",
+        "//async:async_context",
+        "//constants:labels",
+        "//crypto:algorithms",
+        "//jumbf:box_builder",
+        "//jumbf:constants",
+        "//proto:actions_assertion_cc_proto",
+        "//proto:assertion_cc_proto",
+        "//proto:generator_info_cc_proto",
+        "//proto:hashed_uri_cc_proto",
+        "//proto:manifest_cc_proto",
+        "//proto:multi_asset_hash_assertion_cc_proto",
+        "//uuid",
+        "@abseil-cpp//absl/base:nullability",
+        "@abseil-cpp//absl/container:flat_hash_map",
+        "@abseil-cpp//absl/functional:any_invocable",
+        "@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",
+        "@abseil-cpp//absl/strings:string_view",
+        "@protobuf//:protobuf_lite",
+    ],
+)
+
+cc_test(
+    name = "manifest_box_encoder_test",
+    srcs = ["manifest_box_encoder_test.cc"],
+    deps = [
+        ":claim_cbor_encoder",
+        ":local_signer",
+        ":manifest_box",
+        ":manifest_box_encoder",
+        ":manifest_store_builder",
+        ":signer",
+        "//assertion:assertion_encoder",
+        "//assertion:assertion_referencer",
+        "//async:async_context",
+        "//constants:labels",
+        "//crypto:algorithms",
+        "//crypto:crypto_read_handler",
+        "//crypto:crypto_write_handler",
+        "//crypto:hash",
+        "//crypto/default:default_crypto_read_handler",
+        "//crypto/default:hasher",
+        "//jumbf:box_builder",
+        "//proto:actions_assertion_cc_proto",
+        "//proto:assertion_cc_proto",
+        "//proto:generator_cc_proto",
+        "//proto:generator_info_cc_proto",
+        "//proto:hashed_uri_cc_proto",
+        "//proto:ingredient_assertion_cc_proto",
+        "//proto:manifest_cc_proto",
+        "//proto:signature_info_cc_proto",
+        "//proto:validation_result_cc_proto",
+        "//proto:validation_status_cc_proto",
+        "//testing:create_test_crypto_write_handler",
+        "//testing:local_key_crypto_write_handler",
+        "//testing:manifest_store_test_utils",
+        "//testing:proto_test_utils",
+        "//uuid",
+        "@abseil-cpp//absl/log:absl_check",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/strings:cord",
+        "@abseil-cpp//absl/strings:string_view",
+        "@abseil-cpp//absl/synchronization",
+        "@googletest//:gtest_main",
+        "@protobuf//:differencer",
+    ],
+)
+
+cc_library(
+    name = "timestamp_updater",
+    srcs = ["timestamp_updater.cc"],
+    hdrs = ["timestamp_updater.h"],
+    deps = [
+        ":manifest_store_view",
+        "//async:async_context",
+        "//cose:sig_structure",
+        "//cose:timestamp_creator",
+        "//cose:timestamp_updater",
+        "@abseil-cpp//absl/base:nullability",
+        "@abseil-cpp//absl/functional:any_invocable",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/strings:string_view",
+    ],
+)
+
+cc_test(
+    name = "timestamp_updater_test",
+    srcs = ["timestamp_updater_test.cc"],
+    deps = [
+        ":timestamp_updater",
+        "//async:async_context",
+        "//constants:labels",
+        "//cose:sig_structure",
+        "//cose:timestamp_creator",
+        "//crypto:crypto_read_handler",
+        "//crypto:hash",
+        "//crypto:timestamp_write_handler",
+        "//crypto/default:default_crypto_read_handler",
+        "//crypto/default:hasher",
+        "//jumbf:test_utils",
+        "//proto:validation_result_cc_proto",
+        "//testing:create_test_crypto_write_handler",
+        "//testing:manifest_store_test_utils",
+        "//validator:manifest_store_validator_impl",
+        "//validator:validator_options",
+        "@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/synchronization",
+        "@abseil-cpp//absl/time",
+        "@abseil-cpp//absl/time:clock_interface",
+        "@abseil-cpp//absl/time:simulated_clock",
+        "@googletest//:gtest_main",
+        "@protobuf//:timestamp_cc_proto",
+    ],
+)
+
+cc_library(
+    name = "manifest_box_assembler",
+    srcs = ["manifest_box_assembler.cc"],
+    hdrs = ["manifest_box_assembler.h"],
+    deps = [
+        ":manifest_box",
+        ":manifest_store_builder",
+        "//constants:ingredient_relationships",
+        "//constants:labels",
+        "//constants:spec_version",
+        "//crypto:hash",
+        "//proto:actions_assertion_cc_proto",
+        "//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_cc_proto",
+        "//proto:generator_info_cc_proto",
+        "//proto:hashed_uri_cc_proto",
+        "//proto:ingredient_assertion_cc_proto",
+        "//proto:ingredient_validation_result_cc_proto",
+        "//proto:multi_asset_hash_assertion_cc_proto",
+        "//utils:cbor_padding",
+        "//uuid",
+        "@abseil-cpp//absl/container:flat_hash_map",
+        "@abseil-cpp//absl/container:flat_hash_set",
+        "@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 = "manifest_box_assembler_test",
+    srcs = ["manifest_box_assembler_test.cc"],
+    deps = [
+        ":manifest_box",
+        ":manifest_box_assembler",
+        ":manifest_store_builder",
+        "//constants:labels",
+        "//constants:spec_version",
+        "//crypto/default:hasher",
+        "//jumbf:test_utils",
+        "//proto:assertion_cc_proto",
+        "//proto:bmff_based_hash_assertion_cc_proto",
+        "//proto:generator_cc_proto",
+        "//proto:generator_info_cc_proto",
+        "//proto:ingredient_assertion_cc_proto",
+        "//proto:ingredient_validation_result_cc_proto",
+        "//proto:manifest_cc_proto",
+        "//proto:signature_info_cc_proto",
+        "//testing:proto_test_utils",
+        "//uuid",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@abseil-cpp//absl/strings",
+        "@googletest//:gtest_main",
+    ],
+)
+
+cc_library(
+    name = "generator",
+    srcs = ["generator.cc"],
+    hdrs = ["generator.h"],
+    deps = [
+        ":claim_cbor_encoder",
+        ":manifest_box",
+        ":manifest_box_assembler",
+        ":manifest_box_encoder",
+        ":signer",
+        "//assertion:assertion_encoder",
+        "//assertion:assertion_referencer",
+        "//async:async_context",
+        "//constants:spec_version",
+        "//crypto:hash",
+        "//proto:actions_assertion_cc_proto",
+        "//proto:assertion_cc_proto",
+        "//proto:bmff_based_hash_assertion_cc_proto",
+        "//proto:boxes_hash_assertion_cc_proto",
+        "//proto:data_hash_assertion_cc_proto",
+        "//proto:generator_cc_proto",
+        "//proto:generator_info_cc_proto",
+        "//proto:hashed_uri_cc_proto",
+        "//proto:manifest_cc_proto",
+        "//uuid",
+        "@abseil-cpp//absl/base:nullability",
+        "@abseil-cpp//absl/functional:any_invocable",
+        "@abseil-cpp//absl/log:die_if_null",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:statusor",
+    ],
+)
+
+cc_test(
+    name = "generator_test",
+    srcs = ["generator_test.cc"],
+    deps = [
+        ":claim_cbor_encoder",
+        ":generator",
+        ":local_signer",
+        ":manifest_box",
+        ":manifest_box_assembler",
+        ":manifest_box_encoder",
+        ":manifest_store_builder",
+        ":signer",
+        "//assertion:assertion_encoder",
+        "//async:async_context",
+        "//crypto:crypto_read_handler",
+        "//crypto:crypto_write_handler",
+        "//crypto/default:default_crypto_read_handler",
+        "//crypto/default:hasher",
+        "//jumbf:box_builder",
+        "//proto:assertion_cc_proto",
+        "//proto:generator_cc_proto",
+        "//proto:generator_info_cc_proto",
+        "//proto:manifest_cc_proto",
+        "//proto:signature_info_cc_proto",
+        "//proto:validation_result_cc_proto",
+        "//testing:create_test_crypto_write_handler",
+        "//testing:manifest_store_test_utils",
+        "//testing:proto_test_utils",
+        "//uuid",
+        "@abseil-cpp//absl/log:absl_check",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings:cord",
+        "@abseil-cpp//absl/strings:string_view",
+        "@abseil-cpp//absl/synchronization",
+        "@googletest//:gtest_main",
+        "@protobuf//:differencer",
+    ],
+)
diff --git a/claim/claim_cbor_encoder.cc b/claim/claim_cbor_encoder.cc
index 2e1379a..2c950d3 100644
--- a/claim/claim_cbor_encoder.cc
+++ b/claim/claim_cbor_encoder.cc
@@ -30,8 +30,7 @@
 
 class StandardClaimCborEncoder : public ClaimCborEncoder {
  public:
-  explicit StandardClaimCborEncoder(bool skip_validity_checks_for_test = false)
-      : options_({.skip_validation = skip_validity_checks_for_test}) {}
+  StandardClaimCborEncoder() = default;
   ~StandardClaimCborEncoder() override = default;
 
   absl::StatusOr<std::string> ToCbor(const Claim& claim) const override {
@@ -45,17 +44,9 @@
 
 }  // 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;
-  }
+const ClaimCborEncoder& GetClaimCborEncoder() {
+  static const absl::NoDestructor<StandardClaimCborEncoder> encoder;
+  return *encoder;
 }
 
 }  // namespace credentio
diff --git a/claim/claim_cbor_encoder.h b/claim/claim_cbor_encoder.h
index d54c3f9..082bf8a 100644
--- a/claim/claim_cbor_encoder.h
+++ b/claim/claim_cbor_encoder.h
@@ -32,7 +32,7 @@
 };
 
 // Returns a standard singleton ClaimCborEncoder.
-const ClaimCborEncoder& GetClaimCborEncoder(bool skip_validity_checks_for_test);
+const ClaimCborEncoder& GetClaimCborEncoder();
 
 }  // namespace credentio
 
diff --git a/claim/generator.cc b/claim/generator.cc
new file mode 100644
index 0000000..1417c1a
--- /dev/null
+++ b/claim/generator.cc
@@ -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.
+//
+
+#include "claim/generator.h"
+
+#include <memory>
+#include <utility>
+
+#include "absl/base/nullability.h"
+#include "absl/functional/any_invocable.h"
+#include "absl/log/die_if_null.h"
+#include "absl/status/status.h"
+#include "absl/status/statusor.h"
+#include "assertion/assertion_encoder.h"
+#include "assertion/assertion_referencer.h"
+#include "async/async_context.h"
+#include "claim/claim_cbor_encoder.h"
+#include "claim/manifest_box.h"
+#include "claim/manifest_box_assembler.h"
+#include "claim/manifest_box_encoder.h"
+#include "claim/signer.h"
+#include "crypto/hash.h"
+#include "proto/actions_assertion.pb.h"
+#include "proto/assertion.pb.h"
+#include "proto/bmff_based_hash_assertion.pb.h"
+#include "proto/boxes_hash_assertion.pb.h"
+#include "proto/data_hash_assertion.pb.h"
+#include "proto/generator.pb.h"
+#include "proto/generator_info.pb.h"
+#include "proto/hashed_uri.pb.h"
+#include "proto/manifest.pb.h"
+
+namespace credentio {
+namespace {
+
+ClaimSigner::Options ToClaimSignerOptions(
+    const GenerateManifestStoreOptions& options, AsyncContext context) {
+  ClaimSigner::Options result;
+  result.async_context = std::move(context);
+  switch (options.time_stamping_mode()) {
+    case GenerateManifestStoreOptions::TIME_STAMPING_REQUIRED:
+      result.time_stamping_mode = TimeStampingMode::kRequired;
+      break;
+    case GenerateManifestStoreOptions::TIME_STAMPING_OMITTED_WITH_PADDING:
+      result.time_stamping_mode = TimeStampingMode::kOmittedWithPadding;
+      break;
+    case GenerateManifestStoreOptions::TIME_STAMPING_OPTIONAL:
+    case GenerateManifestStoreOptions::TIME_STAMPING_MODE_UNSPECIFIED:
+    default:
+      result.time_stamping_mode = TimeStampingMode::kOptional;
+      break;
+  }
+  return result;
+}
+
+class ManifestEncoderImpl : public ManifestEncoder {
+ public:
+  explicit ManifestEncoderImpl(
+      GeneratorInfo generator_info,
+      const HasherFactory* absl_nonnull hash_factory,
+      ClaimSigner* absl_nonnull claim_signer,
+      const AssertionEncoder* absl_nonnull assertion_cbor_encoder,
+      const ClaimCborEncoder* absl_nonnull claim_cbor_encoder)
+      : generator_info_(std::move(generator_info)),
+        hash_factory_(*ABSL_DIE_IF_NULL(hash_factory)),
+        claim_signer_(*ABSL_DIE_IF_NULL(claim_signer)),
+        assertion_cbor_encoder_(*ABSL_DIE_IF_NULL(assertion_cbor_encoder)),
+        claim_cbor_encoder_(*ABSL_DIE_IF_NULL(claim_cbor_encoder)) {}
+
+  void Encode(ManifestBox* absl_nonnull manifest,
+              const GenerateManifestStoreOptions& options, AsyncContext context,
+              absl::AnyInvocable<void(absl::StatusOr<ManifestEncoderResult>) &&>
+                  cb) const override {
+    auto assertion_reference =
+        std::make_shared<AssertionReferencer>(&hash_factory_);
+    auto manifest_box_encoder = std::make_shared<ManifestBoxEncoder>(
+        assertion_reference.get(), &generator_info_, &claim_signer_,
+        &assertion_cbor_encoder_, &claim_cbor_encoder_, manifest,
+        hash_factory_.algorithm());
+    manifest_box_encoder->Encode(
+        ToClaimSignerOptions(options, std::move(context)),
+        [assertion_reference, manifest_box_encoder, cb = std::move(cb)](
+            absl::StatusOr<ManifestEncoderResult> result) mutable {
+          std::move(cb)(std::move(result));
+        });
+  }
+
+ private:
+  GeneratorInfo generator_info_;
+  const HasherFactory& hash_factory_;
+  ClaimSigner& claim_signer_;
+  const AssertionEncoder& assertion_cbor_encoder_;
+  const ClaimCborEncoder& claim_cbor_encoder_;
+};
+}  // namespace
+
+absl::StatusOr<std::unique_ptr<ManifestBoxAssembler>> CreateManifestAssembler(
+    const CreateManifestAssemblerOptions& options) {
+  return std::make_unique<StandardManifestBoxAssembler>(
+      options.uuid_generator, options.hash_factory, options.spec_version);
+}
+
+absl::StatusOr<std::unique_ptr<ManifestEncoder>> CreateManifestEncoder(
+    const CreateManifestEncoderOptions& options) {
+  return std::make_unique<ManifestEncoderImpl>(
+      options.generator_info, options.hash_factory, options.claim_signer,
+      options.assertion_cbor_encoder, options.claim_cbor_encoder);
+}
+}  // namespace credentio
diff --git a/claim/generator.h b/claim/generator.h
new file mode 100644
index 0000000..a2f59d5
--- /dev/null
+++ b/claim/generator.h
@@ -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.
+//
+
+#ifndef THIRD_PARTY_CREDENTIO_CLAIM_GENERATOR_H_
+#define THIRD_PARTY_CREDENTIO_CLAIM_GENERATOR_H_
+
+#include <memory>
+
+#include "absl/base/nullability.h"
+#include "absl/functional/any_invocable.h"
+#include "absl/status/statusor.h"
+#include "assertion/assertion_encoder.h"
+#include "async/async_context.h"
+#include "claim/claim_cbor_encoder.h"
+#include "claim/manifest_box.h"
+#include "claim/manifest_box_assembler.h"
+#include "claim/manifest_box_encoder.h"
+#include "claim/signer.h"
+#include "constants/spec_version.h"
+#include "crypto/hash.h"
+#include "proto/generator.pb.h"
+#include "proto/generator_info.pb.h"
+#include "proto/manifest.pb.h"
+#include "uuid/uuid.h"
+
+namespace credentio {
+
+// An abstract class to serialize a Manifest in JUMBF format as a Manifest
+// Store SuperBox.
+class ManifestEncoder {
+ public:
+  virtual ~ManifestEncoder() = default;
+  // Encodes the given Manifest to a JUMBF-type box. The manifest will be
+  // wrapped in a Manifest Store SuperBox. Consumes the provided ManifestBox,
+  // which must outlive the task.
+  virtual void Encode(
+      ManifestBox* absl_nonnull manifest,
+      const GenerateManifestStoreOptions& options, AsyncContext context,
+      absl::AnyInvocable<void(absl::StatusOr<ManifestEncoderResult>) &&> cb)
+      const = 0;
+};
+
+struct CreateManifestAssemblerOptions {
+  // Required.
+  const UuidGenerator* absl_nonnull uuid_generator;
+  // Required.
+  const HasherFactory* absl_nonnull hash_factory;
+  // The C2PA specification version the generated manifests conform to.
+  SpecVersion spec_version = SpecVersion::kC2pa_2_2;
+};
+
+using ManifestAssemblerFactory =
+    absl::AnyInvocable<absl::StatusOr<std::unique_ptr<ManifestBoxAssembler>>(
+        const CreateManifestAssemblerOptions& options) const>;
+
+absl::StatusOr<std::unique_ptr<ManifestBoxAssembler>> CreateManifestAssembler(
+    const CreateManifestAssemblerOptions& options);
+
+struct CreateManifestEncoderOptions {
+  // Required.
+  GeneratorInfo generator_info;
+  // Required.
+  ClaimSigner* absl_nonnull claim_signer;
+  // Required.
+  const AssertionEncoder* absl_nonnull assertion_cbor_encoder;
+  // Required.
+  const ClaimCborEncoder* absl_nonnull claim_cbor_encoder;
+  // Required.
+  const HasherFactory* absl_nonnull hash_factory;
+};
+
+absl::StatusOr<std::unique_ptr<ManifestEncoder>> CreateManifestEncoder(
+    const CreateManifestEncoderOptions& options);
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_CLAIM_GENERATOR_H_
diff --git a/claim/generator_test.cc b/claim/generator_test.cc
new file mode 100644
index 0000000..8484500
--- /dev/null
+++ b/claim/generator_test.cc
@@ -0,0 +1,864 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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/generator.h"
+
+#include <memory>
+#include <string>
+#include <utility>
+
+#include "absl/log/absl_check.h"
+#include "absl/status/status.h"
+#include "absl/status/status_matchers.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/cord.h"
+#include "absl/strings/string_view.h"
+#include "absl/synchronization/notification.h"
+#include "assertion/assertion_encoder.h"
+#include "async/async_context.h"
+#include "claim/claim_cbor_encoder.h"
+#include "claim/local_signer.h"
+#include "claim/manifest_box.h"
+#include "claim/manifest_box_assembler.h"
+#include "claim/manifest_box_encoder.h"
+#include "claim/manifest_store_builder.h"
+#include "claim/signer.h"
+#include "crypto/crypto_read_handler.h"
+#include "crypto/crypto_write_handler.h"
+#include "crypto/default/default_crypto_read_handler.h"
+#include "crypto/default/hasher.h"
+#include "gmock/gmock.h"
+#include "google/protobuf/util/message_differencer.h"
+#include "gtest/gtest.h"
+#include "jumbf/box_builder.h"
+#include "proto/assertion.pb.h"
+#include "proto/generator.pb.h"
+#include "proto/generator_info.pb.h"
+#include "proto/manifest.pb.h"
+#include "proto/signature_info.pb.h"
+#include "proto/validation_result.pb.h"
+#include "testing/create_test_crypto_write_handler.h"
+#include "testing/manifest_store_test_utils.h"
+#include "testing/proto_test_utils.h"
+#include "uuid/uuid.h"
+
+namespace credentio {
+namespace {
+
+using ::absl_testing::IsOkAndHolds;
+using ::absl_testing::StatusIs;
+using ::credentio_testing::EqualsProto;
+using ::credentio_testing::ParseTextProtoOrDie;
+using ::credentio_testing::PartiallyEqualsProto;
+using ::testing::HasSubstr;
+using ::testing::Pointee;
+using ::testing::Pointwise;
+
+MATCHER_P(EqualsProtoIgnoringTimestampAndOrdering, expected, "") {
+  google::protobuf::util::MessageDifferencer differencer;
+  differencer.set_repeated_field_comparison(
+      google::protobuf::util::MessageDifferencer::AS_SET);
+  differencer.IgnoreField(
+      SignatureInfo::descriptor()->FindFieldByName("timestamping_time"));
+  std::string diff;
+  differencer.ReportDifferencesToString(&diff);
+  bool match = differencer.Compare(expected, arg);
+  if (!match) {
+    *result_listener << "\nDifference:\n" << diff;
+  }
+  return match;
+}
+
+constexpr Uuid kUuid =
+    Uuid::FromStringOrDie("03020100-0504-0706-0908-0f0e0d0c0b0a");
+
+class FakeUuidGenerator : public UuidGenerator {
+ public:
+  Uuid Generate() const override { return kUuid; }
+};
+
+class ManifestAssemblerTest : public testing::Test {
+ protected:
+  ManifestAssemblerTest() {
+    absl::StatusOr<std::unique_ptr<ManifestBoxAssembler>> manifest_generator =
+        CreateManifestAssembler({.uuid_generator = &uuid_generator_,
+                                 .hash_factory = &hash_factory_});
+    ABSL_CHECK_OK(manifest_generator);
+    manifest_assembler_ = std::move(*manifest_generator);
+  }
+  FakeUuidGenerator uuid_generator_;
+  Sha256HasherFactory hash_factory_;
+  std::unique_ptr<ManifestBoxAssembler> manifest_assembler_;
+};
+
+TEST_F(ManifestAssemblerTest, NoHardBindingInvalid) {
+  GenerateManifestStoreRequest request;
+  EXPECT_THAT(
+      manifest_assembler_->Assemble(request),
+      StatusIs(absl::StatusCode::kInvalidArgument, HasSubstr("hard binding")));
+}
+
+TEST_F(ManifestAssemblerTest, PopulateBmffBasedHardBinding) {
+  GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb(
+    hard_binding {
+      bmff_based_hash {
+        exclusions { xpath: "" }
+        alg: "some alg"
+        hash: "some hash"
+      }
+    }
+    actions { created {} }
+  )pb");
+  ManifestBox expected = {
+      .label = "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a",
+      .hard_binding_assertion = ParseTextProtoOrDie(R"pb(
+        label: "c2pa.hash.bmff.v3"
+        bmff_based_hash {
+          exclusions { xpath: "" }
+          alg: "some alg"
+          hash: "some hash"
+        }
+      )pb"),
+      .actions_assertion = ParseTextProtoOrDie(R"pb(
+        label: "c2pa.actions.v2"
+        actions { actions { action: "c2pa.created" } }
+      )pb"),
+      .ingredient_assertions = {},
+      .manifest_store_builder = ManifestStoreBuilder(&hash_factory_),
+  };
+  absl::StatusOr<ManifestBox> manifest_box =
+      manifest_assembler_->Assemble(request);
+  ABSL_ASSERT_OK(manifest_box);
+  EXPECT_EQ(manifest_box->label, expected.label);
+  EXPECT_THAT(manifest_box->hard_binding_assertion,
+              EqualsProto(expected.hard_binding_assertion));
+  EXPECT_THAT(manifest_box->actions_assertion,
+              EqualsProto(expected.actions_assertion));
+  EXPECT_THAT(manifest_box->ingredient_assertions,
+              Pointwise(EqualsProto(), expected.ingredient_assertions));
+}
+
+TEST_F(ManifestAssemblerTest, PopulateActionsAssertionWithAllActionsIncluded) {
+  GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb(
+    hard_binding {
+      bmff_based_hash {
+        exclusions { xpath: "" }
+        alg: "some alg"
+        hash: "some hash"
+      }
+    }
+    actions {
+      created {}
+      all_actions_included: true
+    }
+  )pb");
+  ManifestBox expected = {
+      .label = "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a",
+      .hard_binding_assertion = ParseTextProtoOrDie(R"pb(
+        label: "c2pa.hash.bmff.v3"
+        bmff_based_hash {
+          exclusions { xpath: "" }
+          alg: "some alg"
+          hash: "some hash"
+        }
+      )pb"),
+      .actions_assertion = ParseTextProtoOrDie(R"pb(
+        label: "c2pa.actions.v2"
+        actions {
+          all_actions_included: true
+          actions { action: "c2pa.created" }
+        }
+      )pb"),
+      .ingredient_assertions = {},
+      .manifest_store_builder = ManifestStoreBuilder(&hash_factory_),
+  };
+  absl::StatusOr<ManifestBox> manifest_box =
+      manifest_assembler_->Assemble(request);
+  ABSL_ASSERT_OK(manifest_box);
+  EXPECT_EQ(manifest_box->label, expected.label);
+  EXPECT_THAT(manifest_box->hard_binding_assertion,
+              EqualsProto(expected.hard_binding_assertion));
+  EXPECT_THAT(manifest_box->actions_assertion,
+              EqualsProto(expected.actions_assertion));
+  EXPECT_THAT(manifest_box->ingredient_assertions,
+              Pointwise(EqualsProto(), expected.ingredient_assertions));
+}
+
+TEST_F(ManifestAssemblerTest, PopulateBoxesBasedHardBinding) {
+  GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb(
+    hard_binding {
+      box_hash {
+        alg: "some alg"
+        boxes { names: "some name" names: "other name" hash: "some hash" }
+      }
+    }
+    actions { created {} }
+  )pb");
+  ManifestBox expected = {
+      .label = "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a",
+      .hard_binding_assertion = ParseTextProtoOrDie(R"pb(
+        label: "c2pa.hash.boxes"
+        boxes_hash {
+          alg: "some alg"
+          boxes {
+            names: "some name"
+            names: "other name"
+            hash: "some hash"
+            pad: ""
+            pad2: ""
+          }
+        }
+      )pb"),
+      .actions_assertion = ParseTextProtoOrDie(R"pb(
+        label: "c2pa.actions.v2"
+        actions { actions { action: "c2pa.created" } }
+      )pb"),
+      .ingredient_assertions = {},
+      .manifest_store_builder = ManifestStoreBuilder(&hash_factory_),
+  };
+  absl::StatusOr<ManifestBox> manifest_box =
+      manifest_assembler_->Assemble(request);
+  ABSL_ASSERT_OK(manifest_box);
+  EXPECT_EQ(manifest_box->label, expected.label);
+  EXPECT_THAT(manifest_box->hard_binding_assertion,
+              EqualsProto(expected.hard_binding_assertion));
+  EXPECT_THAT(manifest_box->actions_assertion,
+              EqualsProto(expected.actions_assertion));
+  EXPECT_THAT(manifest_box->ingredient_assertions,
+              Pointwise(EqualsProto(), expected.ingredient_assertions));
+}
+
+TEST_F(ManifestAssemblerTest, PopulateDataBasedHardBinding) {
+  GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb(
+    hard_binding {
+      data_hash {
+        alg: "some alg"
+        hash: "some hash"
+        exclusions { start: 1 length: 10 }
+      }
+    }
+    actions { created {} }
+  )pb");
+  ManifestBox expected = {
+      .label = "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a",
+      .hard_binding_assertion = ParseTextProtoOrDie(R"pb(
+        label: "c2pa.hash.data"
+        data_hash {
+          alg: "some alg"
+          hash: "some hash"
+          exclusions { start: 1 length: 10 }
+          pad: "\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000"
+        }
+      )pb"),
+      .actions_assertion = ParseTextProtoOrDie(R"pb(
+        label: "c2pa.actions.v2"
+        actions { actions { action: "c2pa.created" } }
+      )pb"),
+      .ingredient_assertions = {},
+      .manifest_store_builder = ManifestStoreBuilder(&hash_factory_),
+  };
+  absl::StatusOr<ManifestBox> manifest_box =
+      manifest_assembler_->Assemble(request);
+  ABSL_ASSERT_OK(manifest_box);
+  EXPECT_EQ(manifest_box->label, expected.label);
+  EXPECT_THAT(manifest_box->hard_binding_assertion,
+              EqualsProto(expected.hard_binding_assertion));
+  EXPECT_THAT(manifest_box->actions_assertion,
+              EqualsProto(expected.actions_assertion));
+  EXPECT_THAT(manifest_box->ingredient_assertions,
+              Pointwise(EqualsProto(), expected.ingredient_assertions));
+}
+
+TEST_F(ManifestAssemblerTest, UnspecifiedInceptionActionInvalid) {
+  GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb(
+    hard_binding { bmff_based_hash { exclusions { xpath: "" } } }
+  )pb");
+  EXPECT_THAT(manifest_assembler_->Assemble(request),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("missing inception action")));
+}
+
+TEST_F(ManifestAssemblerTest, PlacedAction) {
+  GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb(
+    hard_binding { bmff_based_hash { exclusions { xpath: "" } } }
+    actions {
+      created {}
+      actions { placed { ingredient_indexes: 0 } }
+    }
+    ingredients { metadata { title: "ingredient0" } }
+  )pb");
+  absl::StatusOr<ManifestBox> manifest_box =
+      manifest_assembler_->Assemble(request);
+  ABSL_ASSERT_OK(manifest_box);
+  EXPECT_THAT(manifest_box->actions_assertion,
+              PartiallyEqualsProto(ParseTextProtoOrDie<Assertion>(R"pb(
+                label: "c2pa.actions.v2"
+                actions {
+                  actions { action: "c2pa.created" }
+                  actions { action: "c2pa.placed" }
+                }
+              )pb")));
+}
+
+TEST_F(ManifestAssemblerTest, HasCreatedAction) {
+  GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb(
+    hard_binding { bmff_based_hash { exclusions { xpath: "" } } }
+    actions {
+      created {
+        action_metadata {
+          software_agent {}
+          description: "created for testing"
+        }
+        digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture"
+      }
+    }
+  )pb");
+  ManifestBox expected = {
+      .label = "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a",
+      .hard_binding_assertion = ParseTextProtoOrDie(R"pb(
+        label: "c2pa.hash.bmff.v3"
+        bmff_based_hash { exclusions { xpath: "" } }
+      )pb"),
+      .actions_assertion = ParseTextProtoOrDie(R"pb(
+        label: "c2pa.actions.v2"
+        actions {
+          actions {
+            action: "c2pa.created"
+            software_agent {}
+            description: "created for testing"
+            digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture"
+          }
+        }
+      )pb"),
+      .ingredient_assertions = {},
+      .manifest_store_builder = ManifestStoreBuilder(&hash_factory_),
+  };
+  absl::StatusOr<ManifestBox> manifest_box =
+      manifest_assembler_->Assemble(request);
+  ABSL_ASSERT_OK(manifest_box);
+  EXPECT_EQ(manifest_box->label, expected.label);
+  EXPECT_THAT(manifest_box->hard_binding_assertion,
+              EqualsProto(expected.hard_binding_assertion));
+  EXPECT_THAT(manifest_box->actions_assertion,
+              EqualsProto(expected.actions_assertion));
+  EXPECT_THAT(manifest_box->ingredient_assertions,
+              Pointwise(EqualsProto(), expected.ingredient_assertions));
+}
+
+TEST_F(ManifestAssemblerTest, OpenedActionWithoutIngredientIndex) {
+  GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb(
+    hard_binding { bmff_based_hash { exclusions { xpath: "" } } }
+    actions {
+      opened {
+        action_metadata {
+          software_agent {}
+          description: "opened for testing"
+        }
+      }
+    }
+  )pb");
+  EXPECT_THAT(manifest_assembler_->Assemble(request),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("requires an ingredient index.")));
+}
+
+TEST_F(ManifestAssemblerTest, OpenedActionWithInvalidIngredientIndex) {
+  GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb(
+    hard_binding { bmff_based_hash { exclusions { xpath: "" } } }
+    actions {
+      opened {
+        action_metadata {
+          software_agent {}
+          description: "opened for testing"
+        }
+        ingredient_index: 1
+      }
+    }
+    ingredients { metadata { title: "test ingredient" } }
+  )pb");
+  EXPECT_THAT(manifest_assembler_->Assemble(request),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("ingredient index out of range")));
+}
+
+TEST_F(ManifestAssemblerTest, OpenedActionWithUnreferencedIngredient) {
+  GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb(
+    hard_binding { bmff_based_hash { exclusions { xpath: "" } } }
+    actions {
+      opened {
+        action_metadata {
+          software_agent {}
+          description: "opened for testing"
+        }
+        ingredient_index: 1
+      }
+    }
+    ingredients { metadata { title: "test ingredient" } }
+    ingredients { metadata { title: "test ingredient at index 1" } }
+  )pb");
+  EXPECT_THAT(
+      manifest_assembler_->Assemble(request),
+      StatusIs(absl::StatusCode::kInvalidArgument,
+               HasSubstr("ingredient is not referenced by any action")));
+}
+
+TEST_F(ManifestAssemblerTest, HasSimpleOpenedAction) {
+  GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb(
+    hard_binding { bmff_based_hash { exclusions { xpath: "" } } }
+    actions {
+      opened {
+        action_metadata {
+          software_agent {}
+          description: "opened for testing"
+        }
+        ingredient_index: 0
+      }
+    }
+    ingredients { metadata { title: "test ingredient" } }
+  )pb");
+  ManifestBox expected = {
+      .label = "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a",
+      .hard_binding_assertion = ParseTextProtoOrDie(R"pb(
+        label: "c2pa.hash.bmff.v3"
+        bmff_based_hash { exclusions { xpath: "" } }
+      )pb"),
+      .actions_assertion = ParseTextProtoOrDie(R"pb(
+        label: "c2pa.actions.v2"
+        actions {
+          actions {
+            action: "c2pa.opened"
+            software_agent {}
+            description: "opened for testing"
+            parameters {
+              ingredients {
+                url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3"
+              }
+            }
+          }
+        }
+      )pb"),
+      .ingredient_assertions = {ParseTextProtoOrDie(R"pb(
+        label: "c2pa.ingredient.v3"
+        ingredient_v3 { title: "test ingredient" relationship: "parentOf" }
+      )pb")},
+      .manifest_store_builder = ManifestStoreBuilder(&hash_factory_),
+  };
+  absl::StatusOr<ManifestBox> manifest_box =
+      manifest_assembler_->Assemble(request);
+  ABSL_ASSERT_OK(manifest_box);
+  EXPECT_EQ(manifest_box->label, expected.label);
+  EXPECT_THAT(manifest_box->hard_binding_assertion,
+              EqualsProto(expected.hard_binding_assertion));
+  EXPECT_THAT(manifest_box->actions_assertion,
+              EqualsProto(expected.actions_assertion));
+  EXPECT_THAT(manifest_box->ingredient_assertions,
+              Pointwise(EqualsProto(), expected.ingredient_assertions));
+}
+
+TEST_F(ManifestAssemblerTest, HasOpenedAction) {
+  GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb(
+    hard_binding { bmff_based_hash { exclusions { xpath: "" } } }
+    actions {
+      opened {
+        action_metadata {
+          software_agent {}
+          description: "opened for testing"
+        }
+        ingredient_index: 0
+      }
+    }
+    ingredients {
+      metadata {
+        title: "test ingredient"
+        media_type: "video/mp4"
+        instance_id: "test_ingredient"
+        description: "test ingredient"
+        informational_uri: "https://test.com/ingredient"
+      }
+    }
+  )pb");
+  ManifestBox expected = {
+      .label = "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a",
+      .hard_binding_assertion = ParseTextProtoOrDie(R"pb(
+        label: "c2pa.hash.bmff.v3"
+        bmff_based_hash { exclusions { xpath: "" } }
+      )pb"),
+      .actions_assertion = ParseTextProtoOrDie(R"pb(
+        label: "c2pa.actions.v2"
+        actions {
+          actions {
+            action: "c2pa.opened"
+            software_agent {}
+            description: "opened for testing"
+            parameters {
+              ingredients {
+                url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3"
+              }
+            }
+          }
+        }
+      )pb"),
+      .ingredient_assertions = {ParseTextProtoOrDie(R"pb(
+        label: "c2pa.ingredient.v3"
+        ingredient_v3 {
+          title: "test ingredient"
+          media_type: "video/mp4"
+          relationship: "parentOf"
+          instance_id: "test_ingredient"
+          description: "test ingredient"
+          informational_uri: "https://test.com/ingredient"
+        }
+      )pb")},
+      .manifest_store_builder = ManifestStoreBuilder(&hash_factory_),
+  };
+  absl::StatusOr<ManifestBox> manifest_box =
+      manifest_assembler_->Assemble(request);
+  ABSL_ASSERT_OK(manifest_box);
+  EXPECT_EQ(manifest_box->label, expected.label);
+  EXPECT_THAT(manifest_box->hard_binding_assertion,
+              EqualsProto(expected.hard_binding_assertion));
+  EXPECT_THAT(manifest_box->actions_assertion,
+              EqualsProto(expected.actions_assertion));
+  EXPECT_THAT(manifest_box->ingredient_assertions,
+              Pointwise(EqualsProto(), expected.ingredient_assertions));
+}
+
+absl::StatusOr<jumbf::BuiltSuperBox> Encode(ManifestEncoder& manifest_encoder,
+                                            ManifestBox& manifest_box) {
+  absl::Notification done;
+  absl::StatusOr<ManifestEncoderResult> result;
+  GenerateManifestStoreOptions options;
+  manifest_encoder.Encode(
+      &manifest_box, options, AsyncContext(),
+      [&result, &done](absl::StatusOr<ManifestEncoderResult> res) {
+        result = std::move(res);
+        done.Notify();
+      });
+  done.WaitForNotification();
+  if (!result.ok()) {
+    return result.status();
+  }
+  return std::move(result->box);
+}
+
+class ManifestEncoderTest : public testing::Test {
+ protected:
+  ManifestEncoderTest() {
+    generator_info_.set_name("Test generator");
+    absl::StatusOr<std::unique_ptr<CryptoWriteHandler>> crypto_writer =
+        CreateTestCryptoWriteHandler({});
+    ABSL_CHECK_OK(crypto_writer);
+    crypto_writer_ = std::move(*crypto_writer);
+    absl::StatusOr<std::unique_ptr<CryptoReadHandler>> crypto_reader =
+        CreateDefaultCryptoReadHandler({
+            .trust_environment = CryptoReadHandler::TrustEnvironment::kAny,
+        });
+    ABSL_CHECK_OK(crypto_reader);
+    crypto_reader_ = std::move(*crypto_reader);
+    claim_signer_ = std::make_unique<LocalClaimSigner>(crypto_writer_.get(),
+                                                       &hash_factory_);
+    absl::StatusOr<std::unique_ptr<ManifestEncoder>> manifest_encoder =
+        CreateManifestEncoder({
+            .generator_info = generator_info_,
+            .claim_signer = claim_signer_.get(),
+            .assertion_cbor_encoder = &GetAssertionEncoder(),
+            .claim_cbor_encoder = &GetClaimCborEncoder(),
+            .hash_factory = &hash_factory_,
+        });
+    ABSL_CHECK_OK(manifest_encoder);
+    manifest_encoder_ = std::move(*manifest_encoder);
+  }
+  std::unique_ptr<CryptoWriteHandler> crypto_writer_;
+  std::unique_ptr<CryptoReadHandler> crypto_reader_;
+  std::unique_ptr<ClaimSigner> claim_signer_;
+  GeneratorInfo generator_info_;
+  Sha256HasherFactory hash_factory_;
+  std::unique_ptr<ManifestEncoder> manifest_encoder_;
+};
+
+TEST_F(ManifestEncoderTest, EncodeManifest) {
+  ManifestBox manifest_box = {
+      .label = "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a",
+      .instance_id = "12345678-1234-1234-1234-123456789012",
+      .hard_binding_assertion = ParseTextProtoOrDie(R"pb(
+        label: "c2pa.hash.bmff.v3"
+        bmff_based_hash { exclusions { xpath: "" } }
+      )pb"),
+      .actions_assertion = ParseTextProtoOrDie(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"),
+      .ingredient_assertions = {},
+      .manifest_store_builder = ManifestStoreBuilder(&hash_factory_),
+  };
+  Claim expected_claim = ParseTextProtoOrDie(R"pb(
+    instance_id: "12345678-1234-1234-1234-123456789012"
+    claim_generator_info { name: "Test generator" }
+    signature_info {
+      cert_serial_number: "02"
+      issuer: "OU=Test Unit,O=Test Org,CN=Test Claim Signer"
+      certificate_issuer: "OU=Test Unit,O=Test Org,CN=Test Root CA"
+      timestamping_time {}
+    }
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.hash.bmff.v3"
+      algorithm: "sha256"
+      hash: "\016^\204(\332\347\371\222!\343x\220d\3607&7\237\332\211ps\004\231\222q\362\021\2261\330\261"
+    }
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.actions.v2"
+      algorithm: "sha256"
+      hash: "\331\271\373\3228\310\335>y\263\334\342\335\367\211Q\360\325\227\r\345$]\214\005\3047[\r#1\312"
+    }
+    default_algorithm: "sha256"
+    label: "c2pa.claim.v2"
+  )pb");
+  PartialValidationResultProto expected_validated_result = ParseTextProtoOrDie(
+      R"pb(
+        active_manifest {
+          label: "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a"
+          claim {
+            claim_generator_info { name: "Test generator" }
+            signature_info {
+              cert_serial_number: "02"
+              issuer: "OU=Test Unit,O=Test Org,CN=Test Claim Signer"
+              certificate_issuer: "OU=Test Unit,O=Test Org,CN=Test Root CA"
+              timestamping_time {}
+            }
+            created_assertions {
+              url: "self#jumbf=c2pa.assertions/c2pa.hash.bmff.v3"
+              algorithm: "sha256"
+              hash: "\016^\204(\332\347\371\222!\343x\220d\3607&7\237\332\211ps\004\231\222q\362\021\2261\330\261"
+            }
+            created_assertions {
+              url: "self#jumbf=c2pa.assertions/c2pa.actions.v2"
+              algorithm: "sha256"
+              hash: "\331\271\373\3228\310\335>y\263\334\342\335\367\211Q\360\325\227\r\345$]\214\005\3047[\r#1\312"
+            }
+            default_algorithm: "sha256"
+            label: "c2pa.claim.v2"
+            instance_id: "12345678-1234-1234-1234-123456789012"
+          }
+          assertions {
+            label: "c2pa.hash.bmff.v3"
+            bmff_based_hash { exclusions { xpath: "" } }
+          }
+          assertions {
+            label: "c2pa.actions.v2"
+            actions {
+              actions {
+                action: "c2pa.created"
+                description: "created for testing"
+                digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture"
+                software_agent { name: "test generator" }
+              }
+            }
+          }
+          validation {
+            successes {
+              code: "timeStamp.validated"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature"
+            }
+            successes {
+              code: "timeStamp.trusted"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature"
+            }
+            successes {
+              code: "signingCredential.trusted"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature"
+            }
+            successes {
+              code: "claimSignature.insideValidity"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature"
+            }
+            successes {
+              code: "claimSignature.validated"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature"
+            }
+            successes {
+              code: "assertion.hashedURI.match"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.hash.bmff.v3"
+            }
+            successes {
+              code: "assertion.hashedURI.match"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.actions.v2"
+            }
+            informationals {
+              code: "signingCredential.ocsp.skipped"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature"
+            }
+          }
+        }
+        hard_binding_uri: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.hash.bmff.v3"
+      )pb");
+  absl::StatusOr<jumbf::BuiltSuperBox> built_manifest_store_box =
+      Encode(*manifest_encoder_, manifest_box);
+  ABSL_ASSERT_OK(built_manifest_store_box);
+  absl::Cord result_manifest_store_bytes =
+      std::move(*built_manifest_store_box).AsCord();
+  EXPECT_THAT(
+      ValidateClaim(result_manifest_store_bytes.Flatten()),
+      IsOkAndHolds(EqualsProtoIgnoringTimestampAndOrdering(expected_claim)));
+  EXPECT_THAT(ValidateManifestStore(result_manifest_store_bytes.Flatten()),
+              IsOkAndHolds(Pointee(EqualsProtoIgnoringTimestampAndOrdering(
+                  expected_validated_result))));
+}
+
+TEST_F(ManifestEncoderTest, EncodeManifestWithAllActionsIncluded) {
+  ManifestBox manifest_box = {
+      .label = "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a",
+      .instance_id = "12345678-1234-1234-1234-123456789012",
+      .hard_binding_assertion = ParseTextProtoOrDie(R"pb(
+        label: "c2pa.hash.bmff.v3"
+        bmff_based_hash { exclusions { xpath: "" } }
+      )pb"),
+      .actions_assertion = ParseTextProtoOrDie(R"pb(
+        label: "c2pa.actions.v2"
+        actions {
+          all_actions_included: true
+          actions {
+            action: "c2pa.created"
+            software_agent { name: "test generator" }
+            description: "created for testing"
+            digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture"
+          }
+        }
+      )pb"),
+      .ingredient_assertions = {},
+      .manifest_store_builder = ManifestStoreBuilder(&hash_factory_),
+  };
+  Claim expected_claim = ParseTextProtoOrDie(R"pb(
+    instance_id: "12345678-1234-1234-1234-123456789012"
+    claim_generator_info { name: "Test generator" }
+    signature_info {
+      cert_serial_number: "02"
+      issuer: "OU=Test Unit,O=Test Org,CN=Test Claim Signer"
+      certificate_issuer: "OU=Test Unit,O=Test Org,CN=Test Root CA"
+      timestamping_time {}
+    }
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.hash.bmff.v3"
+      algorithm: "sha256"
+      hash: "\016^\204(\332\347\371\222!\343x\220d\3607&7\237\332\211ps\004\231\222q\362\021\2261\330\261"
+    }
+    created_assertions {
+      url: "self#jumbf=c2pa.assertions/c2pa.actions.v2"
+      algorithm: "sha256"
+      hash: "\332\t\376b\010\364\"\024\206\261\340\007Y\255<\361\257\277A\205Q\371\251k\004\227\202\004Ie\237\034"
+    }
+    default_algorithm: "sha256"
+    label: "c2pa.claim.v2"
+  )pb");
+  PartialValidationResultProto expected_validated_result = ParseTextProtoOrDie(
+      R"pb(
+        active_manifest {
+          label: "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a"
+          claim {
+            claim_generator_info { name: "Test generator" }
+            signature_info {
+              cert_serial_number: "02"
+              issuer: "OU=Test Unit,O=Test Org,CN=Test Claim Signer"
+              certificate_issuer: "OU=Test Unit,O=Test Org,CN=Test Root CA"
+              timestamping_time {}
+            }
+            created_assertions {
+              url: "self#jumbf=c2pa.assertions/c2pa.hash.bmff.v3"
+              algorithm: "sha256"
+              hash: "\016^\204(\332\347\371\222!\343x\220d\3607&7\237\332\211ps\004\231\222q\362\021\2261\330\261"
+            }
+            created_assertions {
+              url: "self#jumbf=c2pa.assertions/c2pa.actions.v2"
+              algorithm: "sha256"
+              hash: "\332\t\376b\010\364\"\024\206\261\340\007Y\255<\361\257\277A\205Q\371\251k\004\227\202\004Ie\237\034"
+            }
+            default_algorithm: "sha256"
+            label: "c2pa.claim.v2"
+            instance_id: "12345678-1234-1234-1234-123456789012"
+          }
+          assertions {
+            label: "c2pa.hash.bmff.v3"
+            bmff_based_hash { exclusions { xpath: "" } }
+          }
+          assertions {
+            label: "c2pa.actions.v2"
+            actions {
+              all_actions_included: true
+              actions {
+                action: "c2pa.created"
+                description: "created for testing"
+                digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture"
+                software_agent { name: "test generator" }
+              }
+            }
+          }
+          validation {
+            successes {
+              code: "timeStamp.validated"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature"
+            }
+            successes {
+              code: "timeStamp.trusted"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature"
+            }
+            successes {
+              code: "signingCredential.trusted"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature"
+            }
+            successes {
+              code: "claimSignature.insideValidity"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature"
+            }
+            successes {
+              code: "claimSignature.validated"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature"
+            }
+            successes {
+              code: "assertion.hashedURI.match"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.hash.bmff.v3"
+            }
+            successes {
+              code: "assertion.hashedURI.match"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.actions.v2"
+            }
+            informationals {
+              code: "signingCredential.ocsp.skipped"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature"
+            }
+          }
+        }
+        hard_binding_uri: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.hash.bmff.v3"
+      )pb");
+  absl::StatusOr<jumbf::BuiltSuperBox> built_manifest_store_box =
+      Encode(*manifest_encoder_, manifest_box);
+  ABSL_ASSERT_OK(built_manifest_store_box);
+  absl::Cord result_manifest_store_bytes =
+      std::move(*built_manifest_store_box).AsCord();
+  EXPECT_THAT(
+      ValidateClaim(result_manifest_store_bytes.Flatten()),
+      IsOkAndHolds(EqualsProtoIgnoringTimestampAndOrdering(expected_claim)));
+  EXPECT_THAT(ValidateManifestStore(result_manifest_store_bytes.Flatten()),
+              IsOkAndHolds(Pointee(EqualsProtoIgnoringTimestampAndOrdering(
+                  expected_validated_result))));
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/claim/local_signer.cc b/claim/local_signer.cc
new file mode 100644
index 0000000..896ad7e
--- /dev/null
+++ b/claim/local_signer.cc
@@ -0,0 +1,175 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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/local_signer.h"
+
+#include <cstddef>
+#include <cstdint>
+#include <memory>
+#include <optional>
+#include <string>
+#include <utility>
+#include <vector>
+
+#include "absl/functional/any_invocable.h"
+#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/clock.h"
+#include "absl/time/time.h"
+#include "async/async_context.h"
+#include "claim/signer.h"
+#include "cose/sig_structure.h"
+#include "cose/timestamp_creator.h"
+#include "crypto/crypto_write_handler.h"
+#include "crypto/hash.h"
+#include "utils/cbor_padding.h"
+
+namespace credentio {
+
+namespace {
+
+absl::StatusOr<std::string> FinalizeSignature(
+    const absl::StatusOr<std::string>& timestamp,
+    TimeStampingMode time_stamping_mode, uint64_t max_timestamp_byte_size,
+    CoseSign1TaggedStructure* cose_sign1, uint64_t max_signature_byte_size,
+    std::optional<size_t> pad_to_size) {
+  if (!timestamp.ok() && time_stamping_mode == TimeStampingMode::kRequired) {
+    return absl::Status(timestamp.status().code(),
+                        absl::StrCat("timestamping is required but failed: ",
+                                     timestamp.status().message()));
+  }
+  if (timestamp.ok()) {
+    if (timestamp.value().size() > max_timestamp_byte_size) {
+      if (time_stamping_mode == TimeStampingMode::kRequired) {
+        return absl::InternalError(
+            absl::StrCat("Timestamp size ", timestamp.value().size(),
+                         " is larger than the maximum allowed size ",
+                         max_timestamp_byte_size));
+      } else {
+        ABSL_LOG_EVERY_N_SEC(WARNING, 60)
+            << "Ignoring timestamp because the timestamp size "
+            << timestamp.value().size()
+            << " is larger than the maximum allowed size "
+            << max_timestamp_byte_size;
+      }
+    } else if (!timestamp.value().empty()) {
+      cose_sign1->unprotected_header.sig_tst2 =
+          TstContainer{.tst_tokens = {{.val = timestamp.value()}}};
+    }
+  }
+  if (pad_to_size.has_value()) {
+    // This logic is necessary for remote signing fallback to local where
+    // the template and final signatures must be padded to the same size.
+    // e.g. template uses remote signing but final uses local signing.
+    ABSL_RETURN_IF_ERROR(
+        PadCoseSign1TaggedStructure(*cose_sign1, *pad_to_size));
+  } else {
+    ABSL_RETURN_IF_ERROR(PadCoseSign1TaggedStructure(
+        *cose_sign1, max_signature_byte_size, max_timestamp_byte_size));
+  }
+  return EncodeCoseSign1TaggedStructure(*cose_sign1);
+}
+
+}  // namespace
+
+void LocalClaimSigner::Sign(
+    absl::string_view claim, Options options,
+    absl::AnyInvocable<void(absl::StatusOr<SignResult>) &&> cb) const {
+  AsyncResolver<SignResult> resolver(options.async_context, std::move(cb));
+
+  auto& signer = crypto_writer_.GetSigner();
+  auto protected_header_res = EncodeProtectedHeader(
+      ProtectedHeader{.alg = signer.algorithm(),
+                      .certificate_chain = signer.certificate_chain()});
+  if (!protected_header_res.ok()) {
+    resolver.Return(protected_header_res.status());
+    return;
+  }
+  std::string protected_header = *std::move(protected_header_res);
+
+  Sig1Structure sig_structure{
+      .context = "Signature1",
+      .body_protected = std::move(protected_header),
+      .payload = std::string(claim),
+  };
+  auto sign_res = signer.Sign(EncodeSig1Structure(sig_structure));
+  if (!sign_res.ok()) {
+    resolver.Return(sign_res.status());
+    return;
+  }
+  if (sign_res->signature.size() > signer.max_signature_byte_size()) {
+    resolver.Return(absl::InternalError(
+        absl::StrCat("Signature size ", sign_res->signature.size(),
+                     " is larger than the maximum allowed size ",
+                     signer.max_signature_byte_size())));
+    return;
+  }
+
+  auto cose_sign1 = std::make_shared<CoseSign1TaggedStructure>();
+  cose_sign1->protected_header = sig_structure.body_protected;
+  cose_sign1->signature = std::move(sign_res->signature);
+  // Add OCSP responses.
+  const std::vector<std::string>& ocsp_responses = signer.ocsp_responses();
+  if (!ocsp_responses.empty()) {
+    cose_sign1->unprotected_header.ocsp_responses = ocsp_responses;
+  }
+  // Create timestamp and finalize signature.
+  uint64_t max_signature_byte_size = signer.max_signature_byte_size();
+  uint64_t max_timestamp_byte_size =
+      crypto_writer_.max_timestamp_byte_size(hasher_factory_.algorithm());
+  auto time_stamping_mode = options.time_stamping_mode;
+
+  if (time_stamping_mode == TimeStampingMode::kOmittedWithPadding) {
+    auto signature = FinalizeSignature(
+        /*timestamp=*/"", time_stamping_mode, max_timestamp_byte_size,
+        cose_sign1.get(), max_signature_byte_size, pad_to_size_);
+    if (!signature.ok()) {
+      resolver.Return(signature.status());
+      return;
+    }
+    SignResult sign_result{.signature = std::move(*signature)};
+    resolver.Return(std::move(sign_result));
+    return;
+  }
+
+  TimestampCreator::Options timestamp_creator_options{
+      .async_context = options.async_context,
+  };
+
+  auto create_timestamp_cb =
+      [resolver = std::move(resolver), max_timestamp_byte_size, cose_sign1,
+       max_signature_byte_size, pad_to_size = pad_to_size_, time_stamping_mode](
+          absl::StatusOr<std::string> timestamp_result) mutable {
+        auto signature = FinalizeSignature(
+            timestamp_result, time_stamping_mode, max_timestamp_byte_size,
+            cose_sign1.get(), max_signature_byte_size, pad_to_size);
+        if (!signature.ok()) {
+          resolver.Return(signature.status());
+          return;
+        }
+        SignResult sign_result{.signature = std::move(*signature)};
+        resolver.Return(std::move(sign_result));
+      };
+
+  timestamp_creator_.CreateTimestamp(*cose_sign1,
+                                     std::move(timestamp_creator_options),
+                                     std::move(create_timestamp_cb));
+}
+
+}  // namespace credentio
diff --git a/claim/local_signer.h b/claim/local_signer.h
new file mode 100644
index 0000000..7937c3b
--- /dev/null
+++ b/claim/local_signer.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_CLAIM_LOCAL_SIGNER_H_
+#define THIRD_PARTY_CREDENTIO_CLAIM_LOCAL_SIGNER_H_
+
+#include <cstddef>
+#include <optional>
+
+#include "absl/base/nullability.h"
+#include "absl/functional/any_invocable.h"
+#include "absl/log/die_if_null.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "claim/signer.h"
+#include "cose/timestamp_creator.h"
+#include "crypto/crypto_write_handler.h"
+#include "crypto/hash.h"
+
+namespace credentio {
+// A signer to sign a Claim according to
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_signing_a_claim.
+class LocalClaimSigner : public ClaimSigner {
+ public:
+  explicit LocalClaimSigner(CryptoWriteHandler* absl_nonnull crypto_writer,
+                            const HasherFactory* absl_nonnull hasher_factory,
+                            std::optional<size_t> pad_to_size = std::nullopt)
+      : crypto_writer_(*ABSL_DIE_IF_NULL(crypto_writer)),
+        hasher_factory_(*ABSL_DIE_IF_NULL(hasher_factory)),
+        timestamp_creator_(&crypto_writer_, &hasher_factory_),
+        pad_to_size_(pad_to_size) {}
+
+  // Signs the serialized CBOR of the claim and returns the serialized
+  // COSE_Sign1_Tagged structure that will be written into the C2PA Claim
+  // Signature box. For more details, see
+  // https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_signing_a_claim.
+  // It guarantees that the size of the result is fixed and is a function of
+  // `max_signature_bytes_size()` and `max_timestamp_byte_size()` of the
+  // CryptoWriteHandler rather than of the actual signature and timestamp sizes.
+  void Sign(absl::string_view claim, Options options,
+            absl::AnyInvocable<void(absl::StatusOr<SignResult>) &&> cb)
+      const override;
+
+ private:
+  CryptoWriteHandler& crypto_writer_;
+  const HasherFactory& hasher_factory_;
+  TimestampCreator timestamp_creator_;
+  std::optional<size_t> pad_to_size_;
+};
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_CLAIM_LOCAL_SIGNER_H_
diff --git a/claim/local_signer_test.cc b/claim/local_signer_test.cc
new file mode 100644
index 0000000..d929352
--- /dev/null
+++ b/claim/local_signer_test.cc
@@ -0,0 +1,444 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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/local_signer.h"
+
+#include <cstdint>
+#include <memory>
+#include <string>
+#include <utility>
+#include <vector>
+
+#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/string_view.h"
+#include "absl/synchronization/notification.h"
+#include "async/async_context.h"
+#include "claim/signer.h"
+#include "cose/sig_structure.h"
+#include "cose/verifier.h"
+#include "crypto/algorithms.h"
+#include "crypto/crypto_read_handler.h"
+#include "crypto/crypto_write_handler.h"
+#include "crypto/default/default_crypto_read_handler.h"
+#include "crypto/default/hasher.h"
+#include "crypto/timestamp_write_handler.h"
+#include "gmock/gmock.h"
+#include "google/protobuf/util/message_differencer.h"
+#include "gtest/gtest.h"
+#include "proto/cose_verification_result.pb.h"
+#include "proto/signature_info.pb.h"
+#include "testing/create_test_crypto_write_handler.h"
+#include "testing/local_key_crypto_write_handler.h"
+#include "testing/proto_test_utils.h"
+
+namespace credentio {
+namespace {
+
+using ::absl_testing::StatusIs;
+using ::credentio_testing::ParseTextProtoOrDie;
+using ::testing::ElementsAre;
+using ::testing::HasSubstr;
+using ::testing::IsFalse;
+using ::testing::IsTrue;
+
+MATCHER_P(EqualsProtoIgnoringTimestampAndOrdering, expected, "") {
+  google::protobuf::util::MessageDifferencer differencer;
+  differencer.set_repeated_field_comparison(
+      google::protobuf::util::MessageDifferencer::AS_SET);
+  differencer.IgnoreField(
+      SignatureInfo::descriptor()->FindFieldByName("timestamping_time"));
+  std::string diff;
+  differencer.ReportDifferencesToString(&diff);
+  bool match = differencer.Compare(expected, arg);
+  if (!match) {
+    *result_listener << "\nDifference:\n" << diff;
+  }
+  return match;
+}
+
+absl::StatusOr<std::unique_ptr<CryptoReadHandler>>
+CreateTrustAnyoneCryptoReadHandlerForTest() {
+  return CreateDefaultCryptoReadHandler({
+      .trust_environment = CryptoReadHandler::TrustEnvironment::kAny,
+  });
+}
+
+absl::StatusOr<std::string> Sign(LocalClaimSigner& signer,
+                                 absl::string_view claim,
+                                 ClaimSigner::Options options = {}) {
+  absl::Notification done;
+  absl::StatusOr<std::string> signature =
+      absl::InternalError("Callback not called");
+  {
+    std::string claim_copy(claim);
+    signer.Sign(claim_copy, std::move(options),
+                [&](absl::StatusOr<ClaimSigner::SignResult> cb_result) {
+                  if (cb_result.ok()) {
+                    signature = std::move(cb_result->signature);
+                  } else {
+                    signature = cb_result.status();
+                  }
+                  done.Notify();
+                });
+  }
+  done.WaitForNotification();
+  return signature;
+}
+
+TEST(ClaimSigner, Sign) {
+  absl::StatusOr<std::unique_ptr<CryptoWriteHandler>> crypto_writer =
+      CreateTestCryptoWriteHandler({});
+  ABSL_ASSERT_OK(crypto_writer);
+  Sha256HasherFactory hasher_factory;
+  LocalClaimSigner claim_signer(crypto_writer->get(), &hasher_factory);
+  absl::StatusOr<std::string> signature = Sign(claim_signer, "abc");
+  ABSL_ASSERT_OK(signature);
+
+  absl::StatusOr<std::unique_ptr<CryptoReadHandler>>
+      crypto_read_handler_for_verifier =
+          CreateTrustAnyoneCryptoReadHandlerForTest();
+  ABSL_ASSERT_OK(crypto_read_handler_for_verifier);
+  std::unique_ptr<CoseVerifier> cose_verifier = CreateCoseVerifier(
+      {.crypto_read_handler = std::move(*crypto_read_handler_for_verifier)});
+  CoseVerificationResult result = cose_verifier->Verify(*signature, "abc");
+  CoseVerificationResult expected_result = ParseTextProtoOrDie(R"pb(
+    signature_info {
+      cert_serial_number: "02"
+      issuer: "OU=Test Unit,O=Test Org,CN=Test Claim Signer"
+      certificate_issuer: "OU=Test Unit,O=Test Org,CN=Test Root CA"
+    }
+    validation_status {
+      successes { code: "timeStamp.validated" url: "c2pa.signature" }
+      successes { code: "timeStamp.trusted" url: "c2pa.signature" }
+      successes { code: "signingCredential.trusted" url: "c2pa.signature" }
+      successes { code: "claimSignature.insideValidity" url: "c2pa.signature" }
+      successes { code: "claimSignature.validated" url: "c2pa.signature" }
+      informationals {
+        code: "signingCredential.ocsp.skipped"
+        url: "c2pa.signature"
+      }
+    }
+  )pb");
+  EXPECT_THAT(result, EqualsProtoIgnoringTimestampAndOrdering(expected_result));
+}
+
+// This signer produces signatures that are larger than its advertised
+// max_signature_byte_size.
+class InvalidFixedKeySigner : public FixedKeySigner {
+ public:
+  SigningAlgorithm algorithm() const override {
+    return SigningAlgorithm::kPs256;
+  }
+  const std::vector<std::string>& certificate_chain() const override {
+    return certificate_chain_;
+  }
+  const std::vector<std::string>& ocsp_responses() const override {
+    return ocsp_responses_;
+  }
+  uint64_t max_signature_byte_size() const override { return 100; }
+  absl::StatusOr<SignResponse> Sign(
+      absl::string_view to_be_signed) const override {
+    return SignResponse{.signature = std::string(200, '\0')};
+  }
+
+ private:
+  std::vector<std::string> certificate_chain_ = {"cert1", "cert2"};
+  std::vector<std::string> ocsp_responses_;
+};
+
+class InvalidCryptoWriteHandler : public CryptoWriteHandler {
+ public:
+  const FixedKeySigner& GetSigner() override { return signer_; }
+  void CreateTimestamp(HashAlgorithm hash_algorithm, std::string hashed_message,
+                       TimestampWriteHandler::Options options,
+                       absl::AnyInvocable<void(absl::StatusOr<std::string>) &&>
+                           cb) const override {
+    AsyncResolver<std::string>(options.async_context, std::move(cb))
+        .Return(std::string(100, '\0'));
+  }
+  uint64_t max_timestamp_byte_size(
+      HashAlgorithm hash_algorithm) const override {
+    return 100;
+  }
+
+ private:
+  InvalidFixedKeySigner signer_;
+};
+
+TEST(ClaimSigner, SignatureTooLarge) {
+  InvalidCryptoWriteHandler crypto_writer;
+  Sha256HasherFactory hasher_factory;
+  LocalClaimSigner claim_signer(&crypto_writer, &hasher_factory);
+  EXPECT_THAT(Sign(claim_signer, "abc"), StatusIs(absl::StatusCode::kInternal,
+                                                  HasSubstr("Signature size")));
+}
+
+class DelegatingCryptoWriteHandler : public CryptoWriteHandler {
+ public:
+  explicit DelegatingCryptoWriteHandler(CryptoWriteHandler* delegate)
+      : delegate_(delegate) {}
+
+  const FixedKeySigner& GetSigner() override { return delegate_->GetSigner(); }
+
+  void CreateTimestamp(HashAlgorithm hash_algorithm, std::string hashed_message,
+                       TimestampWriteHandler::Options options,
+                       absl::AnyInvocable<void(absl::StatusOr<std::string>) &&>
+                           cb) const override {
+    delegate_->CreateTimestamp(hash_algorithm, std::move(hashed_message),
+                               options, std::move(cb));
+  }
+
+  uint64_t max_timestamp_byte_size(
+      HashAlgorithm hash_algorithm) const override {
+    return delegate_->max_timestamp_byte_size(hash_algorithm);
+  }
+
+ protected:
+  CryptoWriteHandler* delegate_;
+};
+
+class InvalidTimestampCryptoWriteHandler : public DelegatingCryptoWriteHandler {
+ public:
+  using DelegatingCryptoWriteHandler::DelegatingCryptoWriteHandler;
+
+  uint64_t max_timestamp_byte_size(
+      HashAlgorithm hash_algorithm) const override {
+    return 10;
+  }
+};
+
+TEST(ClaimSigner, TimestampTooLarge) {
+  absl::StatusOr<std::unique_ptr<CryptoWriteHandler>> crypto_writer =
+      CreateTestCryptoWriteHandler({});
+  ABSL_ASSERT_OK(crypto_writer);
+  InvalidTimestampCryptoWriteHandler invalid_crypto_writer(
+      crypto_writer->get());
+  Sha256HasherFactory hasher_factory;
+  LocalClaimSigner claim_signer(&invalid_crypto_writer, &hasher_factory);
+  absl::StatusOr<std::string> signature = Sign(claim_signer, "abc");
+  ABSL_ASSERT_OK(signature);
+  absl::StatusOr<CoseSign1TaggedStructure> cose_sign1 =
+      DecodeCoseSign1TaggedStructure(*signature);
+  ABSL_ASSERT_OK(cose_sign1);
+  EXPECT_THAT(cose_sign1->unprotected_header.sig_tst2.has_value(), IsFalse());
+}
+
+TEST(ClaimSigner, TimestampTooLargeTimestampingRequired) {
+  absl::StatusOr<std::unique_ptr<CryptoWriteHandler>> crypto_writer =
+      CreateTestCryptoWriteHandler({});
+  ABSL_ASSERT_OK(crypto_writer);
+  InvalidTimestampCryptoWriteHandler invalid_crypto_writer(
+      crypto_writer->get());
+  Sha256HasherFactory hasher_factory;
+  LocalClaimSigner claim_signer(&invalid_crypto_writer, &hasher_factory);
+  EXPECT_THAT(
+      Sign(claim_signer, "abc",
+           {.time_stamping_mode = TimeStampingMode::kRequired}),
+      StatusIs(absl::StatusCode::kInternal, HasSubstr("Timestamp size")));
+}
+
+class FixedMaxTimestampCryptoWriteHandler
+    : public DelegatingCryptoWriteHandler {
+ public:
+  FixedMaxTimestampCryptoWriteHandler(CryptoWriteHandler* delegate,
+                                      uint64_t max_timestamp_size)
+      : DelegatingCryptoWriteHandler(delegate),
+        max_timestamp_size_(max_timestamp_size) {}
+
+  uint64_t max_timestamp_byte_size(
+      HashAlgorithm hash_algorithm) const override {
+    return max_timestamp_size_;
+  }
+
+ private:
+  uint64_t max_timestamp_size_;
+};
+
+class UnavailableTimestampCryptoWriteHandler
+    : public DelegatingCryptoWriteHandler {
+ public:
+  UnavailableTimestampCryptoWriteHandler(CryptoWriteHandler* delegate,
+                                         uint64_t max_timestamp_size)
+      : DelegatingCryptoWriteHandler(delegate),
+        max_timestamp_size_(max_timestamp_size) {}
+
+  void CreateTimestamp(HashAlgorithm hash_algorithm, std::string hashed_message,
+                       TimestampWriteHandler::Options options,
+                       absl::AnyInvocable<void(absl::StatusOr<std::string>) &&>
+                           cb) const override {
+    AsyncResolver<std::string>(options.async_context, std::move(cb))
+        .Return(absl::UnavailableError("Timestamping is unavailable."));
+  }
+
+  uint64_t max_timestamp_byte_size(
+      HashAlgorithm hash_algorithm) const override {
+    return max_timestamp_size_;
+  }
+
+ private:
+  uint64_t max_timestamp_size_;
+};
+
+TEST(ClaimSignerTest, TimeStampingRequired) {
+  absl::StatusOr<std::unique_ptr<CryptoWriteHandler>> delegate =
+      CreateTestCryptoWriteHandler({});
+  ABSL_ASSERT_OK(delegate);
+  UnavailableTimestampCryptoWriteHandler crypto_writer(delegate->get(), 100);
+  Sha256HasherFactory hasher_factory;
+  LocalClaimSigner claim_signer(&crypto_writer, &hasher_factory);
+  EXPECT_THAT(Sign(claim_signer, "abc",
+                   {.time_stamping_mode = TimeStampingMode::kRequired}),
+              StatusIs(absl::StatusCode::kUnavailable,
+                       HasSubstr("Timestamping is unavailable.")));
+}
+
+TEST(ClaimSigner, TimeStampingOmittedWithPadding) {
+  absl::StatusOr<std::unique_ptr<CryptoWriteHandler>> delegate =
+      CreateTestCryptoWriteHandler({});
+  ABSL_ASSERT_OK(delegate);
+  FixedMaxTimestampCryptoWriteHandler crypto_writer(delegate->get(), 2000);
+  Sha256HasherFactory hasher_factory;
+  LocalClaimSigner claim_signer(&crypto_writer, &hasher_factory);
+  absl::StatusOr<std::string> signature =
+      Sign(claim_signer, "abc",
+           {.time_stamping_mode = TimeStampingMode::kOmittedWithPadding});
+  ABSL_ASSERT_OK(signature);
+  absl::StatusOr<CoseSign1TaggedStructure> cose_sign1 =
+      DecodeCoseSign1TaggedStructure(*signature);
+  ABSL_ASSERT_OK(cose_sign1);
+  EXPECT_FALSE(cose_sign1->signature.empty());
+  EXPECT_THAT(cose_sign1->unprotected_header.sig_tst2.has_value(), IsFalse());
+
+  // Check if the padding is of correct size.
+  absl::StatusOr<std::string> signature2 = Sign(
+      claim_signer, "abc", {.time_stamping_mode = TimeStampingMode::kRequired});
+  ABSL_ASSERT_OK(signature2);
+  EXPECT_EQ(signature->size(), signature2->size());
+}
+
+class ClaimSignerTest : public testing::TestWithParam<int64_t> {};
+
+TEST_P(ClaimSignerTest, SignatureSizeUnaffectedByTimestamp) {
+  absl::StatusOr<std::unique_ptr<CryptoWriteHandler>> delegate =
+      CreateTestCryptoWriteHandler({});
+  ABSL_ASSERT_OK(delegate);
+  int64_t timestamp_size = GetParam();
+  UnavailableTimestampCryptoWriteHandler unavailable_crypto_writer(
+      delegate->get(), timestamp_size);
+  Sha256HasherFactory hasher_factory;
+  LocalClaimSigner claim_signer(&unavailable_crypto_writer, &hasher_factory);
+  absl::StatusOr<std::string> signature1 = Sign(claim_signer, "abc");
+  ABSL_ASSERT_OK(signature1);
+
+  FixedMaxTimestampCryptoWriteHandler normal_crypto_writer(delegate->get(),
+                                                           timestamp_size);
+  LocalClaimSigner claim_signer2(&normal_crypto_writer, &hasher_factory);
+  absl::StatusOr<std::string> signature2 = Sign(claim_signer2, "abc");
+  ABSL_ASSERT_OK(signature2);
+  EXPECT_EQ(signature1->size(), signature2->size());
+}
+INSTANTIATE_TEST_SUITE_P(ClaimSignerTestWithParams, ClaimSignerTest,
+                         testing::Values(100, 200, 500, 1000, 10000));
+
+class FixedKeySignerWithOcsp : public FixedKeySigner {
+ public:
+  FixedKeySignerWithOcsp(const FixedKeySigner& delegate,
+                         std::vector<std::string> ocsp_responses)
+      : delegate_(delegate), ocsp_responses_(std::move(ocsp_responses)) {}
+  SigningAlgorithm algorithm() const override { return delegate_.algorithm(); }
+  const std::vector<std::string>& certificate_chain() const override {
+    return delegate_.certificate_chain();
+  }
+  const std::vector<std::string>& ocsp_responses() const override {
+    return ocsp_responses_;
+  }
+  uint64_t max_signature_byte_size() const override {
+    return delegate_.max_signature_byte_size();
+  }
+  absl::StatusOr<SignResponse> Sign(
+      absl::string_view to_be_signed) const override {
+    return delegate_.Sign(to_be_signed);
+  }
+
+ private:
+  const FixedKeySigner& delegate_;
+  std::vector<std::string> ocsp_responses_;
+};
+
+class CryptoWriteHandlerWithOcsp : public DelegatingCryptoWriteHandler {
+ public:
+  CryptoWriteHandlerWithOcsp(CryptoWriteHandler* delegate,
+                             std::vector<std::string> ocsp_responses)
+      : DelegatingCryptoWriteHandler(delegate),
+        signer_(delegate->GetSigner(), std::move(ocsp_responses)) {}
+  const FixedKeySigner& GetSigner() override { return signer_; }
+
+ private:
+  FixedKeySignerWithOcsp signer_;
+};
+
+TEST(ClaimSigner, OcspResponsesWithTimestamp) {
+  absl::StatusOr<std::unique_ptr<CryptoWriteHandler>> delegate =
+      CreateTestCryptoWriteHandler({});
+  ABSL_ASSERT_OK(delegate);
+  CryptoWriteHandlerWithOcsp crypto_writer(delegate->get(), {"ocsp1", "ocsp2"});
+  Sha256HasherFactory hasher_factory;
+  LocalClaimSigner claim_signer(&crypto_writer, &hasher_factory);
+  absl::StatusOr<std::string> signature = Sign(claim_signer, "abc");
+  ABSL_ASSERT_OK(signature);
+
+  absl::StatusOr<CoseSign1TaggedStructure> cose_sign1 =
+      DecodeCoseSign1TaggedStructure(*signature);
+  ABSL_ASSERT_OK(cose_sign1);
+  EXPECT_THAT(cose_sign1->unprotected_header.ocsp_responses,
+              ElementsAre("ocsp1", "ocsp2"));
+  EXPECT_THAT(cose_sign1->unprotected_header.sig_tst2.has_value(), IsTrue());
+}
+
+TEST(ClaimSigner, OcspResponsesWithoutTimestamp) {
+  absl::StatusOr<std::unique_ptr<CryptoWriteHandler>> delegate =
+      CreateTestCryptoWriteHandler({.disable_timestamping = true});
+  ABSL_ASSERT_OK(delegate);
+  CryptoWriteHandlerWithOcsp crypto_writer(delegate->get(), {"ocsp1", "ocsp2"});
+  Sha256HasherFactory hasher_factory;
+  LocalClaimSigner claim_signer(&crypto_writer, &hasher_factory);
+  absl::StatusOr<std::string> signature = Sign(claim_signer, "abc");
+  ABSL_ASSERT_OK(signature);
+
+  absl::StatusOr<CoseSign1TaggedStructure> cose_sign1 =
+      DecodeCoseSign1TaggedStructure(*signature);
+  ABSL_ASSERT_OK(cose_sign1);
+  EXPECT_THAT(cose_sign1->unprotected_header.ocsp_responses,
+              ElementsAre("ocsp1", "ocsp2"));
+  EXPECT_THAT(cose_sign1->unprotected_header.sig_tst2.has_value(), IsFalse());
+}
+
+TEST(ClaimSigner, PadToSize) {
+  absl::StatusOr<std::unique_ptr<CryptoWriteHandler>> crypto_writer =
+      CreateTestCryptoWriteHandler({});
+  ABSL_ASSERT_OK(crypto_writer);
+  Sha256HasherFactory hasher_factory;
+  LocalClaimSigner claim_signer(crypto_writer->get(), &hasher_factory,
+                                /*pad_to_size=*/99999);
+  absl::StatusOr<std::string> signature = Sign(claim_signer, "abc");
+  ABSL_ASSERT_OK(signature);
+  EXPECT_EQ(signature->size(), 99999);
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/claim/manifest_box.h b/claim/manifest_box.h
new file mode 100644
index 0000000..767c638
--- /dev/null
+++ b/claim/manifest_box.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_CLAIM_MANIFEST_BOX_H_
+#define THIRD_PARTY_CREDENTIO_CLAIM_MANIFEST_BOX_H_
+
+#include <optional>
+#include <string>
+#include <vector>
+
+#include "claim/manifest_store_builder.h"
+#include "proto/assertion.pb.h"
+#include "proto/generator.pb.h"
+
+namespace credentio {
+
+// An intermediate representation of a manifest box to be used for claim
+// generation.
+struct ManifestBox {
+  std::string label;
+  std::string instance_id;
+  Assertion hard_binding_assertion;
+  Assertion actions_assertion;
+  std::vector<Assertion> part_hash_assertions;
+  std::optional<Assertion> multi_part_assertion;
+  std::vector<Assertion> ingredient_assertions;
+  std::vector<GatheredAssertion> gathered_assertions;
+  ManifestStoreBuilder manifest_store_builder;
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_CLAIM_MANIFEST_BOX_H_
diff --git a/claim/manifest_box_assembler.cc b/claim/manifest_box_assembler.cc
new file mode 100644
index 0000000..aa421d3
--- /dev/null
+++ b/claim/manifest_box_assembler.cc
@@ -0,0 +1,631 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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_box_assembler.h"
+
+#include <cstdint>
+#include <optional>
+#include <string>
+#include <utility>
+#include <vector>
+
+#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/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 "claim/manifest_box.h"
+#include "claim/manifest_store_builder.h"
+#include "constants/ingredient_relationships.h"
+#include "constants/labels.h"
+#include "constants/spec_version.h"
+#include "proto/actions_assertion.pb.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.pb.h"
+#include "proto/generator_info.pb.h"
+#include "proto/hashed_uri.pb.h"
+#include "proto/ingredient_assertion.pb.h"
+#include "proto/ingredient_validation_result.pb.h"
+#include "proto/multi_asset_hash_assertion.pb.h"
+#include "utils/cbor_padding.h"
+
+namespace credentio {
+namespace {
+
+absl::Status ValidateRequest(const GenerateManifestStoreRequest& request) {
+  if (request.hard_binding().binding_case() == HardBinding::BINDING_NOT_SET) {
+    return absl::InvalidArgumentError(
+        "The request does not have a hard binding.");
+  }
+  for (const auto& gathered : request.gathered_assertions()) {
+    if (gathered.label().empty()) {
+      return absl::InvalidArgumentError(
+          "Gathered assertion must have a non-empty label.");
+    }
+    if (gathered.content_type() ==
+        GatheredAssertion::CONTENT_TYPE_UNSPECIFIED) {
+      return absl::InvalidArgumentError(
+          "Gathered assertion content type must be specified.");
+    }
+  }
+  return absl::OkStatus();
+}
+
+void ConvertActionMetadata(const ActionMetadata& action_metadata,
+                           ActionItem* action_item) {
+  if (action_metadata.has_software_agent()) {
+    *action_item->mutable_software_agent() = action_metadata.software_agent();
+  }
+  if (action_metadata.has_description()) {
+    action_item->set_description(action_metadata.description());
+  }
+  if (action_metadata.has_multiple_instances()) {
+    action_item->mutable_parameters()->set_multiple_instances(
+        action_metadata.multiple_instances());
+  }
+}
+
+absl::StatusOr<Assertion> ConvertIngredientAssertion(
+    const IngredientInput& ingredient, absl::string_view label,
+    absl::string_view relationship,
+    const std::optional<IngredientManifestUris>& ingredient_uris,
+    const std::optional<IngredientValidationResults>&
+        ingredient_validation_results,
+    const std::optional<std::string>& digital_source_type) {
+  Assertion assertion;
+
+  // Convert Assertion box fields.
+  assertion.set_label(label);
+  auto* ingredient_v3 = assertion.mutable_ingredient_v3();
+
+  // Convert Ingredient Assertion V3 fields.
+  if (ingredient.metadata().has_title()) {
+    ingredient_v3->set_title(ingredient.metadata().title());
+  }
+  if (ingredient.metadata().has_media_type()) {
+    ingredient_v3->set_media_type(ingredient.metadata().media_type());
+  } else if (ingredient.ingredient_validation_results().has_media_type()) {
+    ingredient_v3->set_media_type(
+        ingredient.ingredient_validation_results().media_type());
+  }
+  ingredient_v3->set_relationship(relationship);
+  if (ingredient.metadata().has_instance_id()) {
+    ingredient_v3->set_instance_id(ingredient.metadata().instance_id());
+  }
+  if (ingredient.metadata().has_description()) {
+    ingredient_v3->set_description(ingredient.metadata().description());
+  }
+  if (ingredient.metadata().has_informational_uri()) {
+    ingredient_v3->set_informational_uri(
+        ingredient.metadata().informational_uri());
+  }
+  if (ingredient_uris.has_value()) {
+    // The ingredient URIs provided by the ManifestStoreBuilder should always
+    // have the active manifest and claim signature URIs set.
+    if (ingredient_uris->active_manifest.url().empty()) {
+      return absl::InternalError(
+          "Should not happen. Ingredient URI missing active manifest.");
+    }
+    *ingredient_v3->mutable_active_manifest() =
+        ingredient_uris->active_manifest;
+    if (ingredient_uris->claim_signature.url().empty()) {
+      return absl::InternalError(
+          "Should not happen. Ingredient URI missing claim signature.");
+    }
+    *ingredient_v3->mutable_claim_signature() =
+        ingredient_uris->claim_signature;
+  }
+  if (ingredient_validation_results.has_value()) {
+    *ingredient_v3->mutable_validation_results() =
+        ingredient_validation_results.value();
+  }
+  if (digital_source_type.has_value()) {
+    ingredient_v3->set_digital_source_type(*digital_source_type);
+  }
+  return assertion;
+}
+
+// An ingredient listed in a GenerateManifestStoreRequest.
+class Ingredient {
+ public:
+  explicit Ingredient(std::string label, const IngredientInput* input)
+      : label_(std::move(label)), input_(*ABSL_DIE_IF_NULL(input)) {}
+
+  absl::Status SetRelationship(absl::string_view relationship) {
+    if (relationship_.empty()) {
+      relationship_ = relationship;
+    } else if (relationship != relationship_) {
+      return absl::InvalidArgumentError(
+          absl::Substitute("Inconsistent use of ingredient ($0 and $1)",
+                           relationship_, relationship));
+    }
+    return absl::OkStatus();
+  }
+
+  absl::string_view label() const { return label_; }
+  absl::string_view relationship() const { return relationship_; }
+  const IngredientInput& input() const { return input_; }
+
+ private:
+  const std::string label_;
+  const IngredientInput& input_;
+  absl::string_view relationship_;
+};
+
+// Creates an assertion label per
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_multiple_instances.
+std::string CreateLabel(absl::string_view base_label, int index) {
+  if (index == 0) {
+    return std::string(base_label);
+  }
+  return absl::StrCat(base_label, "__", index);
+}
+
+class PartLabelProvider {
+ public:
+  absl::StatusOr<std::string> GetLabel(PartBinding::BindingCase binding_case) {
+    absl::string_view base_label;
+    switch (binding_case) {
+      case PartBinding::kBmffBasedHash:
+        base_label = kBmffBasedHashAssertionV3PartLabel;
+        break;
+      case PartBinding::kBoxHash:
+        base_label = kBoxesHashAssertionPartLabel;
+        break;
+      case PartBinding::kDataHash:
+        base_label = kDataHashAssertionPartLabel;
+        break;
+      default:
+        return absl::InvalidArgumentError(
+            absl::StrCat("Unsupported assertion type: ", binding_case));
+    }
+    return CreateLabel(base_label, assertion_type_to_count_[base_label]++);
+  }
+
+ private:
+  absl::flat_hash_map<absl::string_view, int32_t> assertion_type_to_count_;
+};
+
+absl::Status AddMultiAssetHashToManifestBox(
+    const GenerateManifestStoreRequest& request, ManifestBox& manifest_box) {
+  if (request.hard_binding().part_bindings_size() == 0) {
+    // Doesn't have any multi-asset hash assertion information.
+    return absl::OkStatus();
+  }
+
+  Assertion& multi_asset_hash_assertion =
+      manifest_box.multi_part_assertion.emplace();
+  multi_asset_hash_assertion.set_label(kMultiAssetHashAssertionLabel);
+
+  PartLabelProvider part_label_provider;
+  for (const auto& part : request.hard_binding().part_bindings()) {
+    Assertion& part_hash_assertion =
+        manifest_box.part_hash_assertions.emplace_back();
+    ABSL_ASSIGN_OR_RETURN(std::string label,
+                          part_label_provider.GetLabel(part.binding_case()));
+    part_hash_assertion.set_label(std::move(label));
+    switch (part.binding_case()) {
+      case PartBinding::kBmffBasedHash:
+        *part_hash_assertion.mutable_bmff_based_hash() = part.bmff_based_hash();
+        break;
+      case PartBinding::kBoxHash:
+        *part_hash_assertion.mutable_boxes_hash() = part.box_hash();
+        PadBoxesHashAssertion(*part_hash_assertion.mutable_boxes_hash());
+        break;
+      case PartBinding::kDataHash:
+        *part_hash_assertion.mutable_data_hash() = part.data_hash();
+        PadDataHashAssertion(*part_hash_assertion.mutable_data_hash());
+        break;
+      default:
+        return absl::InvalidArgumentError(
+            absl::StrCat("Unsupported assertion type: ", part.binding_case()));
+    }
+
+    MultiAssetPart& multi_asset_part =
+        *multi_asset_hash_assertion.mutable_multi_asset_hash()->add_parts();
+    multi_asset_part.mutable_location()->set_byte_offset(
+        part.location().byte_offset());
+    multi_asset_part.mutable_location()->set_length(part.location().length());
+    multi_asset_part.mutable_hash_assertion()->set_url(absl::StrCat(
+        "self#jumbf=c2pa.assertions/", part_hash_assertion.label()));
+    multi_asset_part.set_is_optional(part.is_optional());
+  }
+
+  PadMultiAssetHashAssertion(
+      *multi_asset_hash_assertion.mutable_multi_asset_hash());
+
+  return absl::OkStatus();
+}
+
+}  // namespace
+
+// A single manifest box assembly operation.
+class StandardManifestBoxAssembler::AssembleOp {
+ public:
+  explicit AssembleOp(const StandardManifestBoxAssembler& assembler,
+                      const GenerateManifestStoreRequest& request)
+      : assembler_(assembler), request_(request) {
+    ingredients_.reserve(request.ingredients_size());
+    for (int i = 0; i < request.ingredients_size(); ++i) {
+      ingredients_.push_back(
+          Ingredient(CreateLabel(kIngredientAssertionV3Label, i),
+                     &request.ingredients(i)));
+    }
+  }
+
+  absl::StatusOr<ManifestBox> Run() {
+    ABSL_RETURN_IF_ERROR(ValidateRequest(request_));
+    ManifestBox manifest_box = {.manifest_store_builder = ManifestStoreBuilder(
+                                    &assembler_.hash_factory_)};
+    manifest_box.label = absl::StrCat(
+        "urn:c2pa:", assembler_.uuid_generator_.Generate().ToString());
+    if (request_.has_instance_id()) {
+      manifest_box.instance_id = request_.instance_id();
+    } else {
+      manifest_box.instance_id =
+          assembler_.uuid_generator_.Generate().ToString();
+    }
+
+    // Convert hard binding assertion.
+    {
+      Assertion& hard_binding = manifest_box.hard_binding_assertion;
+      if (request_.hard_binding().has_bmff_based_hash()) {
+        hard_binding.set_label(kBmffBasedHashAssertionV3Label);
+        *hard_binding.mutable_bmff_based_hash() =
+            request_.hard_binding().bmff_based_hash();
+      } else if (request_.hard_binding().has_box_hash()) {
+        hard_binding.set_label(kBoxesHashAssertionLabel);
+        *hard_binding.mutable_boxes_hash() = request_.hard_binding().box_hash();
+        PadBoxesHashAssertion(*hard_binding.mutable_boxes_hash());
+      } else if (request_.hard_binding().has_data_hash()) {
+        hard_binding.set_label(kDataHashAssertionLabel);
+        *hard_binding.mutable_data_hash() = request_.hard_binding().data_hash();
+        PadDataHashAssertion(*hard_binding.mutable_data_hash());
+      } else if (request_.hard_binding().has_collection_data_hash()) {
+        hard_binding.set_label(kCollectionDataHashAssertionLabel);
+        *hard_binding.mutable_collection_data_hash() =
+            request_.hard_binding().collection_data_hash();
+      }
+    }
+
+    ABSL_RETURN_IF_ERROR(
+        AddMultiAssetHashToManifestBox(request_, manifest_box));
+
+    // Process inception action.
+    Assertion& actions_assertion = manifest_box.actions_assertion;
+    ActionsAssertion& actions = *actions_assertion.mutable_actions();
+    std::optional<int> opened_ingredient_index = std::nullopt;
+    {
+      actions_assertion.set_label(kActionsAssertionV2Label);
+      if (request_.actions().has_all_actions_included()) {
+        actions.set_all_actions_included(
+            request_.actions().all_actions_included());
+      }
+      if (request_.actions().has_created()) {
+        ABSL_RETURN_IF_ERROR(ConvertActionWithInputIngredients(
+            "c2pa.created", request_.actions().created(),
+            actions.add_actions()));
+      } else if (request_.actions().has_opened()) {
+        OpenedAction opened_action = request_.actions().opened();
+        ABSL_RETURN_IF_ERROR(
+            ConvertOpenedAction(opened_action, actions.add_actions()));
+        opened_ingredient_index = opened_action.ingredient_index();
+      } else {
+        return absl::InvalidArgumentError(
+            "missing inception action (created or opened)");
+      }
+    }
+
+    // Process remaining actions.
+    for (const auto& action : request_.actions().actions()) {
+      switch (action.action_case()) {
+        case Action::kAddedText:
+          ABSL_RETURN_IF_ERROR(ConvertDigitalSourceTypeAction(
+              "c2pa.addedText", action.added_text(), actions.add_actions()));
+          break;
+        case Action::kAdjustedColor:
+          ABSL_RETURN_IF_ERROR(ConvertDigitalSourceTypeAction(
+              "c2pa.adjustedColor", action.adjusted_color(),
+              actions.add_actions()));
+          break;
+        case Action::kChangedSpeed:
+          ABSL_RETURN_IF_ERROR(ConvertDigitalSourceTypeAction(
+              "c2pa.changedSpeed", action.changed_speed(),
+              actions.add_actions()));
+          break;
+        case Action::kConverted:
+          ABSL_RETURN_IF_ERROR(ConvertSimpleAction(
+              "c2pa.converted", action.converted(), actions.add_actions()));
+          break;
+        case Action::kCropped:
+          ABSL_RETURN_IF_ERROR(ConvertDigitalSourceTypeAction(
+              "c2pa.cropped", action.cropped(), actions.add_actions()));
+          break;
+        case Action::kDeleted:
+          ABSL_RETURN_IF_ERROR(ConvertDigitalSourceTypeAction(
+              "c2pa.deleted", action.deleted(), actions.add_actions()));
+          break;
+        case Action::kDrawing:
+          ABSL_RETURN_IF_ERROR(ConvertDigitalSourceTypeAction(
+              "c2pa.drawing", action.drawing(), actions.add_actions()));
+          break;
+        case Action::kDubbed:
+          ABSL_RETURN_IF_ERROR(ConvertDigitalSourceTypeAction(
+              "c2pa.dubbed", action.dubbed(), actions.add_actions()));
+          break;
+        case Action::kEdited:
+          ABSL_RETURN_IF_ERROR(ConvertActionWithInputIngredients(
+              "c2pa.edited", action.edited(), actions.add_actions()));
+          break;
+        case Action::kEditedMetadata:
+          ABSL_RETURN_IF_ERROR(ConvertSimpleAction("c2pa.edited.metadata",
+                                                   action.edited_metadata(),
+                                                   actions.add_actions()));
+          break;
+        case Action::kEnhanced:
+          ABSL_RETURN_IF_ERROR(ConvertDigitalSourceTypeAction(
+              "c2pa.enhanced", action.enhanced(), actions.add_actions()));
+          break;
+        case Action::kFiltered:
+          ABSL_RETURN_IF_ERROR(ConvertActionWithInputIngredients(
+              "c2pa.filtered", action.filtered(), actions.add_actions()));
+          break;
+        case Action::kOrientation:
+          ABSL_RETURN_IF_ERROR(ConvertDigitalSourceTypeAction(
+              "c2pa.orientation", action.orientation(), actions.add_actions()));
+          break;
+        case Action::kPlaced:
+          ABSL_RETURN_IF_ERROR(
+              ConvertPlacedAction(action.placed(), actions.add_actions()));
+          break;
+        case Action::kRepackaged:
+          ABSL_RETURN_IF_ERROR(ConvertRepackagedAction(
+              "c2pa.repackaged", action.repackaged(), actions.add_actions(),
+              opened_ingredient_index));
+          break;
+        case Action::kResized:
+          ABSL_RETURN_IF_ERROR(ConvertDigitalSourceTypeAction(
+              "c2pa.resized", action.resized(), actions.add_actions()));
+          break;
+        case Action::kTranscoded:
+          ABSL_RETURN_IF_ERROR(ConvertTranscodedAction(
+              "c2pa.transcoded", action.transcoded(), actions.add_actions(),
+              opened_ingredient_index));
+          break;
+        case Action::kTrimmed:
+          ABSL_RETURN_IF_ERROR(ConvertDigitalSourceTypeAction(
+              "c2pa.trimmed", action.trimmed(), actions.add_actions()));
+          break;
+        case Action::kUnknown:
+          ABSL_RETURN_IF_ERROR(ConvertSimpleAction(
+              "c2pa.unknown", action.unknown(), actions.add_actions()));
+          break;
+        case Action::ACTION_NOT_SET:
+          return absl::InvalidArgumentError("empty action");
+      }
+    }
+
+    // Add redaction actions from ingredients.
+    absl::flat_hash_set<absl::string_view> seen_redaction_uris;
+    for (const auto& ingredient : ingredients_) {
+      if (ingredient.input().has_ingredient_validation_results()) {
+        const IngredientValidationResultProto& result =
+            ingredient.input().ingredient_validation_results();
+        for (const auto& redaction : result.redactions()) {
+          // Currently, duplicate manifests are skipped in order of iteration
+          // over `ingredients_`.
+          if (!seen_redaction_uris.insert(redaction.uri()).second) {
+            continue;
+          }
+          ActionItem* action = actions.add_actions();
+          action->set_action("c2pa.redacted");
+          action->set_reason(redaction.reason());
+          action->mutable_parameters()->set_redacted_uri(redaction.uri());
+        }
+      }
+    }
+
+    // Import ingredients.
+    for (const auto& ingredient : ingredients_) {
+      ABSL_RETURN_IF_ERROR(
+          ImportIngredient(ingredient, manifest_box.ingredient_assertions,
+                           manifest_box.manifest_store_builder));
+    }
+
+    // Copy gathered assertions.
+    manifest_box.gathered_assertions.assign(
+        request_.gathered_assertions().begin(),
+        request_.gathered_assertions().end());
+
+    return manifest_box;
+  }
+
+ private:
+  absl::StatusOr<Ingredient*> GetIngredient(int ingredient_index) {
+    if (ingredient_index < 0 || ingredient_index >= ingredients_.size()) {
+      return absl::InvalidArgumentError(
+          absl::Substitute("ingredient index out of range [0, $0]: $1",
+                           ingredients_.size(), ingredient_index));
+    }
+    return &ingredients_[ingredient_index];
+  }
+
+  absl::Status AddIngredientToAction(int ingredient_index,
+                                     absl::string_view relationship,
+                                     ActionItem* action_item) {
+    ABSL_ASSIGN_OR_RETURN(Ingredient * ingredient,
+                          GetIngredient(ingredient_index));
+    ABSL_RETURN_IF_ERROR(ingredient->SetRelationship(relationship));
+    action_item->mutable_parameters()->add_ingredients()->set_url(
+        absl::StrCat(kAssertionStoreUrlPrefix, ingredient->label()));
+    return absl::OkStatus();
+  }
+
+  absl::Status ConvertOpenedAction(const OpenedAction& action,
+                                   ActionItem* action_item) {
+    ABSL_RETURN_IF_ERROR(
+        ConvertSimpleAction("c2pa.opened", action, action_item));
+    if (!action.has_ingredient_index()) {
+      return absl::InvalidArgumentError(
+          "Opened action requires an ingredient index.");
+    }
+    return AddIngredientToAction(action.ingredient_index(),
+                                 kIngredientRelationshipParentOf, action_item);
+  }
+
+  absl::Status ConvertPlacedAction(const PlacedAction& action,
+                                   ActionItem* action_item) {
+    ABSL_RETURN_IF_ERROR(
+        ConvertSimpleAction("c2pa.placed", action, action_item));
+    if (action.ingredient_indexes_size() == 0) {
+      return absl::InvalidArgumentError("missing ingredients in placed action");
+    }
+    for (int ingredient_index : action.ingredient_indexes()) {
+      ABSL_RETURN_IF_ERROR(AddIngredientToAction(
+          ingredient_index, kIngredientRelationshipComponentOf, action_item));
+    }
+    return absl::OkStatus();
+  }
+
+  absl::Status ConvertTranscodedAction(
+      absl::string_view name, const TranscodedAction& action,
+      ActionItem* action_item, std::optional<int> opened_ingredient_index) {
+    ABSL_RETURN_IF_ERROR(
+        ConvertDigitalSourceTypeAction(name, action, action_item));
+    if (opened_ingredient_index.has_value()) {
+      // We add this redundant ingredient reference to satisfy 2.1 validators,
+      // which require an ingredient reference in c2pa.transcoded and
+      // c2pa.repackaged actions.
+      return AddIngredientToAction(*opened_ingredient_index,
+                                   kIngredientRelationshipParentOf,
+                                   action_item);
+    }
+    return absl::OkStatus();
+  }
+
+  absl::Status ConvertRepackagedAction(
+      absl::string_view name, const RepackagedAction& action,
+      ActionItem* action_item, std::optional<int> opened_ingredient_index) {
+    if (!opened_ingredient_index.has_value()) {
+      return absl::InvalidArgumentError(
+          absl::Substitute("$0 action requires an `opened` action.", name));
+    }
+    ABSL_RETURN_IF_ERROR(ConvertSimpleAction(name, action, action_item));
+    // We add this redundant ingredient reference to satisfy 2.1 validators,
+    // which require an ingredient reference in c2pa.transcoded and
+    // c2pa.repackaged actions.
+    return AddIngredientToAction(*opened_ingredient_index,
+                                 kIngredientRelationshipParentOf, action_item);
+  }
+
+  // CreatedAction, EditedAction, FilteredAction.
+  template <typename T>
+  absl::Status ConvertActionWithInputIngredients(absl::string_view name,
+                                                 const T& action,
+                                                 ActionItem* action_item) {
+    ABSL_RETURN_IF_ERROR(
+        ConvertDigitalSourceTypeAction(name, action, action_item));
+    for (int ingredient_index : action.input_ingredient_indexes()) {
+      ABSL_RETURN_IF_ERROR(AddIngredientToAction(
+          ingredient_index, kIngredientRelationshipInputTo, action_item));
+    }
+    return absl::OkStatus();
+  }
+
+  template <typename T>
+  absl::Status ConvertDigitalSourceTypeAction(absl::string_view name,
+                                              const T& action,
+                                              ActionItem* action_item) {
+    ABSL_RETURN_IF_ERROR(ConvertSimpleAction(name, action, action_item));
+    if (action.has_digital_source_type()) {
+      action_item->set_digital_source_type(action.digital_source_type());
+    }
+    return absl::OkStatus();
+  }
+
+  template <typename T>
+  absl::Status ConvertSimpleAction(absl::string_view name, const T& action,
+                                   ActionItem* action_item) {
+    action_item->set_action(name);
+    if (action.has_action_metadata()) {
+      ConvertActionMetadata(action.action_metadata(), action_item);
+    }
+    return absl::OkStatus();
+  }
+
+  // Imports an ingredient's manifest store and creates an ingredient
+  // assertion.
+  absl::Status ImportIngredient(const Ingredient& ingredient,
+                                std::vector<Assertion>& ingredient_assertions,
+                                ManifestStoreBuilder& manifest_store_builder) {
+    if (ingredient.relationship().empty()) {
+      return absl::InvalidArgumentError(
+          "ingredient is not referenced by any action.");
+    }
+    std::optional<IngredientManifestUris> ingredient_manifest_uris;
+    std::optional<IngredientValidationResults> ingredient_validation_results;
+    std::optional<std::string> digital_source_type;
+    if (ingredient.input().has_ingredient_validation_results()) {
+      const IngredientValidationResultProto& result =
+          ingredient.input().ingredient_validation_results();
+      if (result.has_legacy_ingredient_metadata()) {
+        // Treat ingredients with unsupported manifests like ingredients without
+        // C2PA at all.
+      } else if (result.has_manifest_store_bytes()) {
+        if (!result.has_ingredient_validation_results()) {
+          return absl::InvalidArgumentError(
+              "Ingredient manifest store validation results "
+              "cannot be empty.");
+        }
+        ABSL_ASSIGN_OR_RETURN(ingredient_manifest_uris,
+                              manifest_store_builder.AddIngredientManifestStore(
+                                  result.manifest_store_bytes()),
+                              _ << "adding ingredient manifest store");
+        ingredient_validation_results = result.ingredient_validation_results();
+      } else if (result.has_digital_source_type() &&
+                 assembler_.spec_version_ >= SpecVersion::kC2pa_2_4) {
+        digital_source_type = result.digital_source_type();
+      }
+    }
+
+    ABSL_ASSIGN_OR_RETURN(
+        Assertion ingredient_assertion,
+        ConvertIngredientAssertion(
+            ingredient.input(), ingredient.label(), ingredient.relationship(),
+            ingredient_manifest_uris, ingredient_validation_results,
+            digital_source_type));
+    ingredient_assertions.push_back(ingredient_assertion);
+    return absl::OkStatus();
+  }
+
+  const StandardManifestBoxAssembler& assembler_;
+  const GenerateManifestStoreRequest& request_;
+  std::vector<Ingredient> ingredients_;
+};
+
+absl::StatusOr<ManifestBox> StandardManifestBoxAssembler::Assemble(
+    const GenerateManifestStoreRequest& request) const {
+  AssembleOp op(*this, request);
+  return op.Run();
+}
+}  // namespace credentio
diff --git a/claim/manifest_box_assembler.h b/claim/manifest_box_assembler.h
new file mode 100644
index 0000000..106dc28
--- /dev/null
+++ b/claim/manifest_box_assembler.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_CLAIM_MANIFEST_BOX_ASSEMBLER_H_
+#define THIRD_PARTY_CREDENTIO_CLAIM_MANIFEST_BOX_ASSEMBLER_H_
+
+#include "absl/log/die_if_null.h"
+#include "absl/status/statusor.h"
+#include "claim/manifest_box.h"
+#include "constants/spec_version.h"
+#include "crypto/hash.h"
+#include "proto/generator.pb.h"
+#include "uuid/uuid.h"
+
+namespace credentio {
+
+class ManifestBoxAssembler {
+ public:
+  virtual ~ManifestBoxAssembler() = default;
+
+  // Assembles a manifest box from the given request.
+  virtual absl::StatusOr<ManifestBox> Assemble(
+      const GenerateManifestStoreRequest& request) const = 0;
+};
+
+class StandardManifestBoxAssembler : public ManifestBoxAssembler {
+ public:
+  // `spec_version` is the C2PA specification version the generated manifests
+  // conform to. It gates spec-version-dependent features.
+  explicit StandardManifestBoxAssembler(
+      const UuidGenerator* uuid_generator, const HasherFactory* hash_factory,
+      SpecVersion spec_version = SpecVersion::kC2pa_2_2)
+      : uuid_generator_(*ABSL_DIE_IF_NULL(uuid_generator)),
+        hash_factory_(*ABSL_DIE_IF_NULL(hash_factory)),
+        spec_version_(spec_version) {}
+  ~StandardManifestBoxAssembler() override = default;
+
+  absl::StatusOr<ManifestBox> Assemble(
+      const GenerateManifestStoreRequest& request) const override;
+
+ private:
+  class AssembleOp;
+  const UuidGenerator& uuid_generator_;
+  const HasherFactory& hash_factory_;
+  const SpecVersion spec_version_;
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_CLAIM_MANIFEST_BOX_ASSEMBLER_H_
diff --git a/claim/manifest_box_assembler_test.cc b/claim/manifest_box_assembler_test.cc
new file mode 100644
index 0000000..9c8ef05
--- /dev/null
+++ b/claim/manifest_box_assembler_test.cc
@@ -0,0 +1,2094 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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_box_assembler.h"
+
+#include <memory>
+#include <string>
+
+#include "absl/status/status.h"
+#include "absl/status/status_matchers.h"
+#include "absl/strings/string_view.h"
+#include "claim/manifest_box.h"
+#include "claim/manifest_store_builder.h"
+#include "constants/labels.h"
+#include "constants/spec_version.h"
+#include "crypto/default/hasher.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "jumbf/test_utils.h"
+#include "proto/assertion.pb.h"
+#include "proto/bmff_based_hash_assertion.pb.h"
+#include "proto/generator.pb.h"
+#include "proto/generator_info.pb.h"
+#include "proto/ingredient_assertion.pb.h"
+#include "proto/ingredient_validation_result.pb.h"
+#include "proto/manifest.pb.h"
+#include "proto/signature_info.pb.h"
+#include "testing/proto_test_utils.h"
+#include "uuid/uuid.h"
+
+namespace credentio {
+namespace {
+
+using ::absl_testing::StatusIs;
+using ::credentio_testing::EqualsProto;
+using ::credentio_testing::ParseTextProtoOrDie;
+using ::testing::ElementsAre;
+using ::testing::HasSubstr;
+using ::testing::IsEmpty;
+using ::testing::Optional;
+using ::testing::Pointwise;
+
+constexpr auto kUuid =
+    Uuid::FromStringOrDie("03020100-0504-0706-0908-0f0e0d0c0b0a");
+constexpr auto kInstanceId =
+    Uuid::FromStringOrDie("12345678-1234-1234-1234-123456789012");
+
+std::string CreateSimpleManifestStoreBytes() {
+  std::string cbor_box = jumbf::EncodeCborBox("cbor_box");
+  std::string signature_box = jumbf::EncodeSuperBox(
+      kClaimSignatureUuid, kClaimSignatureLabel, {cbor_box});
+  std::string test_manifest = jumbf::EncodeSuperBox(
+      kStandardManifestUuid, "urn:c2pa:11111111-1111-1111-1111-111111111111",
+      {signature_box});
+  return jumbf::EncodeSuperBox(kManifestStoreUuid, kManifestStoreLabel,
+                               {test_manifest});
+}
+
+std::string CreateLegacyManifestStoreBytes() {
+  std::string cbor_box = jumbf::EncodeCborBox("cbor_box");
+  std::string signature_box = jumbf::EncodeSuperBox(
+      kClaimSignatureUuid, kClaimSignatureLabel, {cbor_box});
+  std::string test_manifest = jumbf::EncodeSuperBox(
+      kStandardManifestUuid, "urn:uuid:legacy-c2pa", {signature_box});
+  return jumbf::EncodeSuperBox(kManifestStoreUuid, kManifestStoreLabel,
+                               {test_manifest});
+}
+
+class FakeUuidGenerator : public UuidGenerator {
+ public:
+  Uuid Generate() const override {
+    if (first_) {
+      first_ = false;
+      return kUuid;
+    }
+    return kInstanceId;
+  }
+
+ private:
+  mutable bool first_ = true;
+};
+
+class ManifestAssemblerTest : public testing::Test {
+ protected:
+  ManifestAssemblerTest() {
+    manifest_assembler_ = std::make_unique<StandardManifestBoxAssembler>(
+        &uuid_generator_, &hash_factory_);
+  }
+
+  // Recreates the assembler targeting the given C2PA spec version.
+  void SetSpecVersion(SpecVersion spec_version) {
+    manifest_assembler_ = std::make_unique<StandardManifestBoxAssembler>(
+        &uuid_generator_, &hash_factory_, spec_version);
+  }
+
+  FakeUuidGenerator uuid_generator_;
+  Sha256HasherFactory hash_factory_;
+  std::unique_ptr<ManifestBoxAssembler> manifest_assembler_;
+};
+
+TEST_F(ManifestAssemblerTest, NoHardBindingInvalid) {
+  GenerateManifestStoreRequest request;
+  EXPECT_THAT(
+      manifest_assembler_->Assemble(request),
+      StatusIs(absl::StatusCode::kInvalidArgument, HasSubstr("hard binding")));
+}
+
+TEST_F(ManifestAssemblerTest, PopulateBmffBasedHardBinding) {
+  GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb(
+    hard_binding { bmff_based_hash { alg: "some alg" hash: "some hash" } }
+    actions { created {} }
+  )pb");
+  ManifestBox expected = {
+      .label = "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a",
+      .hard_binding_assertion = ParseTextProtoOrDie(R"pb(
+        label: "c2pa.hash.bmff.v3"
+        bmff_based_hash { alg: "some alg" hash: "some hash" }
+      )pb"),
+      .actions_assertion = ParseTextProtoOrDie(R"pb(
+        label: "c2pa.actions.v2"
+        actions { actions { action: "c2pa.created" } }
+      )pb"),
+      .ingredient_assertions = {},
+      .manifest_store_builder = ManifestStoreBuilder(&hash_factory_)};
+  auto manifest_box_or = manifest_assembler_->Assemble(request);
+  ABSL_ASSERT_OK(manifest_box_or);
+  const ManifestBox& manifest_box = *manifest_box_or;
+  EXPECT_EQ(manifest_box.label, expected.label);
+  EXPECT_EQ(manifest_box.instance_id, kInstanceId.ToString());
+  EXPECT_THAT(manifest_box.hard_binding_assertion,
+              EqualsProto(expected.hard_binding_assertion));
+  EXPECT_THAT(manifest_box.actions_assertion,
+              EqualsProto(expected.actions_assertion));
+  EXPECT_THAT(manifest_box.ingredient_assertions,
+              Pointwise(EqualsProto(), expected.ingredient_assertions));
+}
+
+TEST_F(ManifestAssemblerTest, PopulateActionsAssertionWithAllActionsIncluded) {
+  GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb(
+    hard_binding { bmff_based_hash { alg: "some alg" hash: "some hash" } }
+    actions {
+      created {}
+      all_actions_included: true
+    }
+  )pb");
+  ManifestBox expected = {
+      .label = "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a",
+      .hard_binding_assertion = ParseTextProtoOrDie(R"pb(
+        label: "c2pa.hash.bmff.v3"
+        bmff_based_hash { alg: "some alg" hash: "some hash" }
+      )pb"),
+      .actions_assertion = ParseTextProtoOrDie(R"pb(
+        label: "c2pa.actions.v2"
+        actions {
+          all_actions_included: true
+          actions { action: "c2pa.created" }
+        }
+      )pb"),
+      .ingredient_assertions = {},
+      .manifest_store_builder = ManifestStoreBuilder(&hash_factory_)};
+  auto manifest_box_or = manifest_assembler_->Assemble(request);
+  ABSL_ASSERT_OK(manifest_box_or);
+  const ManifestBox& manifest_box = *manifest_box_or;
+  EXPECT_EQ(manifest_box.label, expected.label);
+  EXPECT_EQ(manifest_box.instance_id, kInstanceId.ToString());
+  EXPECT_THAT(manifest_box.hard_binding_assertion,
+              EqualsProto(expected.hard_binding_assertion));
+  EXPECT_THAT(manifest_box.actions_assertion,
+              EqualsProto(expected.actions_assertion));
+  EXPECT_THAT(manifest_box.ingredient_assertions,
+              Pointwise(EqualsProto(), expected.ingredient_assertions));
+}
+
+TEST_F(ManifestAssemblerTest, UsesInstanceIdFromRequest) {
+  GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb(
+    hard_binding { bmff_based_hash { alg: "some alg" hash: "some hash" } }
+    actions { created {} }
+    instance_id: "11111111-1111-1111-1111-111111111111"
+  )pb");
+  ManifestBox expected = {
+      .label = "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a",
+      .instance_id = "11111111-1111-1111-1111-111111111111",
+      .hard_binding_assertion = ParseTextProtoOrDie(R"pb(
+        label: "c2pa.hash.bmff.v3"
+        bmff_based_hash { alg: "some alg" hash: "some hash" }
+      )pb"),
+      .actions_assertion = ParseTextProtoOrDie(R"pb(
+        label: "c2pa.actions.v2"
+        actions { actions { action: "c2pa.created" } }
+      )pb"),
+      .ingredient_assertions = {},
+      .manifest_store_builder = ManifestStoreBuilder(&hash_factory_),
+  };
+  auto manifest_box_or = manifest_assembler_->Assemble(request);
+  ABSL_ASSERT_OK(manifest_box_or);
+  const ManifestBox& manifest_box = *manifest_box_or;
+  EXPECT_EQ(manifest_box.label, expected.label);
+  EXPECT_EQ(manifest_box.instance_id, expected.instance_id);
+  EXPECT_THAT(manifest_box.hard_binding_assertion,
+              EqualsProto(expected.hard_binding_assertion));
+  EXPECT_THAT(manifest_box.actions_assertion,
+              EqualsProto(expected.actions_assertion));
+  EXPECT_THAT(manifest_box.ingredient_assertions,
+              Pointwise(EqualsProto(), expected.ingredient_assertions));
+}
+
+TEST_F(ManifestAssemblerTest, PopulateBoxesBasedHardBinding) {
+  GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb(
+    hard_binding {
+      box_hash {
+        alg: "some alg"
+        boxes { names: "some name" names: "other name" hash: "some hash" }
+      }
+    }
+    actions { created {} }
+  )pb");
+  ManifestBox expected = {
+      .label = "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a",
+      .hard_binding_assertion = ParseTextProtoOrDie(R"pb(
+        label: "c2pa.hash.boxes"
+        boxes_hash {
+          alg: "some alg"
+          boxes {
+            names: "some name"
+            names: "other name"
+            hash: "some hash"
+            pad: ""
+            pad2: ""
+          }
+        }
+      )pb"),
+      .actions_assertion = ParseTextProtoOrDie(R"pb(
+        label: "c2pa.actions.v2"
+        actions { actions { action: "c2pa.created" } }
+      )pb"),
+      .ingredient_assertions = {},
+      .manifest_store_builder = ManifestStoreBuilder(&hash_factory_),
+  };
+  auto manifest_box_or = manifest_assembler_->Assemble(request);
+  ABSL_ASSERT_OK(manifest_box_or);
+  const ManifestBox& manifest_box = *manifest_box_or;
+  EXPECT_EQ(manifest_box.label, expected.label);
+  EXPECT_EQ(manifest_box.instance_id, kInstanceId.ToString());
+  EXPECT_THAT(manifest_box.hard_binding_assertion,
+              EqualsProto(expected.hard_binding_assertion));
+  EXPECT_THAT(manifest_box.actions_assertion,
+              EqualsProto(expected.actions_assertion));
+  EXPECT_THAT(manifest_box.ingredient_assertions,
+              Pointwise(EqualsProto(), expected.ingredient_assertions));
+}
+
+TEST_F(ManifestAssemblerTest, PopulateDataBasedHardBinding) {
+  GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb(
+    hard_binding {
+      data_hash {
+        alg: "some alg"
+        hash: "some hash"
+        exclusions { start: 1 length: 10 }
+      }
+    }
+    actions { created {} }
+  )pb");
+  ManifestBox expected = {
+      .label = "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a",
+      .hard_binding_assertion = ParseTextProtoOrDie(R"pb(
+        label: "c2pa.hash.data"
+        data_hash {
+          alg: "some alg"
+          hash: "some hash"
+          exclusions { start: 1 length: 10 }
+          pad: "\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000"
+        }
+      )pb"),
+      .actions_assertion = ParseTextProtoOrDie(R"pb(
+        label: "c2pa.actions.v2"
+        actions { actions { action: "c2pa.created" } }
+      )pb"),
+      .ingredient_assertions = {},
+      .manifest_store_builder = ManifestStoreBuilder(&hash_factory_),
+  };
+  auto manifest_box_or = manifest_assembler_->Assemble(request);
+  ABSL_ASSERT_OK(manifest_box_or);
+  const ManifestBox& manifest_box = *manifest_box_or;
+  EXPECT_EQ(manifest_box.label, expected.label);
+  EXPECT_EQ(manifest_box.instance_id, kInstanceId.ToString());
+  EXPECT_THAT(manifest_box.hard_binding_assertion,
+              EqualsProto(expected.hard_binding_assertion));
+  EXPECT_THAT(manifest_box.actions_assertion,
+              EqualsProto(expected.actions_assertion));
+  EXPECT_THAT(manifest_box.ingredient_assertions,
+              Pointwise(EqualsProto(), expected.ingredient_assertions));
+}
+
+TEST_F(ManifestAssemblerTest, PopulateCollectionDataHashBasedHardBinding) {
+  GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb(
+    hard_binding {
+      collection_data_hash {
+        alg: "some alg"
+        uris { uri: "some uri" hash: "some hash" type: "some type" }
+        zip_central_directory_hash: "some hash"
+      }
+    }
+    actions { created {} }
+  )pb");
+  ManifestBox expected = {
+      .label = "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a",
+      .hard_binding_assertion = ParseTextProtoOrDie(R"pb(
+        label: "c2pa.hash.collection.data"
+        collection_data_hash {
+          uris { uri: "some uri" hash: "some hash" type: "some type" }
+          alg: "some alg"
+          zip_central_directory_hash: "some hash"
+        }
+      )pb"),
+      .actions_assertion = ParseTextProtoOrDie(R"pb(
+        label: "c2pa.actions.v2"
+        actions { actions { action: "c2pa.created" } }
+      )pb"),
+      .ingredient_assertions = {},
+      .manifest_store_builder = ManifestStoreBuilder(&hash_factory_),
+  };
+  auto manifest_box_or = manifest_assembler_->Assemble(request);
+  ABSL_ASSERT_OK(manifest_box_or);
+  const ManifestBox& manifest_box = *manifest_box_or;
+  EXPECT_EQ(manifest_box.label, expected.label);
+  EXPECT_EQ(manifest_box.instance_id, kInstanceId.ToString());
+  EXPECT_THAT(manifest_box.hard_binding_assertion,
+              EqualsProto(expected.hard_binding_assertion));
+  EXPECT_THAT(manifest_box.actions_assertion,
+              EqualsProto(expected.actions_assertion));
+  EXPECT_THAT(manifest_box.ingredient_assertions,
+              Pointwise(EqualsProto(), expected.ingredient_assertions));
+}
+
+TEST_F(ManifestAssemblerTest, UnspecifiedInceptionActionInvalid) {
+  GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb(
+    hard_binding { bmff_based_hash {} }
+  )pb");
+  EXPECT_THAT(manifest_assembler_->Assemble(request),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("missing inception action")));
+}
+
+TEST_F(ManifestAssemblerTest, EmptyAction) {
+  GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb(
+    hard_binding { bmff_based_hash {} }
+    actions {
+      opened { ingredient_index: 0 }
+      actions {}
+    }
+    ingredients { metadata { title: "ingredient0" } }
+  )pb");
+  EXPECT_THAT(
+      manifest_assembler_->Assemble(request),
+      StatusIs(absl::StatusCode::kInvalidArgument, HasSubstr("empty action")));
+}
+
+TEST_F(ManifestAssemblerTest, PlacedAction) {
+  GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb(
+    hard_binding { bmff_based_hash {} }
+    actions {
+      opened { ingredient_index: 0 }
+      actions {
+        placed {
+          action_metadata { description: "test placed description" }
+          ingredient_indexes: 1
+          ingredient_indexes: 2
+        }
+      }
+    }
+    ingredients { metadata { title: "ingredient0" } }
+    ingredients { metadata { title: "ingredient1" } }
+    ingredients { metadata { title: "ingredient2" } }
+  )pb");
+  auto manifest_box_or = manifest_assembler_->Assemble(request);
+  ABSL_ASSERT_OK(manifest_box_or);
+  const ManifestBox& manifest_box = *manifest_box_or;
+  EXPECT_THAT(manifest_box.actions_assertion, EqualsProto(R"pb(
+                label: "c2pa.actions.v2"
+                actions {
+                  actions {
+                    action: "c2pa.opened"
+                    parameters {
+                      ingredients {
+                        url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3"
+                      }
+                    }
+                  }
+                  actions {
+                    action: "c2pa.placed"
+                    description: "test placed description"
+                    parameters {
+                      ingredients {
+                        url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3__1"
+                      }
+                      ingredients {
+                        url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3__2"
+                      }
+                    }
+                  }
+                }
+              )pb"));
+  EXPECT_THAT(
+      manifest_box.ingredient_assertions,
+      ElementsAre(
+          EqualsProto(R"pb(
+            label: "c2pa.ingredient.v3"
+            ingredient_v3 { title: "ingredient0" relationship: "parentOf" }
+          )pb"),
+          EqualsProto(R"pb(
+            label: "c2pa.ingredient.v3__1"
+            ingredient_v3 { title: "ingredient1" relationship: "componentOf" }
+          )pb"),
+          EqualsProto(R"pb(
+            label: "c2pa.ingredient.v3__2"
+            ingredient_v3 { title: "ingredient2" relationship: "componentOf" }
+          )pb")));
+}
+
+TEST_F(ManifestAssemblerTest, LegacyManifestInIngredients) {
+  GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb(
+    hard_binding { bmff_based_hash {} }
+    actions {
+      opened { ingredient_index: 0 }
+      actions {
+        placed {
+          action_metadata { description: "test placed description" }
+          ingredient_indexes: 1
+          ingredient_indexes: 2
+        }
+      }
+    }
+    ingredients {
+      metadata { title: "ingredient0" }
+      ingredient_validation_results {
+        manifest_store_bytes: ""
+        ingredient_validation_results {
+          active_manifest { successes { code: "test" } }
+        }
+        legacy_ingredient_metadata {
+          is_legacy_ingredient: true
+          unsupported_manifests: "unsupported manifest"
+        }
+      }
+    }
+    ingredients {
+      metadata { title: "ingredient1" }
+      ingredient_validation_results {
+        manifest_store_bytes: ""
+        ingredient_validation_results {
+          active_manifest { successes { code: "test" } }
+        }
+        legacy_ingredient_metadata {
+          is_legacy_ingredient: true
+          unsupported_manifests: "unsupported manifest"
+        }
+      }
+    }
+    ingredients {
+      metadata { title: "ingredient2" }
+      ingredient_validation_results {
+        manifest_store_bytes: ""
+        ingredient_validation_results {
+          active_manifest { successes { code: "test" } }
+        }
+      }
+    }
+  )pb");
+  std::string ingredient0_manifest_store_bytes =
+      CreateLegacyManifestStoreBytes();
+  std::string ingredient1_manifest_store_bytes =
+      CreateLegacyManifestStoreBytes();
+  std::string ingredient2_manifest_store_bytes =
+      CreateSimpleManifestStoreBytes();
+  request.mutable_ingredients(0)
+      ->mutable_ingredient_validation_results()
+      ->set_manifest_store_bytes(ingredient0_manifest_store_bytes);
+  request.mutable_ingredients(1)
+      ->mutable_ingredient_validation_results()
+      ->set_manifest_store_bytes(ingredient1_manifest_store_bytes);
+  request.mutable_ingredients(2)
+      ->mutable_ingredient_validation_results()
+      ->set_manifest_store_bytes(ingredient2_manifest_store_bytes);
+  auto manifest_box_or = manifest_assembler_->Assemble(request);
+  ABSL_ASSERT_OK(manifest_box_or);
+  const ManifestBox& manifest_box = *manifest_box_or;
+  EXPECT_THAT(manifest_box.actions_assertion, EqualsProto(R"pb(
+                label: "c2pa.actions.v2"
+                actions {
+                  actions {
+                    action: "c2pa.opened"
+                    parameters {
+                      ingredients {
+                        url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3"
+                      }
+                    }
+                  }
+                  actions {
+                    action: "c2pa.placed"
+                    description: "test placed description"
+                    parameters {
+                      ingredients {
+                        url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3__1"
+                      }
+                      ingredients {
+                        url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3__2"
+                      }
+                    }
+                  }
+                }
+              )pb"));
+  EXPECT_THAT(
+      manifest_box.ingredient_assertions,
+      ElementsAre(
+          EqualsProto(R"pb(
+            label: "c2pa.ingredient.v3"
+            ingredient_v3 { title: "ingredient0" relationship: "parentOf" }
+          )pb"),
+          EqualsProto(R"pb(
+            label: "c2pa.ingredient.v3__1"
+            ingredient_v3 { title: "ingredient1" relationship: "componentOf" }
+          )pb"),
+          EqualsProto(R"pb(
+            label: "c2pa.ingredient.v3__2"
+            ingredient_v3 {
+              title: "ingredient2"
+              relationship: "componentOf"
+              validation_results {
+                active_manifest { successes { code: "test" } }
+              }
+              active_manifest {
+                url: "self#jumbf=/c2pa/urn:c2pa:11111111-1111-1111-1111-111111111111"
+                hash: "7#\034)n*\250\307>)(\263\336K\024\334R\002\'Q\252^\347\361\000\021T\334\256\320pf"
+              }
+              claim_signature {
+                url: "self#jumbf=/c2pa/urn:c2pa:11111111-1111-1111-1111-111111111111/c2pa.signature"
+                hash: "\017b6\027\253\306\235Q\350\246\311y\203sr@\256VE\267S\0137>\027#\377+\311U!l"
+              }
+            }
+          )pb")));
+}
+
+TEST_F(ManifestAssemblerTest, DigitalSourceTypeSetForUnknownProvenance) {
+  SetSpecVersion(SpecVersion::kC2pa_2_4);
+  GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb(
+    hard_binding { bmff_based_hash {} }
+    actions { opened { ingredient_index: 0 } }
+    ingredients {
+      metadata { title: "ingredient0" }
+      ingredient_validation_results {
+        digital_source_type: "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia"
+      }
+    }
+  )pb");
+  auto manifest_box_or = manifest_assembler_->Assemble(request);
+  ABSL_ASSERT_OK(manifest_box_or);
+  const ManifestBox& manifest_box = *manifest_box_or;
+  EXPECT_THAT(
+      manifest_box.ingredient_assertions, ElementsAre(EqualsProto(R"pb(
+        label: "c2pa.ingredient.v3"
+        ingredient_v3 {
+          title: "ingredient0"
+          relationship: "parentOf"
+          digital_source_type: "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia"
+        }
+      )pb")));
+}
+
+TEST_F(ManifestAssemblerTest, DigitalSourceTypeIgnoredBeforeSpec24) {
+  SetSpecVersion(SpecVersion::kC2pa_2_2);
+  GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb(
+    hard_binding { bmff_based_hash {} }
+    actions { opened { ingredient_index: 0 } }
+    ingredients {
+      metadata { title: "ingredient0" }
+      ingredient_validation_results {
+        digital_source_type: "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia"
+      }
+    }
+  )pb");
+  auto manifest_box_or = manifest_assembler_->Assemble(request);
+  ABSL_ASSERT_OK(manifest_box_or);
+  const ManifestBox& manifest_box = *manifest_box_or;
+  EXPECT_THAT(manifest_box.ingredient_assertions, ElementsAre(EqualsProto(R"pb(
+                label: "c2pa.ingredient.v3"
+                ingredient_v3 { title: "ingredient0" relationship: "parentOf" }
+              )pb")));
+}
+
+TEST_F(ManifestAssemblerTest,
+       DigitalSourceTypeNotSetForIngredientWithoutDigitalSourceType) {
+  SetSpecVersion(SpecVersion::kC2pa_2_4);
+  GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb(
+    hard_binding { bmff_based_hash {} }
+    actions { opened { ingredient_index: 0 } }
+    ingredients {
+      metadata { title: "ingredient0" }
+      ingredient_validation_results {}
+    }
+  )pb");
+  auto manifest_box_or = manifest_assembler_->Assemble(request);
+  ABSL_ASSERT_OK(manifest_box_or);
+  const ManifestBox& manifest_box = *manifest_box_or;
+  EXPECT_THAT(manifest_box.ingredient_assertions, ElementsAre(EqualsProto(R"pb(
+                label: "c2pa.ingredient.v3"
+                ingredient_v3 { title: "ingredient0" relationship: "parentOf" }
+              )pb")));
+  ASSERT_EQ(manifest_box.ingredient_assertions.size(), 1);
+  EXPECT_FALSE(manifest_box.ingredient_assertions[0]
+                   .ingredient_v3()
+                   .has_digital_source_type());
+}
+
+TEST_F(ManifestAssemblerTest,
+       DigitalSourceTypeIgnoredIfManifestStoreBytesPresent) {
+  SetSpecVersion(SpecVersion::kC2pa_2_4);
+  GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb(
+    hard_binding { bmff_based_hash {} }
+    actions { opened { ingredient_index: 0 } }
+    ingredients {
+      metadata { title: "ingredient0" }
+      ingredient_validation_results {
+        manifest_store_bytes: ""
+        ingredient_validation_results {
+          active_manifest { successes { code: "test" } }
+        }
+        digital_source_type: "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia"
+      }
+    }
+  )pb");
+  std::string ingredient0_manifest_store_bytes =
+      CreateSimpleManifestStoreBytes();
+  request.mutable_ingredients(0)
+      ->mutable_ingredient_validation_results()
+      ->set_manifest_store_bytes(ingredient0_manifest_store_bytes);
+  auto manifest_box_or = manifest_assembler_->Assemble(request);
+  ABSL_ASSERT_OK(manifest_box_or);
+  const ManifestBox& manifest_box = *manifest_box_or;
+  EXPECT_THAT(
+      manifest_box.ingredient_assertions, ElementsAre(EqualsProto(R"pb(
+        label: "c2pa.ingredient.v3"
+        ingredient_v3 {
+          title: "ingredient0"
+          relationship: "parentOf"
+          validation_results { active_manifest { successes { code: "test" } } }
+          active_manifest {
+            url: "self#jumbf=/c2pa/urn:c2pa:11111111-1111-1111-1111-111111111111"
+            hash: "7#\034)n*\250\307>)(\263\336K\024\334R\002\'Q\252^\347\361\000\021T\334\256\320pf"
+          }
+          claim_signature {
+            url: "self#jumbf=/c2pa/urn:c2pa:11111111-1111-1111-1111-111111111111/c2pa.signature"
+            hash: "\017b6\027\253\306\235Q\350\246\311y\203sr@\256VE\267S\0137>\027#\377+\311U!l"
+          }
+        }
+      )pb")));
+}
+
+TEST_F(ManifestAssemblerTest, PlacedActionWithSharedIngredients) {
+  GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb(
+    hard_binding { bmff_based_hash {} }
+    actions {
+      opened { ingredient_index: 0 }
+      actions {
+        placed {
+          action_metadata { multiple_instances: true }
+          ingredient_indexes: 1
+          ingredient_indexes: 2
+          ingredient_indexes: 1
+        }
+      }
+      actions { placed { ingredient_indexes: 1 } }
+    }
+    ingredients { metadata { title: "ingredient0" } }
+    ingredients { metadata { title: "ingredient1" } }
+    ingredients { metadata { title: "ingredient2" } }
+  )pb");
+  auto manifest_box_or = manifest_assembler_->Assemble(request);
+  ABSL_ASSERT_OK(manifest_box_or);
+  const ManifestBox& manifest_box = *manifest_box_or;
+  EXPECT_THAT(manifest_box.actions_assertion, EqualsProto(R"pb(
+                label: "c2pa.actions.v2"
+                actions {
+                  actions {
+                    action: "c2pa.opened"
+                    parameters {
+                      ingredients {
+                        url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3"
+                      }
+                    }
+                  }
+                  actions {
+                    action: "c2pa.placed"
+                    parameters {
+                      ingredients {
+                        url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3__1"
+                      }
+                      ingredients {
+                        url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3__2"
+                      }
+                      ingredients {
+                        url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3__1"
+                      }
+                      multiple_instances: true
+                    }
+                  }
+                  actions {
+                    action: "c2pa.placed"
+                    parameters {
+                      ingredients {
+                        url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3__1"
+                      }
+                    }
+                  }
+                }
+              )pb"));
+  EXPECT_THAT(
+      manifest_box.ingredient_assertions,
+      ElementsAre(
+          EqualsProto(R"pb(
+            label: "c2pa.ingredient.v3"
+            ingredient_v3 { title: "ingredient0" relationship: "parentOf" }
+          )pb"),
+          EqualsProto(R"pb(
+            label: "c2pa.ingredient.v3__1"
+            ingredient_v3 { title: "ingredient1" relationship: "componentOf" }
+          )pb"),
+          EqualsProto(R"pb(
+            label: "c2pa.ingredient.v3__2"
+            ingredient_v3 { title: "ingredient2" relationship: "componentOf" }
+          )pb")));
+}
+
+TEST_F(ManifestAssemblerTest,
+       PlacedActionWithSharedIngredientsWithSharedRedactions) {
+  GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb(
+    hard_binding { bmff_based_hash {} }
+    actions {
+      opened { ingredient_index: 0 }
+      actions {
+        placed {
+          action_metadata { multiple_instances: true }
+          ingredient_indexes: 1
+          ingredient_indexes: 2
+          ingredient_indexes: 1
+        }
+      }
+      actions { placed { ingredient_indexes: 1 } }
+    }
+    ingredients {
+      metadata { title: "ingredient0" }
+      ingredient_validation_results {
+        redactions { uri: "uri1" reason: "reason1" }
+        redactions { uri: "uri2" reason: "reason2" }
+      }
+    }
+    ingredients {
+      metadata { title: "ingredient1" }
+      ingredient_validation_results {
+        redactions { uri: "uri2" reason: "reason2" }
+      }
+    }
+    ingredients {
+      metadata { title: "ingredient2" }
+      ingredient_validation_results {
+        redactions { uri: "uri2" reason: "reason2" }
+      }
+    }
+  )pb");
+  auto manifest_box_or = manifest_assembler_->Assemble(request);
+  ABSL_ASSERT_OK(manifest_box_or);
+  const ManifestBox& manifest_box = *manifest_box_or;
+  EXPECT_THAT(manifest_box.actions_assertion, EqualsProto(R"pb(
+                label: "c2pa.actions.v2"
+                actions {
+                  actions {
+                    action: "c2pa.opened"
+                    parameters {
+                      ingredients {
+                        url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3"
+                      }
+                    }
+                  }
+                  actions {
+                    action: "c2pa.placed"
+                    parameters {
+                      ingredients {
+                        url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3__1"
+                      }
+                      ingredients {
+                        url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3__2"
+                      }
+                      ingredients {
+                        url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3__1"
+                      }
+                      multiple_instances: true
+                    }
+                  }
+                  actions {
+                    action: "c2pa.placed"
+                    parameters {
+                      ingredients {
+                        url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3__1"
+                      }
+                    }
+                  }
+                  actions {
+                    action: "c2pa.redacted"
+                    reason: "reason1"
+                    parameters { redacted_uri: "uri1" }
+                  }
+                  actions {
+                    action: "c2pa.redacted"
+                    reason: "reason2"
+                    parameters { redacted_uri: "uri2" }
+                  }
+                }
+              )pb"));
+  EXPECT_THAT(
+      manifest_box.ingredient_assertions,
+      ElementsAre(
+          EqualsProto(R"pb(
+            label: "c2pa.ingredient.v3"
+            ingredient_v3 { title: "ingredient0" relationship: "parentOf" }
+          )pb"),
+          EqualsProto(R"pb(
+            label: "c2pa.ingredient.v3__1"
+            ingredient_v3 { title: "ingredient1" relationship: "componentOf" }
+          )pb"),
+          EqualsProto(R"pb(
+            label: "c2pa.ingredient.v3__2"
+            ingredient_v3 { title: "ingredient2" relationship: "componentOf" }
+          )pb")));
+}
+
+TEST_F(ManifestAssemblerTest, PlacedActionWithoutIngredients) {
+  GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb(
+    hard_binding { bmff_based_hash {} }
+    actions {
+      opened { ingredient_index: 0 }
+      actions {
+        placed { action_metadata { description: "test placed description" } }
+      }
+    }
+    ingredients { metadata { title: "ingredient0" } }
+  )pb");
+  EXPECT_THAT(manifest_assembler_->Assemble(request),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("missing ingredients in placed action")));
+}
+
+TEST_F(ManifestAssemblerTest, PlacedActionWithInvalidIngredientIndex) {
+  GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb(
+    hard_binding { bmff_based_hash {} }
+    actions {
+      opened { ingredient_index: 0 }
+      actions {
+        placed {
+          action_metadata { description: "test placed description" }
+          ingredient_indexes: 1
+        }
+      }
+    }
+    ingredients { metadata { title: "ingredient0" } }
+  )pb");
+  EXPECT_THAT(manifest_assembler_->Assemble(request),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("ingredient index out of range")));
+}
+
+TEST_F(ManifestAssemblerTest, RepackagedActionWithoutOpenedIngredient) {
+  GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb(
+    hard_binding { bmff_based_hash {} }
+    actions {
+      created {}
+      actions {
+        repackaged {
+          action_metadata { description: "test repackaged description" }
+        }
+      }
+    }
+  )pb");
+  EXPECT_THAT(manifest_assembler_->Assemble(request),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("requires an `opened` action")));
+}
+
+TEST_F(ManifestAssemblerTest, TranscodedActionWithoutOpenedIngredient) {
+  GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb(
+    hard_binding { bmff_based_hash {} }
+    actions {
+      created {}
+      actions {
+        transcoded {
+          action_metadata { description: "test transcoded description" }
+        }
+      }
+    }
+  )pb");
+  auto manifest_box_or = manifest_assembler_->Assemble(request);
+  ABSL_ASSERT_OK(manifest_box_or);
+  const ManifestBox& manifest_box = *manifest_box_or;
+  EXPECT_THAT(manifest_box.actions_assertion, EqualsProto(R"pb(
+                label: "c2pa.actions.v2"
+                actions {
+                  actions { action: "c2pa.created" }
+                  actions {
+                    action: "c2pa.transcoded"
+                    description: "test transcoded description"
+                  }
+                }
+              )pb"));
+}
+
+TEST_F(ManifestAssemblerTest, RepackagedAction) {
+  GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb(
+    hard_binding { bmff_based_hash {} }
+    actions {
+      opened { ingredient_index: 0 }
+      actions {
+        repackaged {
+          action_metadata { description: "test repackaged description" }
+        }
+      }
+    }
+    ingredients { metadata { title: "ingredient0" } }
+  )pb");
+  auto manifest_box_or = manifest_assembler_->Assemble(request);
+  ABSL_ASSERT_OK(manifest_box_or);
+  const ManifestBox& manifest_box = *manifest_box_or;
+  EXPECT_THAT(manifest_box.actions_assertion, EqualsProto(R"pb(
+                label: "c2pa.actions.v2"
+                actions {
+                  actions {
+                    action: "c2pa.opened"
+                    parameters {
+                      ingredients {
+                        url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3"
+                      }
+                    }
+                  }
+                  actions {
+                    action: "c2pa.repackaged"
+                    description: "test repackaged description"
+                    parameters {
+                      ingredients {
+                        url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3"
+                      }
+                    }
+                  }
+                }
+              )pb"));
+  EXPECT_THAT(manifest_box.ingredient_assertions, ElementsAre(EqualsProto(R"pb(
+                label: "c2pa.ingredient.v3"
+                ingredient_v3 { title: "ingredient0" relationship: "parentOf" }
+              )pb")));
+}
+
+TEST_F(ManifestAssemblerTest, TranscodedAction) {
+  GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb(
+    hard_binding { bmff_based_hash {} }
+    actions {
+      opened { ingredient_index: 0 }
+      actions {
+        transcoded {
+          action_metadata { description: "test transcoded description" }
+          digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/humanEdits"
+        }
+      }
+    }
+    ingredients { metadata { title: "ingredient0" } }
+  )pb");
+  auto manifest_box_or = manifest_assembler_->Assemble(request);
+  ABSL_ASSERT_OK(manifest_box_or);
+  const ManifestBox& manifest_box = *manifest_box_or;
+  EXPECT_THAT(
+      manifest_box.actions_assertion, EqualsProto(R"pb(
+        label: "c2pa.actions.v2"
+        actions {
+          actions {
+            action: "c2pa.opened"
+            parameters {
+              ingredients {
+                url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3"
+              }
+            }
+          }
+          actions {
+            action: "c2pa.transcoded"
+            description: "test transcoded description"
+            digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/humanEdits"
+            parameters {
+              ingredients {
+                url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3"
+              }
+            }
+          }
+        }
+      )pb"));
+  EXPECT_THAT(manifest_box.ingredient_assertions, ElementsAre(EqualsProto(R"pb(
+                label: "c2pa.ingredient.v3"
+                ingredient_v3 { title: "ingredient0" relationship: "parentOf" }
+              )pb")));
+}
+
+// Tests simple actions with no ingredient references or other custom fields.
+TEST_F(ManifestAssemblerTest, SimpleActions) {
+  GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb(
+    hard_binding { bmff_based_hash {} }
+    actions {
+      created {}
+      actions { added_text { action_metadata { description: "added_text" } } }
+      actions { converted { action_metadata { description: "converted" } } }
+      actions {
+        edited_metadata { action_metadata { description: "edited_metadata" } }
+      }
+      actions { unknown { action_metadata { description: "unknown" } } }
+    }
+  )pb");
+  auto manifest_box_or = manifest_assembler_->Assemble(request);
+  ABSL_ASSERT_OK(manifest_box_or);
+  const ManifestBox& manifest_box = *manifest_box_or;
+  EXPECT_THAT(manifest_box.actions_assertion, EqualsProto(R"pb(
+                label: "c2pa.actions.v2"
+                actions {
+                  actions { action: "c2pa.created" }
+                  actions { action: "c2pa.addedText" description: "added_text" }
+                  actions { action: "c2pa.converted" description: "converted" }
+                  actions {
+                    action: "c2pa.edited.metadata"
+                    description: "edited_metadata"
+                  }
+                  actions { action: "c2pa.unknown" description: "unknown" }
+                }
+              )pb"));
+  EXPECT_THAT(manifest_box.ingredient_assertions, IsEmpty());
+}
+
+// Tests actions with digital source type.
+TEST_F(ManifestAssemblerTest, DigitalSourceTypeActions) {
+  GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb(
+    hard_binding { bmff_based_hash {} }
+    actions {
+      created {}
+      actions {
+        added_text {
+          action_metadata { description: "added_text" }
+          digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/humanEdits"
+        }
+      }
+      actions {
+        adjusted_color {
+          action_metadata { description: "adjusted_color" }
+          digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/humanEdits"
+        }
+      }
+      actions {
+        changed_speed {
+          action_metadata { description: "changed_speed" }
+          digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/humanEdits"
+        }
+      }
+      actions {
+        cropped {
+          action_metadata { description: "cropped" }
+          digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/humanEdits"
+        }
+      }
+      actions {
+        deleted {
+          action_metadata { description: "deleted" }
+          digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/humanEdits"
+        }
+      }
+      actions {
+        drawing {
+          action_metadata { description: "drawing" }
+          digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/humanEdits"
+        }
+      }
+      actions {
+        dubbed {
+          action_metadata { description: "dubbed" }
+          digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/humanEdits"
+        }
+      }
+      actions {
+        edited {
+          action_metadata { description: "edited" }
+          digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/humanEdits"
+        }
+      }
+      actions {
+        enhanced {
+          action_metadata { description: "enhanced" }
+          digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/humanEdits"
+        }
+      }
+      actions {
+        filtered {
+          action_metadata { description: "filtered" }
+          digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/humanEdits"
+        }
+      }
+      actions {
+        orientation {
+          action_metadata { description: "orientation" }
+          digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/humanEdits"
+        }
+      }
+      actions {
+        resized {
+          action_metadata { description: "resized" }
+          digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/humanEdits"
+        }
+      }
+      actions {
+        trimmed {
+          action_metadata { description: "trimmed" }
+          digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/humanEdits"
+        }
+      }
+    }
+  )pb");
+  auto manifest_box_or = manifest_assembler_->Assemble(request);
+  ABSL_ASSERT_OK(manifest_box_or);
+  const ManifestBox& manifest_box = *manifest_box_or;
+  EXPECT_THAT(
+      manifest_box.actions_assertion, EqualsProto(R"pb(
+        label: "c2pa.actions.v2"
+        actions {
+          actions { action: "c2pa.created" }
+          actions {
+            action: "c2pa.addedText"
+            description: "added_text"
+            digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/humanEdits"
+          }
+          actions {
+            action: "c2pa.adjustedColor"
+            description: "adjusted_color"
+            digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/humanEdits"
+          }
+          actions {
+            action: "c2pa.changedSpeed"
+            description: "changed_speed"
+            digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/humanEdits"
+          }
+          actions {
+            action: "c2pa.cropped"
+            description: "cropped"
+            digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/humanEdits"
+          }
+          actions {
+            action: "c2pa.deleted"
+            description: "deleted"
+            digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/humanEdits"
+          }
+          actions {
+            action: "c2pa.drawing"
+            description: "drawing"
+            digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/humanEdits"
+          }
+          actions {
+            action: "c2pa.dubbed"
+            description: "dubbed"
+            digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/humanEdits"
+          }
+          actions {
+            action: "c2pa.edited"
+            description: "edited"
+            digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/humanEdits"
+          }
+          actions {
+            action: "c2pa.enhanced"
+            description: "enhanced"
+            digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/humanEdits"
+          }
+          actions {
+            action: "c2pa.filtered"
+            description: "filtered"
+            digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/humanEdits"
+          }
+          actions {
+            action: "c2pa.orientation"
+            description: "orientation"
+            digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/humanEdits"
+          }
+          actions {
+            action: "c2pa.resized"
+            description: "resized"
+            digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/humanEdits"
+          }
+          actions {
+            action: "c2pa.trimmed"
+            description: "trimmed"
+            digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/humanEdits"
+          }
+        }
+      )pb"));
+  EXPECT_THAT(manifest_box.ingredient_assertions, IsEmpty());
+}
+
+TEST_F(ManifestAssemblerTest, ConflictingIngredientRelationships) {
+  GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb(
+    hard_binding { bmff_based_hash {} }
+    actions {
+      opened { ingredient_index: 0 }
+      actions {
+        placed {
+          action_metadata { description: "test placed description" }
+          ingredient_indexes: 0
+        }
+      }
+    }
+    ingredients { metadata { title: "ingredient0" } }
+  )pb");
+  EXPECT_THAT(manifest_assembler_->Assemble(request),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("Inconsistent use of ingredient")));
+}
+
+TEST_F(ManifestAssemblerTest, HasCreatedAction) {
+  GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb(
+    hard_binding { bmff_based_hash {} }
+    actions {
+      created {
+        action_metadata {
+          software_agent {}
+          description: "created for testing"
+        }
+        digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture"
+      }
+    }
+  )pb");
+  ManifestBox expected = {
+      .label = "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a",
+      .hard_binding_assertion = ParseTextProtoOrDie(R"pb(
+        label: "c2pa.hash.bmff.v3"
+        bmff_based_hash {}
+      )pb"),
+      .actions_assertion = ParseTextProtoOrDie(R"pb(
+        label: "c2pa.actions.v2"
+        actions {
+          actions {
+            action: "c2pa.created"
+            software_agent {}
+            description: "created for testing"
+            digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture"
+          }
+        }
+      )pb"),
+      .ingredient_assertions = {},
+      .manifest_store_builder = ManifestStoreBuilder(&hash_factory_),
+  };
+  auto manifest_box_or = manifest_assembler_->Assemble(request);
+  ABSL_ASSERT_OK(manifest_box_or);
+  const ManifestBox& manifest_box = *manifest_box_or;
+  EXPECT_EQ(manifest_box.label, expected.label);
+  EXPECT_EQ(manifest_box.instance_id, kInstanceId.ToString());
+  EXPECT_THAT(manifest_box.hard_binding_assertion,
+              EqualsProto(expected.hard_binding_assertion));
+  EXPECT_THAT(manifest_box.actions_assertion,
+              EqualsProto(expected.actions_assertion));
+  EXPECT_THAT(manifest_box.ingredient_assertions,
+              Pointwise(EqualsProto(), expected.ingredient_assertions));
+}
+
+TEST_F(ManifestAssemblerTest, HasCreatedActionWithInputToIngredients) {
+  GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb(
+    hard_binding { bmff_based_hash {} }
+    actions {
+      created {
+        action_metadata {
+          software_agent {}
+          description: "created for testing"
+        }
+        digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia"
+        input_ingredient_indexes: 0
+        input_ingredient_indexes: 1
+      }
+    }
+    ingredients { metadata { title: "test ingredient 1" } }
+    ingredients { metadata { title: "test ingredient 2" } }
+  )pb");
+  ManifestBox expected = {
+      .label = "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a",
+      .hard_binding_assertion = ParseTextProtoOrDie(R"pb(
+        label: "c2pa.hash.bmff.v3"
+        bmff_based_hash {}
+      )pb"),
+      .actions_assertion = ParseTextProtoOrDie(R"pb(
+        label: "c2pa.actions.v2"
+        actions {
+          actions {
+            action: "c2pa.created"
+            software_agent {}
+            description: "created for testing"
+            digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia"
+            parameters {
+              ingredients {
+                url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3"
+              }
+              ingredients {
+                url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3__1"
+              }
+            }
+          }
+        }
+      )pb"),
+      .ingredient_assertions =
+          {
+              ParseTextProtoOrDie(R"pb(
+                label: "c2pa.ingredient.v3"
+                ingredient_v3 {
+                  title: "test ingredient 1"
+                  relationship: "inputTo"
+                }
+              )pb"),
+              ParseTextProtoOrDie(R"pb(
+                label: "c2pa.ingredient.v3__1"
+                ingredient_v3 {
+                  title: "test ingredient 2"
+                  relationship: "inputTo"
+                }
+              )pb"),
+          },
+      .manifest_store_builder = ManifestStoreBuilder(&hash_factory_),
+  };
+  auto manifest_box_or = manifest_assembler_->Assemble(request);
+  ABSL_ASSERT_OK(manifest_box_or);
+  const ManifestBox& manifest_box = *manifest_box_or;
+  EXPECT_EQ(manifest_box.label, expected.label);
+  EXPECT_EQ(manifest_box.instance_id, kInstanceId.ToString());
+  EXPECT_THAT(manifest_box.hard_binding_assertion,
+              EqualsProto(expected.hard_binding_assertion));
+  EXPECT_THAT(manifest_box.actions_assertion,
+              EqualsProto(expected.actions_assertion));
+  EXPECT_THAT(manifest_box.ingredient_assertions,
+              Pointwise(EqualsProto(), expected.ingredient_assertions));
+}
+
+TEST_F(ManifestAssemblerTest, EditedActionWithInputToIngredient) {
+  GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb(
+    hard_binding { bmff_based_hash {} }
+    actions {
+      opened { ingredient_index: 0 }
+      actions {
+        edited {
+          digital_source_type: "http://cv.iptc.org/newscodes/digitalsourcetype/compositeWithTrainedAlgorithmicMedia"
+          input_ingredient_indexes: 1
+        }
+      }
+    }
+    ingredients { metadata { title: "test ingredient 0" } }
+    ingredients { metadata { title: "test ingredient 1" } }
+  )pb");
+  ManifestBox expected = {
+      .label = "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a",
+      .hard_binding_assertion = ParseTextProtoOrDie(R"pb(
+        label: "c2pa.hash.bmff.v3"
+        bmff_based_hash {}
+      )pb"),
+      .actions_assertion = ParseTextProtoOrDie(R"pb(
+        label: "c2pa.actions.v2"
+        actions {
+          actions {
+            action: "c2pa.opened"
+            parameters {
+              ingredients {
+                url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3"
+              }
+            }
+          }
+          actions {
+            action: "c2pa.edited"
+            digital_source_type: "http://cv.iptc.org/newscodes/digitalsourcetype/compositeWithTrainedAlgorithmicMedia"
+            parameters {
+              ingredients {
+                url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3__1"
+              }
+            }
+          }
+        }
+      )pb"),
+      .ingredient_assertions =
+          {
+              ParseTextProtoOrDie(R"pb(
+                label: "c2pa.ingredient.v3"
+                ingredient_v3 {
+                  title: "test ingredient 0"
+                  relationship: "parentOf"
+                }
+              )pb"),
+              ParseTextProtoOrDie(R"pb(
+                label: "c2pa.ingredient.v3__1"
+                ingredient_v3 {
+                  title: "test ingredient 1"
+                  relationship: "inputTo"
+                }
+              )pb"),
+          },
+      .manifest_store_builder = ManifestStoreBuilder(&hash_factory_),
+  };
+  auto manifest_box_or = manifest_assembler_->Assemble(request);
+  ABSL_ASSERT_OK(manifest_box_or);
+  const ManifestBox& manifest_box = *manifest_box_or;
+  EXPECT_EQ(manifest_box.label, expected.label);
+  EXPECT_EQ(manifest_box.instance_id, kInstanceId.ToString());
+  EXPECT_THAT(manifest_box.hard_binding_assertion,
+              EqualsProto(expected.hard_binding_assertion));
+  EXPECT_THAT(manifest_box.actions_assertion,
+              EqualsProto(expected.actions_assertion));
+  EXPECT_THAT(manifest_box.ingredient_assertions,
+              Pointwise(EqualsProto(), expected.ingredient_assertions));
+}
+
+TEST_F(ManifestAssemblerTest, FilteredActionWithInputToIngredient) {
+  GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb(
+    hard_binding { bmff_based_hash {} }
+    actions {
+      opened { ingredient_index: 0 }
+      actions {
+        filtered {
+          digital_source_type: "http://cv.iptc.org/newscodes/digitalsourcetype/compositeWithTrainedAlgorithmicMedia"
+          input_ingredient_indexes: 1
+        }
+      }
+    }
+    ingredients { metadata { title: "test ingredient 0" } }
+    ingredients { metadata { title: "test ingredient 1" } }
+  )pb");
+  ManifestBox expected = {
+      .label = "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a",
+      .hard_binding_assertion = ParseTextProtoOrDie(R"pb(
+        label: "c2pa.hash.bmff.v3"
+        bmff_based_hash {}
+      )pb"),
+      .actions_assertion = ParseTextProtoOrDie(R"pb(
+        label: "c2pa.actions.v2"
+        actions {
+          actions {
+            action: "c2pa.opened"
+            parameters {
+              ingredients {
+                url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3"
+              }
+            }
+          }
+          actions {
+            action: "c2pa.filtered"
+            digital_source_type: "http://cv.iptc.org/newscodes/digitalsourcetype/compositeWithTrainedAlgorithmicMedia"
+            parameters {
+              ingredients {
+                url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3__1"
+              }
+            }
+          }
+        }
+      )pb"),
+      .ingredient_assertions =
+          {
+              ParseTextProtoOrDie(R"pb(
+                label: "c2pa.ingredient.v3"
+                ingredient_v3 {
+                  title: "test ingredient 0"
+                  relationship: "parentOf"
+                }
+              )pb"),
+              ParseTextProtoOrDie(R"pb(
+                label: "c2pa.ingredient.v3__1"
+                ingredient_v3 {
+                  title: "test ingredient 1"
+                  relationship: "inputTo"
+                }
+              )pb"),
+          },
+      .manifest_store_builder = ManifestStoreBuilder(&hash_factory_),
+  };
+  auto manifest_box_or = manifest_assembler_->Assemble(request);
+  ABSL_ASSERT_OK(manifest_box_or);
+  const ManifestBox& manifest_box = *manifest_box_or;
+  EXPECT_EQ(manifest_box.label, expected.label);
+  EXPECT_EQ(manifest_box.instance_id, kInstanceId.ToString());
+  EXPECT_THAT(manifest_box.hard_binding_assertion,
+              EqualsProto(expected.hard_binding_assertion));
+  EXPECT_THAT(manifest_box.actions_assertion,
+              EqualsProto(expected.actions_assertion));
+  EXPECT_THAT(manifest_box.ingredient_assertions,
+              Pointwise(EqualsProto(), expected.ingredient_assertions));
+}
+
+TEST_F(ManifestAssemblerTest, ParentIngredientReferencedAsInputTo) {
+  GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb(
+    hard_binding { bmff_based_hash {} }
+    actions {
+      opened { ingredient_index: 0 }
+      actions {
+        edited {
+          digital_source_type: "http://cv.iptc.org/newscodes/digitalsourcetype/compositeWithTrainedAlgorithmicMedia"
+          input_ingredient_indexes: 0
+        }
+      }
+    }
+    ingredients { metadata { title: "test ingredient 0" } }
+  )pb");
+  EXPECT_THAT(manifest_assembler_->Assemble(request),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("Inconsistent use of ingredient")));
+}
+
+TEST_F(ManifestAssemblerTest, OpenedActionWithoutIngredientIndex) {
+  GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb(
+    hard_binding { bmff_based_hash {} }
+    actions {
+      opened {
+        action_metadata {
+          software_agent {}
+          description: "opened for testing"
+        }
+      }
+    }
+  )pb");
+  EXPECT_THAT(manifest_assembler_->Assemble(request),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("requires an ingredient index.")));
+}
+
+TEST_F(ManifestAssemblerTest, OpenedActionWithInvalidIngredientIndex) {
+  GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb(
+    hard_binding { bmff_based_hash {} }
+    actions {
+      opened {
+        action_metadata {
+          software_agent {}
+          description: "opened for testing"
+        }
+        ingredient_index: 1
+      }
+    }
+    ingredients { metadata { title: "test ingredient" } }
+  )pb");
+  EXPECT_THAT(manifest_assembler_->Assemble(request),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("ingredient index out of range")));
+}
+
+TEST_F(ManifestAssemblerTest, OpenedActionWithUnreferencedIngredient) {
+  GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb(
+    hard_binding { bmff_based_hash {} }
+    actions {
+      opened {
+        action_metadata {
+          software_agent {}
+          description: "opened for testing"
+        }
+        ingredient_index: 1
+      }
+    }
+    ingredients { metadata { title: "test ingredient" } }
+    ingredients { metadata { title: "test ingredient at index 1" } }
+  )pb");
+  EXPECT_THAT(
+      manifest_assembler_->Assemble(request),
+      StatusIs(absl::StatusCode::kInvalidArgument,
+               HasSubstr("ingredient is not referenced by any action")));
+}
+
+TEST_F(ManifestAssemblerTest, HasSimpleOpenedAction) {
+  GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb(
+    hard_binding { bmff_based_hash {} }
+    actions {
+      opened {
+        action_metadata {
+          software_agent {}
+          description: "opened for testing"
+        }
+        ingredient_index: 0
+      }
+    }
+    ingredients { metadata { title: "test ingredient" } }
+  )pb");
+  ManifestBox expected = {
+      .label = "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a",
+      .hard_binding_assertion = ParseTextProtoOrDie(R"pb(
+        label: "c2pa.hash.bmff.v3"
+        bmff_based_hash {}
+      )pb"),
+      .actions_assertion = ParseTextProtoOrDie(R"pb(
+        label: "c2pa.actions.v2"
+        actions {
+          actions {
+            action: "c2pa.opened"
+            software_agent {}
+            description: "opened for testing"
+            parameters {
+              ingredients {
+                url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3"
+              }
+            }
+          }
+        }
+      )pb"),
+      .ingredient_assertions = {ParseTextProtoOrDie(R"pb(
+        label: "c2pa.ingredient.v3"
+        ingredient_v3 { title: "test ingredient" relationship: "parentOf" }
+      )pb")},
+      .manifest_store_builder = ManifestStoreBuilder(&hash_factory_),
+  };
+  auto manifest_box_or = manifest_assembler_->Assemble(request);
+  ABSL_ASSERT_OK(manifest_box_or);
+  const ManifestBox& manifest_box = *manifest_box_or;
+  EXPECT_EQ(manifest_box.label, expected.label);
+  EXPECT_EQ(manifest_box.instance_id, kInstanceId.ToString());
+  EXPECT_THAT(manifest_box.hard_binding_assertion,
+              EqualsProto(expected.hard_binding_assertion));
+  EXPECT_THAT(manifest_box.actions_assertion,
+              EqualsProto(expected.actions_assertion));
+  EXPECT_THAT(manifest_box.ingredient_assertions,
+              Pointwise(EqualsProto(), expected.ingredient_assertions));
+}
+
+TEST_F(ManifestAssemblerTest, HasOpenedAction) {
+  GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb(
+    hard_binding { bmff_based_hash {} }
+    actions {
+      opened {
+        action_metadata {
+          software_agent {}
+          description: "opened for testing"
+        }
+        ingredient_index: 0
+      }
+    }
+    ingredients {
+      metadata {
+        title: "test ingredient"
+        media_type: "video/mp4"
+        instance_id: "test_ingredient"
+        description: "test ingredient"
+        informational_uri: "https://test.com/ingredient"
+      }
+    }
+  )pb");
+  ManifestBox expected = {
+      .label = "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a",
+      .hard_binding_assertion = ParseTextProtoOrDie(R"pb(
+        label: "c2pa.hash.bmff.v3"
+        bmff_based_hash {}
+      )pb"),
+      .actions_assertion = ParseTextProtoOrDie(R"pb(
+        label: "c2pa.actions.v2"
+        actions {
+          actions {
+            action: "c2pa.opened"
+            software_agent {}
+            description: "opened for testing"
+            parameters {
+              ingredients {
+                url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3"
+              }
+            }
+          }
+        }
+      )pb"),
+      .ingredient_assertions = {ParseTextProtoOrDie(R"pb(
+        label: "c2pa.ingredient.v3"
+        ingredient_v3 {
+          title: "test ingredient"
+          media_type: "video/mp4"
+          relationship: "parentOf"
+          instance_id: "test_ingredient"
+          description: "test ingredient"
+          informational_uri: "https://test.com/ingredient"
+        }
+      )pb")},
+      .manifest_store_builder = ManifestStoreBuilder(&hash_factory_),
+  };
+  auto manifest_box_or = manifest_assembler_->Assemble(request);
+  ABSL_ASSERT_OK(manifest_box_or);
+  const ManifestBox& manifest_box = *manifest_box_or;
+  EXPECT_EQ(manifest_box.label, expected.label);
+  EXPECT_EQ(manifest_box.instance_id, kInstanceId.ToString());
+  EXPECT_THAT(manifest_box.hard_binding_assertion,
+              EqualsProto(expected.hard_binding_assertion));
+  EXPECT_THAT(manifest_box.actions_assertion,
+              EqualsProto(expected.actions_assertion));
+  EXPECT_THAT(manifest_box.ingredient_assertions,
+              Pointwise(EqualsProto(), expected.ingredient_assertions));
+}
+
+TEST_F(ManifestAssemblerTest, HasC2paOpenedAction) {
+  GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb(
+    hard_binding { bmff_based_hash {} }
+    actions {
+      opened {
+        action_metadata {
+          software_agent {}
+          description: "opened for testing"
+        }
+        ingredient_index: 0
+      }
+    }
+    ingredients {
+      metadata {
+        title: "test ingredient"
+        media_type: "video/mp4"
+        instance_id: "test_ingredient"
+        description: "test ingredient"
+        informational_uri: "https://test.com/ingredient"
+      }
+      ingredient_validation_results {
+        manifest_store_bytes: ""
+        ingredient_validation_results {
+          active_manifest { successes { code: "test" } }
+        }
+      }
+    }
+  )pb");
+  std::string ingredient_manifest_store_bytes =
+      CreateSimpleManifestStoreBytes();
+  request.mutable_ingredients(0)
+      ->mutable_ingredient_validation_results()
+      ->set_manifest_store_bytes(ingredient_manifest_store_bytes);
+  ManifestStoreBuilder manifest_store_builder(&hash_factory_);
+  ABSL_ASSERT_OK(manifest_store_builder.AddIngredientManifestStore(
+      request.ingredients(0)
+          .ingredient_validation_results()
+          .manifest_store_bytes()));
+  ManifestBox expected = {
+      .label = "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a",
+      .hard_binding_assertion = ParseTextProtoOrDie(R"pb(
+        label: "c2pa.hash.bmff.v3"
+        bmff_based_hash {}
+      )pb"),
+      .actions_assertion = ParseTextProtoOrDie(R"pb(
+        label: "c2pa.actions.v2"
+        actions {
+          actions {
+            action: "c2pa.opened"
+            software_agent {}
+            description: "opened for testing"
+            parameters {
+              ingredients {
+                url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3"
+              }
+            }
+          }
+        }
+      )pb"),
+      .ingredient_assertions = {ParseTextProtoOrDie(R"pb(
+        label: "c2pa.ingredient.v3"
+        ingredient_v3 {
+          title: "test ingredient"
+          media_type: "video/mp4"
+          relationship: "parentOf"
+          instance_id: "test_ingredient"
+          description: "test ingredient"
+          informational_uri: "https://test.com/ingredient"
+          active_manifest {
+            url: "self#jumbf=/c2pa/urn:c2pa:11111111-1111-1111-1111-111111111111"
+            hash: "7#\034)n*\250\307>)(\263\336K\024\334R\002\'Q\252^\347\361\000\021T\334\256\320pf"
+          }
+          claim_signature {
+            url: "self#jumbf=/c2pa/urn:c2pa:11111111-1111-1111-1111-111111111111/c2pa.signature"
+            hash: "\017b6\027\253\306\235Q\350\246\311y\203sr@\256VE\267S\0137>\027#\377+\311U!l"
+          }
+          validation_results { active_manifest { successes { code: "test" } } }
+        }
+      )pb")},
+      .manifest_store_builder = manifest_store_builder,
+  };
+  auto manifest_box_or = manifest_assembler_->Assemble(request);
+  ABSL_ASSERT_OK(manifest_box_or);
+  const ManifestBox& manifest_box = *manifest_box_or;
+  EXPECT_EQ(manifest_box.label, expected.label);
+  EXPECT_THAT(manifest_box.hard_binding_assertion,
+              EqualsProto(expected.hard_binding_assertion));
+  EXPECT_THAT(manifest_box.actions_assertion,
+              EqualsProto(expected.actions_assertion));
+  EXPECT_THAT(manifest_box.ingredient_assertions,
+              Pointwise(EqualsProto(), expected.ingredient_assertions));
+}
+
+TEST_F(ManifestAssemblerTest, HasC2paOpenedActionNoManifestStoreBytes) {
+  GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb(
+    hard_binding { bmff_based_hash {} }
+    actions {
+      opened {
+        action_metadata {
+          software_agent {}
+          description: "opened for testing"
+        }
+        ingredient_index: 0
+      }
+    }
+    ingredients {
+      metadata {
+        title: "test ingredient"
+        media_type: "video/mp4"
+        instance_id: "test_ingredient"
+        description: "test ingredient"
+        informational_uri: "https://test.com/ingredient"
+      }
+      ingredient_validation_results {}
+    }
+  )pb");
+  ManifestBox expected = {
+      .label = "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a",
+      .hard_binding_assertion = ParseTextProtoOrDie(R"pb(
+        label: "c2pa.hash.bmff.v3"
+        bmff_based_hash {}
+      )pb"),
+      .actions_assertion = ParseTextProtoOrDie(R"pb(
+        label: "c2pa.actions.v2"
+        actions {
+          actions {
+            action: "c2pa.opened"
+            software_agent {}
+            description: "opened for testing"
+            parameters {
+              ingredients {
+                url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3"
+              }
+            }
+          }
+        }
+      )pb"),
+      .ingredient_assertions = {ParseTextProtoOrDie(R"pb(
+        label: "c2pa.ingredient.v3"
+        ingredient_v3 {
+          title: "test ingredient"
+          media_type: "video/mp4"
+          relationship: "parentOf"
+          instance_id: "test_ingredient"
+          description: "test ingredient"
+          informational_uri: "https://test.com/ingredient"
+        }
+      )pb")},
+      .manifest_store_builder = ManifestStoreBuilder(&hash_factory_),
+  };
+  auto manifest_box_or = manifest_assembler_->Assemble(request);
+  ABSL_ASSERT_OK(manifest_box_or);
+  const ManifestBox& manifest_box = *manifest_box_or;
+  EXPECT_EQ(manifest_box.label, expected.label);
+  EXPECT_EQ(manifest_box.instance_id, kInstanceId.ToString());
+  EXPECT_THAT(manifest_box.hard_binding_assertion,
+              EqualsProto(expected.hard_binding_assertion));
+  EXPECT_THAT(manifest_box.actions_assertion,
+              EqualsProto(expected.actions_assertion));
+  EXPECT_THAT(manifest_box.ingredient_assertions,
+              Pointwise(EqualsProto(), expected.ingredient_assertions));
+}
+
+TEST_F(ManifestAssemblerTest, HasC2paOpenedActionMissingValidationResults) {
+  GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb(
+    hard_binding { bmff_based_hash {} }
+    actions {
+      opened {
+        action_metadata {
+          software_agent {}
+          description: "opened for testing"
+        }
+        ingredient_index: 0
+      }
+    }
+    ingredients {
+      metadata {
+        title: "test ingredient"
+        media_type: "video/mp4"
+        instance_id: "test_ingredient"
+        description: "test ingredient"
+        informational_uri: "https://test.com/ingredient"
+      }
+      ingredient_validation_results { manifest_store_bytes: "" }
+    }
+  )pb");
+  std::string ingredient_manifest_store_bytes =
+      CreateSimpleManifestStoreBytes();
+  request.mutable_ingredients(0)
+      ->mutable_ingredient_validation_results()
+      ->set_manifest_store_bytes(ingredient_manifest_store_bytes);
+  EXPECT_THAT(manifest_assembler_->Assemble(request),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("validation results cannot be empty")));
+}
+
+TEST_F(ManifestAssemblerTest, MediaTypeFromIngredientValidationResults) {
+  GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb(
+    hard_binding { bmff_based_hash {} }
+    actions {
+      opened {
+        action_metadata {
+          software_agent {}
+          description: "opened for testing"
+        }
+        ingredient_index: 0
+      }
+    }
+    ingredients {
+      metadata { title: "test ingredient" }
+      ingredient_validation_results { media_type: "video/mp4" }
+    }
+  )pb");
+  auto manifest_box_or = manifest_assembler_->Assemble(request);
+  ABSL_ASSERT_OK(manifest_box_or);
+  const ManifestBox& manifest_box = *manifest_box_or;
+  EXPECT_THAT(manifest_box.ingredient_assertions, ElementsAre(EqualsProto(R"pb(
+                label: "c2pa.ingredient.v3"
+                ingredient_v3 {
+                  title: "test ingredient"
+                  media_type: "video/mp4"
+                  relationship: "parentOf"
+                }
+              )pb")));
+}
+
+TEST_F(ManifestAssemblerTest, CreatesWithMultiPartAssertion) {
+  GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb(
+    hard_binding {
+      bmff_based_hash {}
+      part_bindings {
+        bmff_based_hash {}
+        location { byte_offset: 0 length: 10 }
+      }
+      part_bindings {
+        bmff_based_hash {}
+        location { byte_offset: 10 length: 10 }
+        is_optional: true
+      }
+    }
+    actions {
+      created {
+        action_metadata {
+          software_agent {}
+          description: "created for testing"
+        }
+        digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture"
+      }
+    }
+  )pb");
+  ManifestBox expected = {
+      .label = "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a",
+      .hard_binding_assertion = ParseTextProtoOrDie(R"pb(
+        label: "c2pa.hash.bmff.v3"
+        bmff_based_hash {}
+      )pb"),
+      .actions_assertion = ParseTextProtoOrDie(R"pb(
+        label: "c2pa.actions.v2"
+        actions {
+          actions {
+            action: "c2pa.created"
+            software_agent {}
+            description: "created for testing"
+            digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture"
+          }
+        }
+      )pb"),
+      .part_hash_assertions = {ParseTextProtoOrDie(R"pb(
+                                 label: "c2pa.hash.bmff.v3.part"
+                                 bmff_based_hash {}
+                               )pb"),
+                               ParseTextProtoOrDie(R"pb(
+                                 label: "c2pa.hash.bmff.v3.part__1"
+                                 bmff_based_hash {}
+                               )pb")},
+      .multi_part_assertion = ParseTextProtoOrDie(R"pb(
+        label: "c2pa.hash.multi-asset"
+        multi_asset_hash {
+          parts {
+            location { byte_offset: 0 length: 10 }
+            hash_assertion {
+              url: "self#jumbf=c2pa.assertions/c2pa.hash.bmff.v3.part"
+            }
+            is_optional: false
+          }
+          parts {
+            location { byte_offset: 10 length: 10 }
+            hash_assertion {
+              url: "self#jumbf=c2pa.assertions/c2pa.hash.bmff.v3.part__1"
+            }
+            is_optional: true
+          }
+          pad: "\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000"
+          pad2: ""
+        }
+      )pb"),
+      .ingredient_assertions = {},
+      .manifest_store_builder = ManifestStoreBuilder(&hash_factory_),
+  };
+  auto manifest_box_or = manifest_assembler_->Assemble(request);
+  ABSL_ASSERT_OK(manifest_box_or);
+  const ManifestBox& manifest_box = *manifest_box_or;
+
+  EXPECT_EQ(manifest_box.label, expected.label);
+  EXPECT_EQ(manifest_box.instance_id, kInstanceId.ToString());
+  EXPECT_THAT(manifest_box.hard_binding_assertion,
+              EqualsProto(expected.hard_binding_assertion));
+  EXPECT_THAT(manifest_box.actions_assertion,
+              EqualsProto(expected.actions_assertion));
+  EXPECT_THAT(manifest_box.ingredient_assertions,
+              Pointwise(EqualsProto(), expected.ingredient_assertions));
+  EXPECT_THAT(manifest_box.part_hash_assertions,
+              Pointwise(EqualsProto(), expected.part_hash_assertions));
+  EXPECT_THAT(manifest_box.multi_part_assertion,
+              Optional(EqualsProto(*expected.multi_part_assertion)));
+}
+
+TEST_F(ManifestAssemblerTest, EmptyGatheredAssertionLabelInvalid) {
+  GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb(
+    hard_binding { bmff_based_hash {} }
+    actions {
+      created {
+        action_metadata {
+          software_agent {}
+          description: "created for testing"
+        }
+        digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture"
+      }
+    }
+    gathered_assertions { label: "" data: "{}" content_type: CONTENT_TYPE_JSON }
+  )pb");
+  EXPECT_THAT(manifest_assembler_->Assemble(request),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("non-empty label")));
+}
+
+TEST_F(ManifestAssemblerTest, InvalidGatheredAssertionContentTypeInvalid) {
+  GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb(
+    hard_binding { bmff_based_hash {} }
+    actions {
+      created {
+        action_metadata {
+          software_agent {}
+          description: "created for testing"
+        }
+        digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture"
+      }
+    }
+    gathered_assertions {
+      label: "com.example.gathered"
+      data: "{}"
+      content_type: CONTENT_TYPE_UNSPECIFIED
+    }
+  )pb");
+  EXPECT_THAT(manifest_assembler_->Assemble(request),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("content type must be specified")));
+}
+
+TEST_F(ManifestAssemblerTest, PopulateGatheredAssertions) {
+  GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb(
+    hard_binding { bmff_based_hash {} }
+    actions {
+      created {
+        action_metadata {
+          software_agent {}
+          description: "created for testing"
+        }
+        digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture"
+      }
+    }
+    gathered_assertions {
+      label: "com.example.gathered.1"
+      data: "{\"foo\": 1}"
+      content_type: CONTENT_TYPE_JSON
+    }
+    gathered_assertions {
+      label: "com.example.gathered.2"
+      data: "{\"bar\": 2}"
+      content_type: CONTENT_TYPE_JSON
+    }
+  )pb");
+  auto manifest_box_or = manifest_assembler_->Assemble(request);
+  ABSL_ASSERT_OK(manifest_box_or);
+  const ManifestBox& manifest_box = *manifest_box_or;
+
+  ASSERT_EQ(manifest_box.gathered_assertions.size(), 2);
+  EXPECT_THAT(manifest_box.gathered_assertions,
+              Pointwise(EqualsProto(), request.gathered_assertions()));
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/claim/manifest_box_encoder.cc b/claim/manifest_box_encoder.cc
new file mode 100644
index 0000000..d93c169
--- /dev/null
+++ b/claim/manifest_box_encoder.cc
@@ -0,0 +1,272 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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_box_encoder.h"
+
+#include <string>
+#include <utility>
+#include <vector>
+
+#include "absl/container/flat_hash_map.h"
+#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/cord.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/string_view.h"
+#include "assertion/assertion_referencer.h"
+#include "async/async_context.h"
+#include "claim/signer.h"
+#include "constants/labels.h"
+#include "jumbf/box_builder.h"
+#include "jumbf/constants.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/manifest.pb.h"
+#include "proto/multi_asset_hash_assertion.pb.h"
+#include "uuid/uuid.h"
+
+namespace credentio {
+namespace {
+
+absl::StatusOr<jumbf::BuiltSuperBox> SerializeAssertionStore(
+    std::vector<jumbf::BuiltSuperBox> assertions) {
+  jumbf::SuperBoxBuilder assertion_store_builder(
+      kAssertionStoreUuid, {.label = absl::Cord(kAssertionStoreLabel)});
+  for (jumbf::BuiltSuperBox& assertion_box : assertions) {
+    ABSL_RETURN_IF_ERROR(
+        assertion_store_builder.AddChild(std::move(assertion_box)));
+  }
+  return std::move(assertion_store_builder).Finalize();
+}
+
+absl::StatusOr<jumbf::BuiltSuperBox> SerializeCborSuperBox(
+    const Uuid& uuid, absl::string_view label, absl::Cord cbor_content) {
+  jumbf::SuperBoxBuilder builder(uuid, {.label = absl::Cord(label)});
+  ABSL_RETURN_IF_ERROR(
+      builder.AddContent(jumbf::kCborBoxType, std::move(cbor_content)));
+  return std::move(builder).Finalize();
+}
+
+struct SignatureBoxResult {
+  jumbf::BuiltSuperBox box;
+};
+
+void MakeSignatureBox(
+    absl::string_view claim_cbor, const ClaimSigner& claim_signer,
+    const ClaimSigner::Options& options,
+    absl::AnyInvocable<void(absl::StatusOr<SignatureBoxResult>) &&> cb) {
+  AsyncResolver<SignatureBoxResult> resolver(options.async_context,
+                                             std::move(cb));
+  auto done_cb =
+      [resolver = std::move(resolver)](
+          absl::StatusOr<ClaimSigner::SignResult> signature_status) mutable {
+        if (!signature_status.ok()) {
+          resolver.Return(signature_status.status());
+          return;
+        }
+        absl::StatusOr<jumbf::BuiltSuperBox> signature_box =
+            SerializeCborSuperBox(
+                kClaimSignatureUuid, kClaimSignatureLabel,
+                absl::Cord(std::move(signature_status->signature)));
+        if (!signature_box.ok()) {
+          resolver.Return(signature_box.status());
+          return;
+        }
+        resolver.Return(SignatureBoxResult{
+            .box = std::move(*signature_box),
+        });
+      };
+  claim_signer.Sign(claim_cbor, options, std::move(done_cb));
+}
+
+}  // namespace
+
+absl::Status ManifestBoxEncoder::PopulateMultiAssetPartHashes(
+    MultiAssetHashAssertion* assertion) const {
+  for (auto& part : *assertion->mutable_parts()) {
+    absl::string_view url = part.hash_assertion().url();
+    auto it = assertion_url_to_index_.find(url);
+    if (it == assertion_url_to_index_.end()) {
+      return absl::InternalError(
+          absl::StrCat("Could not find hash for url: ", url));
+    }
+    *part.mutable_hash_assertion() = assertion_hashed_uris_[it->second];
+  }
+  return absl::OkStatus();
+}
+
+absl::Status ManifestBoxEncoder::AddAssertion(Assertion& assertion) {
+  if (assertion.has_multi_asset_hash()) {
+    ABSL_RETURN_IF_ERROR(
+        PopulateMultiAssetPartHashes(assertion.mutable_multi_asset_hash()));
+  }
+
+  ABSL_ASSIGN_OR_RETURN(jumbf::BuiltSuperBox assertion_box,
+                        assertion_encoder_.ToJumbf(assertion));
+  ABSL_ASSIGN_OR_RETURN(HashedUri assertion_hashed_uri,
+                        assertion_reference_.Generate(assertion_box));
+  assertion_url_to_index_[assertion_hashed_uri.url()] =
+      assertion_hashed_uris_.size();
+  assertion_hashed_uris_.Add(std::move(assertion_hashed_uri));
+  assertion_boxes_.push_back(std::move(assertion_box));
+  return absl::OkStatus();
+}
+
+absl::Status ManifestBoxEncoder::AddActionsAssertion(Assertion assertion) {
+  for (ActionItem& action : *assertion.mutable_actions()->mutable_actions()) {
+    if (action.action() == "c2pa.opened" &&
+        action.parameters().ingredients_size() != 1) {
+      return absl::InvalidArgumentError(
+          "Opened action must have exactly one ingredient.");
+    }
+    if (action.parameters().ingredients_size() != 0) {
+      for (auto& ingredient :
+           *action.mutable_parameters()->mutable_ingredients()) {
+        auto it = assertion_url_to_index_.find(ingredient.url());
+        if (it == assertion_url_to_index_.end()) {
+          return absl::InternalError(absl::StrCat(
+              "Could not find hash for ingredient url: ", ingredient.url()));
+        }
+        ingredient = assertion_hashed_uris_[it->second];
+      }
+    }
+    if (action.parameters().has_redacted_uri()) {
+      redacted_assertions_.emplace_back(action.parameters().redacted_uri());
+    }
+  }
+  return AddAssertion(assertion);
+}
+
+absl::Status ManifestBoxEncoder::AddGatheredAssertion(
+    const GatheredAssertion& gathered_assertion) {
+  if (gathered_assertion.content_type() !=
+      GatheredAssertion::CONTENT_TYPE_JSON) {
+    return absl::InvalidArgumentError(
+        "Gathered assertion content type must be JSON.");
+  }
+  jumbf::SuperBoxBuilder builder(
+      jumbf::kJsonBoxTypeUuid,
+      {.label = absl::Cord(gathered_assertion.label())});
+  ABSL_RETURN_IF_ERROR(builder.AddContent(
+      jumbf::kJsonBoxType, absl::Cord(gathered_assertion.data())));
+  ABSL_ASSIGN_OR_RETURN(jumbf::BuiltSuperBox assertion_box,
+                        std::move(builder).Finalize());
+  ABSL_ASSIGN_OR_RETURN(HashedUri assertion_hashed_uri,
+                        assertion_reference_.Generate(assertion_box));
+  gathered_assertion_hashed_uris_.Add(std::move(assertion_hashed_uri));
+  assertion_boxes_.push_back(std::move(assertion_box));
+  return absl::OkStatus();
+}
+
+absl::Status ManifestBoxEncoder::AddAssertions() {
+  // Note that we must add an assertion before adding any other assertion that
+  // references it, so the referenced assertion's hash is available. So, for
+  // example, ingredient assertions must be added before actions assertions
+  // since some actions assertions reference ingredient assertions.
+  for (Assertion& assertion : manifest_.ingredient_assertions) {
+    ABSL_RETURN_IF_ERROR(AddAssertion(assertion));
+  }
+  ABSL_RETURN_IF_ERROR(AddActionsAssertion(manifest_.actions_assertion));
+  ABSL_RETURN_IF_ERROR(AddAssertion(manifest_.hard_binding_assertion));
+  for (Assertion& assertion : manifest_.part_hash_assertions) {
+    ABSL_RETURN_IF_ERROR(AddAssertion(assertion));
+  }
+  if (manifest_.multi_part_assertion.has_value()) {
+    ABSL_RETURN_IF_ERROR(AddAssertion(*manifest_.multi_part_assertion));
+  }
+  for (const auto& gathered_assertion : manifest_.gathered_assertions) {
+    ABSL_RETURN_IF_ERROR(AddGatheredAssertion(gathered_assertion));
+  }
+  return absl::OkStatus();
+}
+
+void ManifestBoxEncoder::EncodeClaimAndSignature(
+    const ClaimSigner::Options& options,
+    absl::AnyInvocable<void(absl::StatusOr<ManifestEncoderResult>) &&> cb) {
+  AsyncResolver<ManifestEncoderResult> resolver(options.async_context,
+                                                std::move(cb));
+  Claim claim;
+  claim.set_instance_id(manifest_.instance_id);
+  *claim.mutable_claim_generator_info() = generator_info_;
+  claim.mutable_created_assertions()->Swap(&assertion_hashed_uris_);
+  claim.mutable_gathered_assertions()->Swap(&gathered_assertion_hashed_uris_);
+  for (const std::string& redacted_assertion : redacted_assertions_) {
+    *claim.add_redacted_assertions() = redacted_assertion;
+  }
+  claim.set_signature(kClaimSignatureUrl);
+  claim.set_default_algorithm(absl::StrCat(hash_algorithm_));
+
+  auto prepare_claim = [&]() -> absl::StatusOr<std::string> {
+    ABSL_ASSIGN_OR_RETURN(std::string claim_cbor,
+                          claim_cbor_encoder_.ToCbor(claim));
+    ABSL_ASSIGN_OR_RETURN(jumbf::BuiltSuperBox claim_box,
+                          SerializeCborSuperBox(kClaimUuid, kClaimV2Label,
+                                                absl::Cord(claim_cbor)));
+    ABSL_RETURN_IF_ERROR(manifest_builder_.AddChild(std::move(claim_box)));
+    return claim_cbor;
+  };
+  absl::StatusOr<std::string> claim_cbor = prepare_claim();
+  if (!claim_cbor.ok()) {
+    resolver.Return(claim_cbor.status());
+    return;
+  }
+  auto done_cb =
+      [this, resolver = std::move(resolver)](
+          absl::StatusOr<SignatureBoxResult> claim_signature) mutable {
+        auto finalize_manifest =
+            [&]() -> absl::StatusOr<ManifestEncoderResult> {
+          ABSL_RETURN_IF_ERROR(claim_signature.status());
+          ABSL_RETURN_IF_ERROR(
+              manifest_builder_.AddChild(std::move(claim_signature->box)));
+          ABSL_ASSIGN_OR_RETURN(jumbf::BuiltSuperBox active_manifest,
+                                std::move(manifest_builder_).Finalize());
+          ABSL_ASSIGN_OR_RETURN(jumbf::BuiltSuperBox finalized_manifest_store,
+                                std::move(manifest_.manifest_store_builder)
+                                    .Finalize(std::move(active_manifest)));
+          return ManifestEncoderResult{
+              .box = std::move(finalized_manifest_store),
+          };
+        };
+        resolver.Return(finalize_manifest());
+      };
+  MakeSignatureBox(*claim_cbor, claim_signer_, options, std::move(done_cb));
+}
+
+absl::Status ManifestBoxEncoder::EncodeAssertionStore() {
+  ABSL_ASSIGN_OR_RETURN(jumbf::BuiltSuperBox assertion_store,
+                        SerializeAssertionStore(std::move(assertion_boxes_)));
+  return manifest_builder_.AddChild(std::move(assertion_store));
+}
+
+void ManifestBoxEncoder::Encode(
+    const ClaimSigner::Options& options,
+    absl::AnyInvocable<void(absl::StatusOr<ManifestEncoderResult>) &&> cb) {
+  auto add_assertions_status = AddAssertions();
+  if (!add_assertions_status.ok()) {
+    std::move(cb)(add_assertions_status);
+    return;
+  }
+  auto encode_assertion_store_status = EncodeAssertionStore();
+  if (!encode_assertion_store_status.ok()) {
+    std::move(cb)(encode_assertion_store_status);
+    return;
+  }
+  EncodeClaimAndSignature(options, std::move(cb));
+}
+}  // namespace credentio
diff --git a/claim/manifest_box_encoder.h b/claim/manifest_box_encoder.h
new file mode 100644
index 0000000..b78550d
--- /dev/null
+++ b/claim/manifest_box_encoder.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_CLAIM_MANIFEST_BOX_ENCODER_H_
+#define THIRD_PARTY_CREDENTIO_CLAIM_MANIFEST_BOX_ENCODER_H_
+
+#include <cstddef>
+#include <string>
+#include <vector>
+
+#include "absl/base/nullability.h"
+#include "absl/container/flat_hash_map.h"
+#include "absl/functional/any_invocable.h"
+#include "absl/log/die_if_null.h"
+#include "absl/status/status.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/cord.h"
+#include "assertion/assertion_encoder.h"
+#include "assertion/assertion_referencer.h"
+#include "claim/claim_cbor_encoder.h"
+#include "claim/manifest_box.h"
+#include "claim/signer.h"
+#include "constants/labels.h"
+#include "crypto/algorithms.h"
+#include "google/protobuf/repeated_ptr_field.h"
+#include "jumbf/box_builder.h"
+#include "proto/assertion.pb.h"
+#include "proto/generator_info.pb.h"
+#include "proto/hashed_uri.pb.h"
+#include "proto/multi_asset_hash_assertion.pb.h"
+
+namespace credentio {
+
+struct ManifestEncoderResult {
+  // Generated JUMBF manifest store superbox.
+  jumbf::BuiltSuperBox box;
+};
+
+class ManifestBoxEncoder {
+ public:
+  ManifestBoxEncoder(
+      const AssertionReferencer* absl_nonnull assertion_reference,
+      const GeneratorInfo* absl_nonnull generator_info,
+      const ClaimSigner* absl_nonnull claim_signer,
+      const AssertionEncoder* absl_nonnull assertion_cbor_encoder,
+      const ClaimCborEncoder* absl_nonnull claim_cbor_encoder,
+      ManifestBox* absl_nonnull manifest, HashAlgorithm hash_algorithm)
+      : assertion_reference_(*ABSL_DIE_IF_NULL(assertion_reference)),
+        generator_info_(*ABSL_DIE_IF_NULL(generator_info)),
+        claim_signer_(*ABSL_DIE_IF_NULL(claim_signer)),
+        manifest_builder_(kStandardManifestUuid,
+                          {.label = absl::Cord(manifest->label)}),
+        manifest_(*ABSL_DIE_IF_NULL(manifest)),
+        hash_algorithm_(hash_algorithm),
+        assertion_encoder_(*ABSL_DIE_IF_NULL(assertion_cbor_encoder)),
+        claim_cbor_encoder_(*ABSL_DIE_IF_NULL(claim_cbor_encoder)) {}
+
+  // Encodes the manifest to a JUMBF-type box.
+  // Consumes the provided ManifestBox.
+  // Encodings are done in the following order:
+  // 1. Build all assertions, resolving references between them.
+  // 2. Encode the claim and signature.
+  // 3. Encode the assertion store.
+  void Encode(
+      const ClaimSigner::Options& options,
+      absl::AnyInvocable<void(absl::StatusOr<ManifestEncoderResult>) &&> cb);
+
+ private:
+  // Encodes the given assertion and adds it to assertion_boxes_,
+  // assertion_hashed_uris_, and assertion_url_to_index_.
+  absl::Status AddAssertion(Assertion& assertion);
+
+  // Populates hashes for hashed URIs in the given actions assertion, then adds
+  // it via AddAssertion.
+  absl::Status AddActionsAssertion(Assertion assertion);
+
+  // Builds all assertions in the manifest and resolves references between
+  // them, populating assertion_boxes_, assertion_hashed_uris_, and
+  // assertion_url_to_index_.
+  absl::Status AddAssertions();
+
+  // Encodes and signs the claim, adding claim and claim signature boxes to
+  // manifest_builder_.
+  void EncodeClaimAndSignature(
+      const ClaimSigner::Options& options,
+      absl::AnyInvocable<void(absl::StatusOr<ManifestEncoderResult>) &&> cb);
+
+  // Adds the assertion store box to manifest_builder_.
+  absl::Status EncodeAssertionStore();
+
+  // Populates hashed URIs in the given multi-asset hash assertion.
+  // Assumes that the referenced part hash assertions have already been added.
+  absl::Status PopulateMultiAssetPartHashes(
+      MultiAssetHashAssertion* assertion) const;
+
+  // Encodes a gathered assertion as a JSON content superbox, generates its
+  // hashed URI, adds the box to assertion_boxes_, and adds the hashed URI to
+  // gathered_assertion_hashed_uris_.
+  absl::Status AddGatheredAssertion(
+      const GatheredAssertion& gathered_assertion);
+
+  const AssertionReferencer& assertion_reference_;
+  const GeneratorInfo& generator_info_;
+  const ClaimSigner& claim_signer_;
+  jumbf::SuperBoxBuilder manifest_builder_;
+  ManifestBox& manifest_;
+  // Redacted assertions to be added to the claim.
+  std::vector<std::string> redacted_assertions_;
+  std::vector<jumbf::BuiltSuperBox> assertion_boxes_;
+  google::protobuf::RepeatedPtrField<HashedUri> assertion_hashed_uris_;
+  google::protobuf::RepeatedPtrField<HashedUri> gathered_assertion_hashed_uris_;
+  // Map from relative assertion URL (e.g.
+  // "self#jumbf=c2pa.assertions/c2pa.actions.v2") to index in assertion_uris_.
+  // Needed for resolving references between assertions.
+  absl::flat_hash_map<std::string, size_t> assertion_url_to_index_;
+  HashAlgorithm hash_algorithm_;
+  const AssertionEncoder& assertion_encoder_;
+  const ClaimCborEncoder& claim_cbor_encoder_;
+};
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_CLAIM_MANIFEST_BOX_ENCODER_H_
diff --git a/claim/manifest_box_encoder_test.cc b/claim/manifest_box_encoder_test.cc
new file mode 100644
index 0000000..d03ed35
--- /dev/null
+++ b/claim/manifest_box_encoder_test.cc
@@ -0,0 +1,1980 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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_box_encoder.h"
+
+#include <memory>
+#include <string>
+#include <utility>
+#include <vector>
+
+#include "absl/log/absl_check.h"
+#include "absl/status/status.h"
+#include "absl/status/status_matchers.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/cord.h"
+#include "absl/strings/str_join.h"
+#include "absl/strings/string_view.h"
+#include "absl/synchronization/notification.h"
+#include "assertion/assertion_encoder.h"
+#include "assertion/assertion_referencer.h"
+#include "async/async_context.h"
+#include "claim/claim_cbor_encoder.h"
+#include "claim/local_signer.h"
+#include "claim/manifest_box.h"
+#include "claim/manifest_store_builder.h"
+#include "claim/signer.h"
+#include "constants/labels.h"
+#include "crypto/algorithms.h"
+#include "crypto/crypto_read_handler.h"
+#include "crypto/crypto_write_handler.h"
+#include "crypto/default/default_crypto_read_handler.h"
+#include "crypto/default/hasher.h"
+#include "crypto/hash.h"
+#include "gmock/gmock.h"
+#include "google/protobuf/util/message_differencer.h"
+#include "gtest/gtest.h"
+#include "jumbf/box_builder.h"
+#include "proto/actions_assertion.pb.h"
+#include "proto/assertion.pb.h"
+#include "proto/generator.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/signature_info.pb.h"
+#include "proto/validation_result.pb.h"
+#include "proto/validation_status.pb.h"
+#include "testing/create_test_crypto_write_handler.h"
+#include "testing/local_key_crypto_write_handler.h"
+#include "testing/manifest_store_test_utils.h"
+#include "testing/proto_test_utils.h"
+#include "uuid/uuid.h"
+
+namespace credentio {
+namespace {
+using ::absl_testing::IsOk;
+using ::absl_testing::IsOkAndHolds;
+using ::absl_testing::StatusIs;
+using ::credentio_testing::ParseTextProtoOrDie;
+using ::testing::HasSubstr;
+using ::testing::Pointee;
+
+constexpr auto kUuid =
+    Uuid::FromStringOrDie("12345678-1234-1234-1234-123456789012");
+
+MATCHER_P(EqualsProtoIgnoringFieldsAndOrdering, expected, "") {
+  google::protobuf::util::MessageDifferencer differencer;
+  differencer.set_repeated_field_comparison(
+      google::protobuf::util::MessageDifferencer::AS_SET);
+  differencer.IgnoreField(HashedUri::descriptor()->FindFieldByName("hash"));
+  differencer.IgnoreField(
+      SignatureInfo::descriptor()->FindFieldByName("cert_serial_number"));
+  differencer.IgnoreField(
+      SignatureInfo::descriptor()->FindFieldByName("certificate_issuer"));
+  differencer.IgnoreField(
+      SignatureInfo::descriptor()->FindFieldByName("timestamping_time"));
+  std::string diff;
+  differencer.ReportDifferencesToString(&diff);
+  bool match = differencer.Compare(expected, arg);
+  if (!match) {
+    *result_listener << "\nDifference:\n" << diff;
+  }
+  return match;
+}
+
+ClaimSigner::Options ToClaimSignerOptions(
+    const GenerateManifestStoreOptions& options, AsyncContext context) {
+  ClaimSigner::Options result;
+  result.async_context = std::move(context);
+  switch (options.time_stamping_mode()) {
+    case GenerateManifestStoreOptions::TIME_STAMPING_REQUIRED:
+      result.time_stamping_mode = TimeStampingMode::kRequired;
+      break;
+    case GenerateManifestStoreOptions::TIME_STAMPING_OMITTED_WITH_PADDING:
+      result.time_stamping_mode = TimeStampingMode::kOmittedWithPadding;
+      break;
+    case GenerateManifestStoreOptions::TIME_STAMPING_OPTIONAL:
+    case GenerateManifestStoreOptions::TIME_STAMPING_MODE_UNSPECIFIED:
+    default:
+      result.time_stamping_mode = TimeStampingMode::kOptional;
+      break;
+  }
+  return result;
+}
+
+absl::StatusOr<jumbf::BuiltSuperBox> Encode(
+    ManifestBoxEncoder& manifest_box_encoder,
+    const GenerateManifestStoreOptions& options = {}) {
+  absl::Notification done;
+  absl::StatusOr<ManifestEncoderResult> result;
+  manifest_box_encoder.Encode(
+      ToClaimSignerOptions(options, AsyncContext()),
+      [&result, &done](absl::StatusOr<ManifestEncoderResult> res) {
+        result = std::move(res);
+        done.Notify();
+      });
+  done.WaitForNotification();
+  if (!result.ok()) {
+    return result.status();
+  }
+  return std::move(result->box);
+}
+
+class FakeUuidGenerator : public UuidGenerator {
+ public:
+  FakeUuidGenerator() = default;
+
+  Uuid Generate() const override { return kUuid; }
+};
+
+class FakeHasherFactory : public HasherFactory {
+ public:
+  absl::StatusOr<std::unique_ptr<Hasher>> Create() const override {
+    return CreateHasher("sha256");
+  }
+  HashAlgorithm algorithm() const override { return HashAlgorithm::kSha256; }
+};
+
+class ManifestBoxEncoderTest : public testing::Test {
+ protected:
+  ManifestBoxEncoderTest()
+      : assertion_cbor_encoder_(), claim_cbor_encoder_(GetClaimCborEncoder()) {
+    generator_info_.set_name("Test generator");
+    generator_info_.set_spec_version("2.2.0");
+    auto crypto_writer = CreateTestCryptoWriteHandler({});
+    ABSL_CHECK_OK(crypto_writer);
+    crypto_writer_ = std::move(*crypto_writer);
+  }
+  std::unique_ptr<CryptoWriteHandler> crypto_writer_;
+  GeneratorInfo generator_info_;
+  FakeHasherFactory hasher_factory_;
+  StandardAssertionEncoder assertion_cbor_encoder_;
+  const ClaimCborEncoder& claim_cbor_encoder_;
+
+  ManifestBox CreateMinimalManifestBox() {
+    return {
+        .label = "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a",
+        .instance_id = "12345678-1234-1234-1234-123456789012",
+        .hard_binding_assertion = ParseTextProtoOrDie(R"pb(
+          label: "c2pa.hash.bmff.v3"
+          bmff_based_hash { exclusions { xpath: "" } }
+        )pb"),
+        .actions_assertion = ParseTextProtoOrDie(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"),
+        .ingredient_assertions = {},
+        .manifest_store_builder = ManifestStoreBuilder(&hasher_factory_),
+    };
+  }
+};
+
+TEST_F(ManifestBoxEncoderTest, GetEncodedManifest) {
+  ManifestBox manifest_box = CreateMinimalManifestBox();
+  PartialValidationResultProto expected_validated_result = ParseTextProtoOrDie(
+      R"pb(
+        active_manifest {
+          label: "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a"
+          claim {
+            claim_generator_info {
+              name: "Test generator"
+              spec_version: "2.2.0"
+            }
+            signature_info {
+              issuer: "OU=Test Unit,O=Test Org,CN=Test Claim Signer"
+            }
+            created_assertions {
+              url: "self#jumbf=c2pa.assertions/c2pa.hash.bmff.v3"
+              algorithm: "sha256"
+              hash: "D\242\244\274\235o\350\352\312\307\225|C\033\204\347\311\243\202\'\203c\362\331T1a\377P\271\255\366"
+            }
+            created_assertions {
+              url: "self#jumbf=c2pa.assertions/c2pa.actions.v2"
+              algorithm: "sha256"
+              hash: "\331\271\373\3228\310\335>y\263\334\342\335\367\211Q\360\325\227\r\345$]\214\005\3047[\r#1\312"
+            }
+            default_algorithm: "sha256"
+            label: "c2pa.claim.v2"
+            instance_id: "12345678-1234-1234-1234-123456789012"
+          }
+          assertions {
+            label: "c2pa.hash.bmff.v3"
+            bmff_based_hash { exclusions { xpath: "" } }
+          }
+          assertions {
+            label: "c2pa.actions.v2"
+            actions {
+              actions {
+                action: "c2pa.created"
+                description: "created for testing"
+                digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture"
+                software_agent { name: "test generator" }
+              }
+            }
+          }
+          validation {
+            successes {
+              code: "timeStamp.validated"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature"
+            }
+            successes {
+              code: "timeStamp.trusted"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature"
+            }
+            successes {
+              code: "signingCredential.trusted"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature"
+            }
+            successes {
+              code: "claimSignature.insideValidity"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature"
+            }
+            successes {
+              code: "claimSignature.validated"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature"
+            }
+            successes {
+              code: "assertion.hashedURI.match"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.hash.bmff.v3"
+            }
+            successes {
+              code: "assertion.hashedURI.match"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.actions.v2"
+            }
+            informationals {
+              code: "signingCredential.ocsp.skipped"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature"
+            }
+          }
+        }
+        hard_binding_uri: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.hash.bmff.v3"
+      )pb");
+  AssertionReferencer assertion_reference(&hasher_factory_);
+  LocalClaimSigner claim_signer(crypto_writer_.get(), &hasher_factory_);
+  ManifestBoxEncoder manifest_box_encoder(
+      &assertion_reference, &generator_info_, &claim_signer,
+      &assertion_cbor_encoder_, &claim_cbor_encoder_, &manifest_box,
+      hasher_factory_.algorithm());
+  absl::StatusOr<jumbf::BuiltSuperBox> built_manifest_store_box =
+      Encode(manifest_box_encoder);
+  ABSL_ASSERT_OK(built_manifest_store_box);
+  absl::Cord result_manifest_store_bytes =
+      std::move(*built_manifest_store_box).AsCord();
+  EXPECT_THAT(ValidateClaim(result_manifest_store_bytes.Flatten()),
+              IsOkAndHolds(EqualsProtoIgnoringFieldsAndOrdering(
+                  expected_validated_result.active_manifest().claim())));
+  EXPECT_THAT(ValidateManifestStore(result_manifest_store_bytes.Flatten()),
+              IsOkAndHolds(Pointee(EqualsProtoIgnoringFieldsAndOrdering(
+                  expected_validated_result))));
+}
+
+TEST_F(ManifestBoxEncoderTest, TimeStampingModeUnspecified) {
+  ManifestBox manifest_box = CreateMinimalManifestBox();
+  AssertionReferencer assertion_reference(&hasher_factory_);
+  auto crypto_writer =
+      CreateTestCryptoWriteHandler({.disable_timestamping = true});
+  ABSL_ASSERT_OK(crypto_writer);
+  LocalClaimSigner claim_signer(crypto_writer->get(), &hasher_factory_);
+  ManifestBoxEncoder manifest_box_encoder(
+      &assertion_reference, &generator_info_, &claim_signer,
+      &assertion_cbor_encoder_, &claim_cbor_encoder_, &manifest_box,
+      hasher_factory_.algorithm());
+  GenerateManifestStoreOptions options;
+  options.set_time_stamping_mode(
+      GenerateManifestStoreOptions::TIME_STAMPING_MODE_UNSPECIFIED);
+  // Should default to OPTIONAL, so even if timestamping fails, encode succeeds.
+  EXPECT_THAT(Encode(manifest_box_encoder, options), IsOk());
+}
+
+TEST_F(ManifestBoxEncoderTest, TimeStampingModeOptional) {
+  ManifestBox manifest_box = CreateMinimalManifestBox();
+  AssertionReferencer assertion_reference(&hasher_factory_);
+  auto crypto_writer =
+      CreateTestCryptoWriteHandler({.disable_timestamping = true});
+  ABSL_ASSERT_OK(crypto_writer);
+  LocalClaimSigner claim_signer(crypto_writer->get(), &hasher_factory_);
+  ManifestBoxEncoder manifest_box_encoder(
+      &assertion_reference, &generator_info_, &claim_signer,
+      &assertion_cbor_encoder_, &claim_cbor_encoder_, &manifest_box,
+      hasher_factory_.algorithm());
+  GenerateManifestStoreOptions options;
+  options.set_time_stamping_mode(
+      GenerateManifestStoreOptions::TIME_STAMPING_OPTIONAL);
+  // Timestamping fails, but mode is OPTIONAL, so encode succeeds.
+  EXPECT_THAT(Encode(manifest_box_encoder, options), IsOk());
+}
+
+TEST_F(ManifestBoxEncoderTest, TimeStampingModeRequiredSuccess) {
+  ManifestBox manifest_box = CreateMinimalManifestBox();
+  AssertionReferencer assertion_reference(&hasher_factory_);
+  auto crypto_writer =
+      CreateTestCryptoWriteHandler({.disable_timestamping = false});
+  ABSL_ASSERT_OK(crypto_writer);
+  LocalClaimSigner claim_signer(crypto_writer->get(), &hasher_factory_);
+  ManifestBoxEncoder manifest_box_encoder(
+      &assertion_reference, &generator_info_, &claim_signer,
+      &assertion_cbor_encoder_, &claim_cbor_encoder_, &manifest_box,
+      hasher_factory_.algorithm());
+  GenerateManifestStoreOptions options;
+  options.set_time_stamping_mode(
+      GenerateManifestStoreOptions::TIME_STAMPING_REQUIRED);
+  EXPECT_THAT(Encode(manifest_box_encoder, options), IsOk());
+}
+
+TEST_F(ManifestBoxEncoderTest, TimeStampingModeRequiredFailure) {
+  ManifestBox manifest_box = CreateMinimalManifestBox();
+  AssertionReferencer assertion_reference(&hasher_factory_);
+  auto crypto_writer =
+      CreateTestCryptoWriteHandler({.disable_timestamping = true});
+  ABSL_ASSERT_OK(crypto_writer);
+  LocalClaimSigner claim_signer(crypto_writer->get(), &hasher_factory_);
+  ManifestBoxEncoder manifest_box_encoder(
+      &assertion_reference, &generator_info_, &claim_signer,
+      &assertion_cbor_encoder_, &claim_cbor_encoder_, &manifest_box,
+      hasher_factory_.algorithm());
+  GenerateManifestStoreOptions options;
+  options.set_time_stamping_mode(
+      GenerateManifestStoreOptions::TIME_STAMPING_REQUIRED);
+  // Timestamping fails, and mode is REQUIRED, so encode fails.
+  EXPECT_THAT(
+      Encode(manifest_box_encoder, options),
+      StatusIs(absl::StatusCode::kUnavailable, HasSubstr("TSA is disabled")));
+}
+
+TEST_F(ManifestBoxEncoderTest, TimeStampingModeOmittedWithPadding) {
+  ManifestBox manifest_box = CreateMinimalManifestBox();
+  AssertionReferencer assertion_reference(&hasher_factory_);
+  // This should not cause a failure because timestamping should be skipped.
+  auto crypto_writer =
+      CreateTestCryptoWriteHandler({.disable_timestamping = true});
+  ABSL_ASSERT_OK(crypto_writer);
+  LocalClaimSigner claim_signer(crypto_writer->get(), &hasher_factory_);
+  ManifestBoxEncoder manifest_box_encoder(
+      &assertion_reference, &generator_info_, &claim_signer,
+      &assertion_cbor_encoder_, &claim_cbor_encoder_, &manifest_box,
+      hasher_factory_.algorithm());
+  GenerateManifestStoreOptions options;
+  options.set_time_stamping_mode(
+      GenerateManifestStoreOptions::TIME_STAMPING_OMITTED_WITH_PADDING);
+  EXPECT_THAT(Encode(manifest_box_encoder, options), IsOk());
+}
+
+TEST_F(ManifestBoxEncoderTest,
+       EncodeManifestWithInvalidOpenedActionNoIngredients) {
+  ManifestBox manifest_box = {
+      .label = "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a",
+      .instance_id = "12345678-1234-1234-1234-123456789012",
+      .hard_binding_assertion = ParseTextProtoOrDie(R"pb(
+        label: "c2pa.hash.bmff.v3"
+        bmff_based_hash { exclusions { xpath: "" } }
+      )pb"),
+      .actions_assertion = ParseTextProtoOrDie(R"pb(
+        label: "c2pa.actions.v2"
+        actions {
+          actions {
+            action: "c2pa.opened"
+            software_agent { name: "test generator" }
+            description: "created for testing"
+          }
+        }
+      )pb"),
+      .ingredient_assertions = {},
+      .manifest_store_builder = ManifestStoreBuilder(&hasher_factory_),
+  };
+  AssertionReferencer assertion_reference(&hasher_factory_);
+  LocalClaimSigner claim_signer(crypto_writer_.get(), &hasher_factory_);
+  ManifestBoxEncoder manifest_box_encoder(
+      &assertion_reference, &generator_info_, &claim_signer,
+      &assertion_cbor_encoder_, &claim_cbor_encoder_, &manifest_box,
+      hasher_factory_.algorithm());
+  EXPECT_THAT(Encode(manifest_box_encoder),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("Opened action must have exactly one "
+                                 "ingredient.")));
+}
+
+TEST_F(ManifestBoxEncoderTest,
+       EncodeManifestWithInvalidOpenedActionMultipleIngredients) {
+  ManifestBox manifest_box = {
+      .label = "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a",
+      .instance_id = "12345678-1234-1234-1234-123456789012",
+      .hard_binding_assertion = ParseTextProtoOrDie(R"pb(
+        label: "c2pa.hash.bmff.v3"
+        bmff_based_hash { exclusions { xpath: "" } }
+      )pb"),
+      .actions_assertion = ParseTextProtoOrDie(R"pb(
+        label: "c2pa.actions.v2"
+        actions {
+          actions {
+            action: "c2pa.opened"
+            software_agent { name: "test generator" }
+            description: "created for testing"
+            parameters {
+              ingredients {
+                url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3"
+              }
+              ingredients {
+                url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3__1"
+              }
+            }
+          }
+        }
+      )pb"),
+      .ingredient_assertions =
+          {
+              ParseTextProtoOrDie(R"pb(
+                label: "c2pa.ingredient.v3"
+                ingredient_v3 {
+                  title: "test ingredient"
+                  media_type: "video/mp4"
+                  relationship: "parentOf"
+                  instance_id: "test_ingredient"
+                  description: "test ingredient"
+                  informational_uri: "https://test.com/ingredient"
+                }
+              )pb"),
+              ParseTextProtoOrDie(R"pb(
+                label: "c2pa.ingredient.v3__1"
+                ingredient_v3 {
+                  title: "test ingredient 2"
+                  media_type: "video/mp4"
+                  relationship: "parentOf"
+                  instance_id: "test_ingredient"
+                  description: "test ingredient"
+                  informational_uri: "https://test.com/ingredient"
+                }
+              )pb"),
+          },
+      .manifest_store_builder = ManifestStoreBuilder(&hasher_factory_),
+  };
+  AssertionReferencer assertion_reference(&hasher_factory_);
+  LocalClaimSigner claim_signer(crypto_writer_.get(), &hasher_factory_);
+  ManifestBoxEncoder manifest_box_encoder(
+      &assertion_reference, &generator_info_, &claim_signer,
+      &assertion_cbor_encoder_, &claim_cbor_encoder_, &manifest_box,
+      hasher_factory_.algorithm());
+  EXPECT_THAT(Encode(manifest_box_encoder),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("Opened action must have exactly one "
+                                 "ingredient.")));
+}
+
+TEST_F(ManifestBoxEncoderTest, EncodedManifestWithCreatedActionIngredient) {
+  ManifestBox manifest_box = {
+      .label = "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a",
+      .instance_id = "12345678-1234-1234-1234-123456789012",
+      .hard_binding_assertion = ParseTextProtoOrDie(R"pb(
+        label: "c2pa.hash.bmff.v3"
+        bmff_based_hash { exclusions { xpath: "" } }
+      )pb"),
+      .actions_assertion = ParseTextProtoOrDie(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"
+            parameters {
+              ingredients {
+                url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3"
+              }
+              ingredients {
+                url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3__1"
+              }
+            }
+          }
+        }
+      )pb"),
+      .ingredient_assertions =
+          {ParseTextProtoOrDie(R"pb(
+             label: "c2pa.ingredient.v3"
+             ingredient_v3 {
+               title: "test ingredient 1"
+               media_type: "video/mp4"
+               relationship: "inputTo"
+               instance_id: "test_ingredient"
+               description: "test ingredient"
+               informational_uri: "https://test.com/ingredient"
+             }
+           )pb"),
+           ParseTextProtoOrDie(R"pb(
+             label: "c2pa.ingredient.v3__1"
+             ingredient_v3 {
+               title: "test ingredient 2"
+               media_type: "video/mp4"
+               relationship: "inputTo"
+               instance_id: "test_ingredient"
+               description: "test ingredient"
+               informational_uri: "https://test.com/ingredient"
+             }
+           )pb")},
+      .manifest_store_builder = ManifestStoreBuilder(&hasher_factory_),
+  };
+  PartialValidationResultProto expected_validated_result = ParseTextProtoOrDie(
+      R"pb(
+        active_manifest {
+          label: "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a"
+          claim {
+            claim_generator_info {
+              name: "Test generator"
+              spec_version: "2.2.0"
+            }
+            signature_info {
+              issuer: "OU=Test Unit,O=Test Org,CN=Test Claim Signer"
+            }
+            created_assertions {
+              url: "self#jumbf=c2pa.assertions/c2pa.hash.bmff.v3"
+              algorithm: "sha256"
+              hash: "D\242\244\274\235o\350\352\312\307\225|C\033\204\347\311\243\202\'\203c\362\331T1a\377P\271\255\366"
+            }
+            created_assertions {
+              url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3"
+              algorithm: "sha256"
+            }
+            created_assertions {
+              url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3__1"
+              algorithm: "sha256"
+            }
+            created_assertions {
+              url: "self#jumbf=c2pa.assertions/c2pa.actions.v2"
+              algorithm: "sha256"
+              hash: "\331\271\373\3228\310\335>y\263\334\342\335\367\211Q\360\325\227\r\345$]\214\005\3047[\r#1\312"
+            }
+            default_algorithm: "sha256"
+            label: "c2pa.claim.v2"
+            instance_id: "12345678-1234-1234-1234-123456789012"
+          }
+          assertions {
+            label: "c2pa.hash.bmff.v3"
+            bmff_based_hash { exclusions { xpath: "" } }
+          }
+          assertions {
+            label: "c2pa.actions.v2"
+            actions {
+              actions {
+                action: "c2pa.created"
+                description: "created for testing"
+                digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture"
+                software_agent { name: "test generator" }
+                parameters {
+                  ingredients {
+                    url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3"
+                  }
+                  ingredients {
+                    url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3__1"
+                  }
+                }
+              }
+            }
+          }
+          assertions {
+            label: "c2pa.ingredient.v3"
+            ingredient_v3 {
+              title: "test ingredient 1"
+              media_type: "video/mp4"
+              relationship: "inputTo"
+              instance_id: "test_ingredient"
+              description: "test ingredient"
+              informational_uri: "https://test.com/ingredient"
+            }
+          }
+          assertions {
+            label: "c2pa.ingredient.v3__1"
+            ingredient_v3 {
+              title: "test ingredient 2"
+              media_type: "video/mp4"
+              relationship: "inputTo"
+              instance_id: "test_ingredient"
+              description: "test ingredient"
+              informational_uri: "https://test.com/ingredient"
+            }
+          }
+          validation {
+            successes {
+              code: "timeStamp.validated"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature"
+            }
+            successes {
+              code: "timeStamp.trusted"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature"
+            }
+            successes {
+              code: "signingCredential.trusted"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature"
+            }
+            successes {
+              code: "claimSignature.insideValidity"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature"
+            }
+            successes {
+              code: "claimSignature.validated"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature"
+            }
+            successes {
+              code: "assertion.hashedURI.match"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.hash.bmff.v3"
+            }
+            successes {
+              code: "assertion.hashedURI.match"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.actions.v2"
+            }
+            successes {
+              code: "assertion.hashedURI.match"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.ingredient.v3"
+            }
+            successes {
+              code: "assertion.hashedURI.match"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.ingredient.v3__1"
+            }
+            informationals {
+              code: "signingCredential.ocsp.skipped"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature"
+            }
+          }
+        }
+        hard_binding_uri: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.hash.bmff.v3"
+      )pb");
+  AssertionReferencer assertion_reference(&hasher_factory_);
+  LocalClaimSigner claim_signer(crypto_writer_.get(), &hasher_factory_);
+  ManifestBoxEncoder manifest_box_encoder(
+      &assertion_reference, &generator_info_, &claim_signer,
+      &assertion_cbor_encoder_, &claim_cbor_encoder_, &manifest_box,
+      hasher_factory_.algorithm());
+  absl::StatusOr<jumbf::BuiltSuperBox> built_manifest_store_box =
+      Encode(manifest_box_encoder);
+  ABSL_ASSERT_OK(built_manifest_store_box);
+  absl::Cord result_manifest_store_bytes =
+      std::move(*built_manifest_store_box).AsCord();
+  EXPECT_THAT(ValidateClaim(result_manifest_store_bytes.Flatten()),
+              IsOkAndHolds(EqualsProtoIgnoringFieldsAndOrdering(
+                  expected_validated_result.active_manifest().claim())));
+  EXPECT_THAT(ValidateManifestStore(result_manifest_store_bytes.Flatten()),
+              IsOkAndHolds(Pointee(EqualsProtoIgnoringFieldsAndOrdering(
+                  expected_validated_result))));
+}
+
+TEST_F(ManifestBoxEncoderTest, EncodeManifestWithPlacedActionIngredients) {
+  ManifestBox manifest_box = {
+      .label = "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a",
+      .instance_id = "12345678-1234-1234-1234-123456789012",
+      .hard_binding_assertion = ParseTextProtoOrDie(R"pb(
+        label: "c2pa.hash.bmff.v3"
+        bmff_based_hash { exclusions { xpath: "" } }
+      )pb"),
+      .actions_assertion = ParseTextProtoOrDie(R"pb(
+        label: "c2pa.actions.v2"
+        actions {
+          actions {
+            action: "c2pa.opened"
+            software_agent { name: "test generator" }
+            description: "created for testing"
+            parameters {
+              ingredients {
+                url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3"
+              }
+            }
+          }
+          actions {
+            action: "c2pa.placed"
+            software_agent { name: "test generator" }
+            description: "placed for testing"
+            parameters {
+              ingredients {
+                url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3__1"
+              }
+              ingredients {
+                url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3__2"
+              }
+            }
+          }
+        }
+      )pb"),
+      .ingredient_assertions =
+          {
+              ParseTextProtoOrDie(R"pb(
+                label: "c2pa.ingredient.v3"
+                ingredient_v3 {
+                  title: "test ingredient"
+                  media_type: "video/mp4"
+                  relationship: "parentOf"
+                  instance_id: "test_ingredient"
+                  description: "test ingredient"
+                  informational_uri: "https://test.com/ingredient"
+                }
+              )pb"),
+              ParseTextProtoOrDie(R"pb(
+                label: "c2pa.ingredient.v3__1"
+                ingredient_v3 {
+                  title: "test ingredient 2"
+                  media_type: "video/mp4"
+                  relationship: "componentOf"
+                  instance_id: "test_ingredient"
+                  description: "test ingredient"
+                  informational_uri: "https://test.com/ingredient"
+                }
+              )pb"),
+              ParseTextProtoOrDie(R"pb(
+                label: "c2pa.ingredient.v3__2"
+                ingredient_v3 {
+                  title: "test ingredient 3"
+                  media_type: "video/mp4"
+                  relationship: "componentOf"
+                  instance_id: "test_ingredient"
+                  description: "test ingredient"
+                  informational_uri: "https://test.com/ingredient"
+                }
+              )pb"),
+          },
+      .manifest_store_builder = ManifestStoreBuilder(&hasher_factory_),
+  };
+  PartialValidationResultProto expected_validated_result = ParseTextProtoOrDie(
+      R"pb(
+        active_manifest {
+          label: "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a"
+          claim {
+            claim_generator_info {
+              name: "Test generator"
+              spec_version: "2.2.0"
+            }
+            signature_info {
+              issuer: "OU=Test Unit,O=Test Org,CN=Test Claim Signer"
+            }
+            created_assertions {
+              url: "self#jumbf=c2pa.assertions/c2pa.actions.v2"
+              algorithm: "sha256"
+            }
+            created_assertions {
+              url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3"
+              algorithm: "sha256"
+            }
+            created_assertions {
+              url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3__1"
+              algorithm: "sha256"
+            }
+            created_assertions {
+              url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3__2"
+              algorithm: "sha256"
+            }
+            created_assertions {
+              url: "self#jumbf=c2pa.assertions/c2pa.hash.bmff.v3"
+              algorithm: "sha256"
+            }
+            default_algorithm: "sha256"
+            label: "c2pa.claim.v2"
+            instance_id: "12345678-1234-1234-1234-123456789012"
+          }
+          assertions {
+            label: "c2pa.actions.v2"
+            actions {
+              actions {
+                action: "c2pa.opened"
+                description: "created for testing"
+                software_agent { name: "test generator" }
+                parameters {
+                  ingredients {
+                    url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3"
+                  }
+                }
+              }
+              actions {
+                action: "c2pa.placed"
+                description: "placed for testing"
+                software_agent { name: "test generator" }
+                parameters {
+                  ingredients {
+                    url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3__1"
+                  }
+                  ingredients {
+                    url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3__2"
+                  }
+                }
+              }
+            }
+          }
+          assertions {
+            label: "c2pa.ingredient.v3"
+            ingredient_v3 {
+              title: "test ingredient"
+              media_type: "video/mp4"
+              relationship: "parentOf"
+              instance_id: "test_ingredient"
+              description: "test ingredient"
+              informational_uri: "https://test.com/ingredient"
+            }
+          }
+          assertions {
+            label: "c2pa.ingredient.v3__1"
+            ingredient_v3 {
+              title: "test ingredient 2"
+              media_type: "video/mp4"
+              relationship: "componentOf"
+              instance_id: "test_ingredient"
+              description: "test ingredient"
+              informational_uri: "https://test.com/ingredient"
+            }
+          }
+          assertions {
+            label: "c2pa.ingredient.v3__2"
+            ingredient_v3 {
+              title: "test ingredient 3"
+              media_type: "video/mp4"
+              relationship: "componentOf"
+              instance_id: "test_ingredient"
+              description: "test ingredient"
+              informational_uri: "https://test.com/ingredient"
+            }
+          }
+          assertions {
+            label: "c2pa.hash.bmff.v3"
+            bmff_based_hash { exclusions { xpath: "" } }
+          }
+          validation {
+            successes {
+              code: "timeStamp.validated"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature"
+            }
+            successes {
+              code: "timeStamp.trusted"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature"
+            }
+            successes {
+              code: "signingCredential.trusted"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature"
+            }
+            successes {
+              code: "claimSignature.insideValidity"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature"
+            }
+            successes {
+              code: "claimSignature.validated"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature"
+            }
+            successes {
+              code: "assertion.hashedURI.match"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.actions.v2"
+            }
+            successes {
+              code: "assertion.hashedURI.match"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.ingredient.v3"
+            }
+            successes {
+              code: "assertion.hashedURI.match"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.ingredient.v3__1"
+            }
+            successes {
+              code: "assertion.hashedURI.match"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.ingredient.v3__2"
+            }
+            successes {
+              code: "assertion.hashedURI.match"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.hash.bmff.v3"
+            }
+            informationals {
+              code: "signingCredential.ocsp.skipped"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature"
+            }
+            informationals {
+              code: "ingredient.unknownProvenance"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.ingredient.v3"
+            }
+            informationals {
+              code: "ingredient.unknownProvenance"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.ingredient.v3__1"
+            }
+            informationals {
+              code: "ingredient.unknownProvenance"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.ingredient.v3__2"
+            }
+          }
+        }
+        hard_binding_uri: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.hash.bmff.v3"
+      )pb");
+  AssertionReferencer assertion_reference(&hasher_factory_);
+  LocalClaimSigner claim_signer(crypto_writer_.get(), &hasher_factory_);
+  ManifestBoxEncoder manifest_box_encoder(
+      &assertion_reference, &generator_info_, &claim_signer,
+      &assertion_cbor_encoder_, &claim_cbor_encoder_, &manifest_box,
+      hasher_factory_.algorithm());
+  absl::StatusOr<jumbf::BuiltSuperBox> built_manifest_store_box =
+      Encode(manifest_box_encoder);
+  ABSL_ASSERT_OK(built_manifest_store_box);
+  absl::Cord result_manifest_store_bytes =
+      std::move(*built_manifest_store_box).AsCord();
+  EXPECT_THAT(ValidateClaim(result_manifest_store_bytes.Flatten()),
+              IsOkAndHolds(EqualsProtoIgnoringFieldsAndOrdering(
+                  expected_validated_result.active_manifest().claim())));
+  EXPECT_THAT(ValidateManifestStore(result_manifest_store_bytes.Flatten()),
+              IsOkAndHolds(Pointee(EqualsProtoIgnoringFieldsAndOrdering(
+                  expected_validated_result))));
+}
+
+TEST_F(ManifestBoxEncoderTest, EncodeManifestWithOpenedAction) {
+  ManifestBox manifest_box = {
+      .label = "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a",
+      .instance_id = "12345678-1234-1234-1234-123456789012",
+      .hard_binding_assertion = ParseTextProtoOrDie(R"pb(
+        label: "c2pa.hash.bmff.v3"
+        bmff_based_hash { exclusions { xpath: "" } }
+      )pb"),
+      .actions_assertion = ParseTextProtoOrDie(R"pb(
+        label: "c2pa.actions.v2"
+        actions {
+          actions {
+            action: "c2pa.opened"
+            software_agent { name: "test generator" }
+            description: "created for testing"
+            parameters {
+              ingredients {
+                url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3"
+              }
+            }
+          }
+        }
+      )pb"),
+      .ingredient_assertions =
+          {
+              ParseTextProtoOrDie(R"pb(
+                label: "c2pa.ingredient.v3"
+                ingredient_v3 {
+                  title: "test ingredient"
+                  media_type: "video/mp4"
+                  relationship: "parentOf"
+                  instance_id: "test_ingredient"
+                  description: "test ingredient"
+                  informational_uri: "https://test.com/ingredient"
+                }
+              )pb"),
+          },
+      .manifest_store_builder = ManifestStoreBuilder(&hasher_factory_),
+  };
+  PartialValidationResultProto expected_validated_result = ParseTextProtoOrDie(
+      R"pb(
+        active_manifest {
+          label: "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a"
+          claim {
+            claim_generator_info {
+              name: "Test generator"
+              spec_version: "2.2.0"
+            }
+            signature_info {
+              issuer: "OU=Test Unit,O=Test Org,CN=Test Claim Signer"
+            }
+            created_assertions {
+              url: "self#jumbf=c2pa.assertions/c2pa.actions.v2"
+              algorithm: "sha256"
+              hash: "\202\377\224T\003\374\022X)\214\244z\337\002\020Wd\351\261\236\324\205\345T\321\267u\216\227]\332\014"
+            }
+            created_assertions {
+              url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3"
+              algorithm: "sha256"
+              hash: "\300\273\\<\213\017\004\226\266\032\210!\274~\013A\274+\200\215\333[\270#\035\014\247\371\356i\006\214"
+            }
+            created_assertions {
+              url: "self#jumbf=c2pa.assertions/c2pa.hash.bmff.v3"
+              algorithm: "sha256"
+              hash: "D\242\244\274\235o\350\352\312\307\225|C\033\204\347\311\243\202\'\203c\362\331T1a\377P\271\255\366"
+            }
+            default_algorithm: "sha256"
+            label: "c2pa.claim.v2"
+            instance_id: "12345678-1234-1234-1234-123456789012"
+          }
+          assertions {
+            label: "c2pa.actions.v2"
+            actions {
+              actions {
+                action: "c2pa.opened"
+                description: "created for testing"
+                software_agent { name: "test generator" }
+                parameters {
+                  ingredients {
+                    url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3"
+                    hash: "\300\273\\<\213\017\004\226\266\032\210!\274~\013A\274+\200\215\333[\270#\035\014\247\371\356i\006\214"
+                  }
+                }
+              }
+            }
+          }
+          assertions {
+            label: "c2pa.ingredient.v3"
+            ingredient_v3 {
+              title: "test ingredient"
+              media_type: "video/mp4"
+              relationship: "parentOf"
+              instance_id: "test_ingredient"
+              description: "test ingredient"
+              informational_uri: "https://test.com/ingredient"
+            }
+          }
+          assertions {
+            label: "c2pa.hash.bmff.v3"
+            bmff_based_hash { exclusions { xpath: "" } }
+          }
+          validation {
+            successes {
+              code: "timeStamp.validated"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature"
+            }
+            successes {
+              code: "timeStamp.trusted"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature"
+            }
+            successes {
+              code: "signingCredential.trusted"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature"
+            }
+            successes {
+              code: "claimSignature.insideValidity"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature"
+            }
+            successes {
+              code: "claimSignature.validated"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature"
+            }
+            successes {
+              code: "assertion.hashedURI.match"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.actions.v2"
+            }
+            successes {
+              code: "assertion.hashedURI.match"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.ingredient.v3"
+            }
+            successes {
+              code: "assertion.hashedURI.match"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.hash.bmff.v3"
+            }
+            informationals {
+              code: "signingCredential.ocsp.skipped"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature"
+            }
+            informationals {
+              code: "ingredient.unknownProvenance"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.ingredient.v3"
+            }
+          }
+        }
+        hard_binding_uri: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.hash.bmff.v3"
+      )pb");
+  AssertionReferencer assertion_reference(&hasher_factory_);
+  LocalClaimSigner claim_signer(crypto_writer_.get(), &hasher_factory_);
+  ManifestBoxEncoder manifest_box_encoder(
+      &assertion_reference, &generator_info_, &claim_signer,
+      &assertion_cbor_encoder_, &claim_cbor_encoder_, &manifest_box,
+      hasher_factory_.algorithm());
+  absl::StatusOr<jumbf::BuiltSuperBox> built_manifest_store_box =
+      Encode(manifest_box_encoder);
+  ABSL_ASSERT_OK(built_manifest_store_box);
+  absl::Cord result_manifest_store_bytes =
+      std::move(*built_manifest_store_box).AsCord();
+  EXPECT_THAT(ValidateClaim(result_manifest_store_bytes.Flatten()),
+              IsOkAndHolds(EqualsProtoIgnoringFieldsAndOrdering(
+                  expected_validated_result.active_manifest().claim())));
+  EXPECT_THAT(ValidateManifestStore(result_manifest_store_bytes.Flatten()),
+              IsOkAndHolds(Pointee(EqualsProtoIgnoringFieldsAndOrdering(
+                  expected_validated_result))));
+}
+
+TEST_F(ManifestBoxEncoderTest, EncodeManifestWithC2paOpenedAction) {
+  FakeUuidGenerator uuid_generator;
+  SimpleManifestStoreOptions options = {
+      .uuid_generator = &uuid_generator,
+  };
+  absl::StatusOr<std::string> ingredient_manifest_store_bytes =
+      CreateSimpleManifestStoreBytes(options);
+  ABSL_ASSERT_OK(ingredient_manifest_store_bytes);
+
+  ManifestStoreBuilder manifest_store_builder(&hasher_factory_);
+  absl::StatusOr<IngredientManifestUris> ingredient_manifest_uris =
+      manifest_store_builder.AddIngredientManifestStore(
+          *ingredient_manifest_store_bytes);
+  ABSL_ASSERT_OK(ingredient_manifest_uris);
+  ManifestBox manifest_box = {
+      .label = "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a",
+      .instance_id = "03020100-0504-0706-0908-0f0e0d0c0b0a",
+      .hard_binding_assertion = ParseTextProtoOrDie(R"pb(
+        label: "c2pa.hash.bmff.v3"
+        bmff_based_hash { exclusions { xpath: "" } }
+      )pb"),
+      .actions_assertion = ParseTextProtoOrDie(R"pb(
+        label: "c2pa.actions.v2"
+        actions {
+          actions {
+            action: "c2pa.opened"
+            software_agent { name: "test generator" }
+            description: "created for testing"
+            parameters {
+              ingredients {
+                url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3"
+              }
+            }
+          }
+        }
+      )pb"),
+      .ingredient_assertions =
+          {
+              ParseTextProtoOrDie(R"pb(
+                label: "c2pa.ingredient.v3"
+                ingredient_v3 {
+                  title: "test ingredient"
+                  media_type: "video/mp4"
+                  relationship: "parentOf"
+                  instance_id: "test_ingredient"
+                  description: "test ingredient"
+                  informational_uri: "https://test.com/ingredient"
+                }
+              )pb"),
+          },
+      .manifest_store_builder = manifest_store_builder,
+  };
+  *manifest_box.ingredient_assertions[0]
+       .mutable_ingredient_v3()
+       ->mutable_active_manifest() = ingredient_manifest_uris->active_manifest;
+  manifest_box.ingredient_assertions[0]
+      .mutable_ingredient_v3()
+      ->mutable_validation_results()
+      ->mutable_active_manifest()
+      ->add_successes()
+      ->set_code("assertion.dataHash.match");
+  *manifest_box.ingredient_assertions[0]
+       .mutable_ingredient_v3()
+       ->mutable_claim_signature() = ingredient_manifest_uris->claim_signature;
+  PartialValidationResultProto expected_validated_result = ParseTextProtoOrDie(
+      R"pb(
+        active_manifest {
+          label: "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a"
+          claim {
+            claim_generator_info {
+              name: "Test generator"
+              spec_version: "2.2.0"
+            }
+            signature_info {
+              issuer: "OU=Test Unit,O=Test Org,CN=Test Claim Signer"
+            }
+            created_assertions {
+              url: "self#jumbf=c2pa.assertions/c2pa.hash.bmff.v3"
+              algorithm: "sha256"
+            }
+            created_assertions {
+              url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3"
+              algorithm: "sha256"
+            }
+            created_assertions {
+              url: "self#jumbf=c2pa.assertions/c2pa.actions.v2"
+              algorithm: "sha256"
+            }
+            default_algorithm: "sha256"
+            label: "c2pa.claim.v2"
+            instance_id: "03020100-0504-0706-0908-0f0e0d0c0b0a"
+          }
+          assertions {
+            label: "c2pa.hash.bmff.v3"
+            bmff_based_hash { exclusions { xpath: "" } }
+          }
+          assertions {
+            label: "c2pa.ingredient.v3"
+            ingredient_v3 {
+              title: "test ingredient"
+              media_type: "video/mp4"
+              relationship: "parentOf"
+              instance_id: "test_ingredient"
+              active_manifest {
+                url: "self#jumbf=/c2pa/urn:c2pa:12345678-1234-1234-1234-123456789012"
+              }
+              validation_results {
+                active_manifest {
+                  successes { code: "assertion.dataHash.match" }
+                }
+              }
+              claim_signature {
+                url: "self#jumbf=/c2pa/urn:c2pa:12345678-1234-1234-1234-123456789012/c2pa.signature"
+              }
+              description: "test ingredient"
+              informational_uri: "https://test.com/ingredient"
+            }
+          }
+          assertions {
+            label: "c2pa.actions.v2"
+            actions {
+              actions {
+                action: "c2pa.opened"
+                description: "created for testing"
+                software_agent { name: "test generator" }
+                parameters {
+                  ingredients {
+                    url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3"
+                  }
+                }
+              }
+            }
+          }
+          validation {
+            successes {
+              code: "timeStamp.validated"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature"
+            }
+            successes {
+              code: "timeStamp.trusted"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature"
+            }
+            successes {
+              code: "signingCredential.trusted"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature"
+            }
+            successes {
+              code: "claimSignature.insideValidity"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature"
+            }
+            successes {
+              code: "claimSignature.validated"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature"
+            }
+            successes {
+              code: "assertion.hashedURI.match"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.hash.bmff.v3"
+            }
+            successes {
+              code: "assertion.hashedURI.match"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.ingredient.v3"
+            }
+            successes {
+              code: "assertion.hashedURI.match"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.actions.v2"
+            }
+            successes {
+              code: "ingredient.claimSignature.validated"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.ingredient.v3"
+            }
+            informationals {
+              code: "signingCredential.ocsp.skipped"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature"
+            }
+          }
+        }
+        ingredient_manifests {
+          label: "urn:c2pa:12345678-1234-1234-1234-123456789012"
+          claim {
+            claim_generator_info { name: "Test C2PA Generator" }
+            signature_info {
+              issuer: "OU=Test Unit,O=Test Org,CN=Test Claim Signer"
+            }
+            created_assertions {
+              url: "self#jumbf=c2pa.assertions/c2pa.actions.v2"
+              algorithm: "sha256"
+            }
+            created_assertions {
+              url: "self#jumbf=c2pa.assertions/c2pa.hash.boxes"
+              algorithm: "sha256"
+            }
+            default_algorithm: "sha256"
+            label: "c2pa.claim.v2"
+            instance_id: "12345678-1234-1234-1234-123456789012"
+          }
+          assertions {
+            label: "c2pa.actions.v2"
+            actions {
+              actions {
+                action: "c2pa.created"
+                description: "created for testing"
+                digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture"
+                software_agent { name: "test generator" }
+              }
+            }
+          }
+          assertions {
+            label: "c2pa.hash.boxes"
+            boxes_hash {
+              boxes { names: "some name" names: "other name" hash: "some hash" }
+              alg: "some alg"
+            }
+          }
+          validation {
+            successes {
+              code: "timeStamp.validated"
+              url: "self#jumbf=/c2pa/urn:c2pa:12345678-1234-1234-1234-123456789012/c2pa.signature"
+            }
+            successes {
+              code: "timeStamp.trusted"
+              url: "self#jumbf=/c2pa/urn:c2pa:12345678-1234-1234-1234-123456789012/c2pa.signature"
+            }
+            successes {
+              code: "signingCredential.trusted"
+              url: "self#jumbf=/c2pa/urn:c2pa:12345678-1234-1234-1234-123456789012/c2pa.signature"
+            }
+            successes {
+              code: "claimSignature.insideValidity"
+              url: "self#jumbf=/c2pa/urn:c2pa:12345678-1234-1234-1234-123456789012/c2pa.signature"
+            }
+            successes {
+              code: "claimSignature.validated"
+              url: "self#jumbf=/c2pa/urn:c2pa:12345678-1234-1234-1234-123456789012/c2pa.signature"
+            }
+            successes {
+              code: "assertion.hashedURI.match"
+              url: "self#jumbf=/c2pa/urn:c2pa:12345678-1234-1234-1234-123456789012/c2pa.assertions/c2pa.actions.v2"
+            }
+            successes {
+              code: "assertion.hashedURI.match"
+              url: "self#jumbf=/c2pa/urn:c2pa:12345678-1234-1234-1234-123456789012/c2pa.assertions/c2pa.hash.boxes"
+            }
+            informationals {
+              code: "signingCredential.ocsp.skipped"
+              url: "self#jumbf=/c2pa/urn:c2pa:12345678-1234-1234-1234-123456789012/c2pa.signature"
+            }
+          }
+        }
+        hard_binding_uri: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.hash.bmff.v3"
+      )pb");
+  AssertionReferencer assertion_reference(&hasher_factory_);
+  LocalClaimSigner claim_signer(crypto_writer_.get(), &hasher_factory_);
+  ManifestBoxEncoder manifest_box_encoder(
+      &assertion_reference, &generator_info_, &claim_signer,
+      &assertion_cbor_encoder_, &claim_cbor_encoder_, &manifest_box,
+      hasher_factory_.algorithm());
+  absl::StatusOr<jumbf::BuiltSuperBox> built_manifest_store_box =
+      Encode(manifest_box_encoder);
+  ABSL_ASSERT_OK(built_manifest_store_box);
+  absl::Cord result_manifest_store_bytes =
+      std::move(*built_manifest_store_box).AsCord();
+  EXPECT_THAT(ValidateClaim(result_manifest_store_bytes.Flatten()),
+              IsOkAndHolds(EqualsProtoIgnoringFieldsAndOrdering(
+                  expected_validated_result.active_manifest().claim())));
+  EXPECT_THAT(ValidateManifestStore(result_manifest_store_bytes.Flatten()),
+              IsOkAndHolds(Pointee(EqualsProtoIgnoringFieldsAndOrdering(
+                  expected_validated_result))));
+}
+
+TEST_F(ManifestBoxEncoderTest, EncodeManifestWithRedactions) {
+  FakeUuidGenerator uuid_generator;
+  SimpleManifestStoreOptions options = {
+      .uuid_generator = &uuid_generator,
+  };
+  absl::StatusOr<std::string> ingredient_manifest_store_bytes =
+      CreateSimpleManifestStoreBytes(options);
+  ABSL_ASSERT_OK(ingredient_manifest_store_bytes);
+
+  ManifestStoreBuilder manifest_store_builder(&hasher_factory_);
+  absl::StatusOr<IngredientManifestUris> ingredient_manifest_uris =
+      manifest_store_builder.AddIngredientManifestStore(
+          *ingredient_manifest_store_bytes);
+  ABSL_ASSERT_OK(ingredient_manifest_uris);
+  ManifestBox manifest_box = {
+      .label = "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a",
+      .instance_id = "03020100-0504-0706-0908-0f0e0d0c0b0a",
+      .hard_binding_assertion = ParseTextProtoOrDie(R"pb(
+        label: "c2pa.hash.bmff.v3"
+        bmff_based_hash { exclusions { xpath: "" } }
+      )pb"),
+      .actions_assertion = ParseTextProtoOrDie(R"pb(
+        label: "c2pa.actions.v2"
+        actions {
+          actions {
+            action: "c2pa.opened"
+            software_agent { name: "test generator" }
+            description: "created for testing"
+            parameters {
+              ingredients {
+                url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3"
+              }
+            }
+          }
+          actions {
+            action: "c2pa.redacted"
+            description: "redacted for testing"
+          }
+        }
+      )pb"),
+      .ingredient_assertions =
+          {
+              ParseTextProtoOrDie(R"pb(
+                label: "c2pa.ingredient.v3"
+                ingredient_v3 {
+                  title: "test ingredient"
+                  media_type: "video/mp4"
+                  relationship: "parentOf"
+                  instance_id: "test_ingredient"
+                  description: "test ingredient"
+                  informational_uri: "https://test.com/ingredient"
+                }
+              )pb"),
+          },
+      .manifest_store_builder = manifest_store_builder,
+  };
+  std::string redacted_uri =
+      absl::StrJoin({ingredient_manifest_uris->active_manifest.url(),
+                     kAssertionStoreLabel, kIngredientAssertionV3Label},
+                    kManifestLabelDelimiter);
+  manifest_box.actions_assertion.mutable_actions()
+      ->mutable_actions(1)
+      ->mutable_parameters()
+      ->set_redacted_uri(redacted_uri);
+  *manifest_box.ingredient_assertions[0]
+       .mutable_ingredient_v3()
+       ->mutable_active_manifest() = ingredient_manifest_uris->active_manifest;
+  manifest_box.ingredient_assertions[0]
+      .mutable_ingredient_v3()
+      ->mutable_validation_results()
+      ->mutable_active_manifest()
+      ->add_successes()
+      ->set_code("assertion.dataHash.match");
+  *manifest_box.ingredient_assertions[0]
+       .mutable_ingredient_v3()
+       ->mutable_claim_signature() = ingredient_manifest_uris->claim_signature;
+  PartialValidationResultProto expected_validated_result = ParseTextProtoOrDie(
+      R"pb(
+        active_manifest {
+          label: "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a"
+          claim {
+            claim_generator_info {
+              name: "Test generator"
+              spec_version: "2.2.0"
+            }
+            signature_info {
+              issuer: "OU=Test Unit,O=Test Org,CN=Test Claim Signer"
+            }
+            created_assertions {
+              url: "self#jumbf=c2pa.assertions/c2pa.hash.bmff.v3"
+              algorithm: "sha256"
+            }
+            created_assertions {
+              url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3"
+              algorithm: "sha256"
+            }
+            created_assertions {
+              url: "self#jumbf=c2pa.assertions/c2pa.actions.v2"
+              algorithm: "sha256"
+            }
+            default_algorithm: "sha256"
+            label: "c2pa.claim.v2"
+            instance_id: "03020100-0504-0706-0908-0f0e0d0c0b0a"
+          }
+          assertions {
+            label: "c2pa.hash.bmff.v3"
+            bmff_based_hash { exclusions { xpath: "" } }
+          }
+          assertions {
+            label: "c2pa.ingredient.v3"
+            ingredient_v3 {
+              title: "test ingredient"
+              media_type: "video/mp4"
+              relationship: "parentOf"
+              instance_id: "test_ingredient"
+              active_manifest {
+                url: "self#jumbf=/c2pa/urn:c2pa:12345678-1234-1234-1234-123456789012"
+              }
+              validation_results {
+                active_manifest {
+                  successes { code: "assertion.dataHash.match" }
+                }
+              }
+              claim_signature {
+                url: "self#jumbf=/c2pa/urn:c2pa:12345678-1234-1234-1234-123456789012/c2pa.signature"
+              }
+              description: "test ingredient"
+              informational_uri: "https://test.com/ingredient"
+            }
+          }
+          assertions {
+            label: "c2pa.actions.v2"
+            actions {
+              actions {
+                action: "c2pa.opened"
+                description: "created for testing"
+                software_agent { name: "test generator" }
+                parameters {
+                  ingredients {
+                    url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3"
+                  }
+                }
+              }
+              actions {
+                action: "c2pa.redacted"
+                description: "redacted for testing"
+              }
+            }
+          }
+          validation {
+            successes {
+              code: "timeStamp.validated"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature"
+            }
+            successes {
+              code: "timeStamp.trusted"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature"
+            }
+            successes {
+              code: "signingCredential.trusted"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature"
+            }
+            successes {
+              code: "claimSignature.insideValidity"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature"
+            }
+            successes {
+              code: "claimSignature.validated"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature"
+            }
+            successes {
+              code: "assertion.hashedURI.match"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.hash.bmff.v3"
+            }
+            successes {
+              code: "assertion.hashedURI.match"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.ingredient.v3"
+            }
+            successes {
+              code: "assertion.hashedURI.match"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.actions.v2"
+            }
+            successes {
+              code: "ingredient.claimSignature.validated"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.ingredient.v3"
+            }
+            informationals {
+              code: "signingCredential.ocsp.skipped"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature"
+            }
+          }
+        }
+        ingredient_manifests {
+          label: "urn:c2pa:12345678-1234-1234-1234-123456789012"
+          claim {
+            claim_generator_info { name: "Test C2PA Generator" }
+            signature_info {
+              issuer: "OU=Test Unit,O=Test Org,CN=Test Claim Signer"
+            }
+            created_assertions {
+              url: "self#jumbf=c2pa.assertions/c2pa.actions.v2"
+              algorithm: "sha256"
+            }
+            created_assertions {
+              url: "self#jumbf=c2pa.assertions/c2pa.hash.boxes"
+              algorithm: "sha256"
+            }
+            default_algorithm: "sha256"
+            label: "c2pa.claim.v2"
+            instance_id: "12345678-1234-1234-1234-123456789012"
+          }
+          assertions {
+            label: "c2pa.actions.v2"
+            actions {
+              actions {
+                action: "c2pa.created"
+                description: "created for testing"
+                digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture"
+                software_agent { name: "test generator" }
+              }
+            }
+          }
+          assertions {
+            label: "c2pa.hash.boxes"
+            boxes_hash {
+              boxes { names: "some name" names: "other name" hash: "some hash" }
+              alg: "some alg"
+            }
+          }
+          validation {
+            successes {
+              code: "timeStamp.validated"
+              url: "self#jumbf=/c2pa/urn:c2pa:12345678-1234-1234-1234-123456789012/c2pa.signature"
+            }
+            successes {
+              code: "timeStamp.trusted"
+              url: "self#jumbf=/c2pa/urn:c2pa:12345678-1234-1234-1234-123456789012/c2pa.signature"
+            }
+            successes {
+              code: "signingCredential.trusted"
+              url: "self#jumbf=/c2pa/urn:c2pa:12345678-1234-1234-1234-123456789012/c2pa.signature"
+            }
+            successes {
+              code: "claimSignature.insideValidity"
+              url: "self#jumbf=/c2pa/urn:c2pa:12345678-1234-1234-1234-123456789012/c2pa.signature"
+            }
+            successes {
+              code: "claimSignature.validated"
+              url: "self#jumbf=/c2pa/urn:c2pa:12345678-1234-1234-1234-123456789012/c2pa.signature"
+            }
+            successes {
+              code: "assertion.hashedURI.match"
+              url: "self#jumbf=/c2pa/urn:c2pa:12345678-1234-1234-1234-123456789012/c2pa.assertions/c2pa.actions.v2"
+            }
+            successes {
+              code: "assertion.hashedURI.match"
+              url: "self#jumbf=/c2pa/urn:c2pa:12345678-1234-1234-1234-123456789012/c2pa.assertions/c2pa.hash.boxes"
+            }
+            informationals {
+              code: "signingCredential.ocsp.skipped"
+              url: "self#jumbf=/c2pa/urn:c2pa:12345678-1234-1234-1234-123456789012/c2pa.signature"
+            }
+          }
+        }
+        hard_binding_uri: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.hash.bmff.v3"
+      )pb");
+  expected_validated_result.mutable_active_manifest()
+      ->mutable_claim()
+      ->add_redacted_assertions(redacted_uri);
+  expected_validated_result.mutable_active_manifest()
+      ->mutable_assertions(2)
+      ->mutable_actions()
+      ->mutable_actions(1)
+      ->mutable_parameters()
+      ->set_redacted_uri(redacted_uri);
+  AssertionReferencer assertion_reference(&hasher_factory_);
+  LocalClaimSigner claim_signer(crypto_writer_.get(), &hasher_factory_);
+  ManifestBoxEncoder manifest_box_encoder(
+      &assertion_reference, &generator_info_, &claim_signer,
+      &assertion_cbor_encoder_, &claim_cbor_encoder_, &manifest_box,
+      hasher_factory_.algorithm());
+  absl::StatusOr<jumbf::BuiltSuperBox> built_manifest_store_box =
+      Encode(manifest_box_encoder);
+  ABSL_ASSERT_OK(built_manifest_store_box);
+  absl::Cord result_manifest_store_bytes =
+      std::move(*built_manifest_store_box).AsCord();
+  EXPECT_THAT(ValidateClaim(result_manifest_store_bytes.Flatten()),
+              IsOkAndHolds(EqualsProtoIgnoringFieldsAndOrdering(
+                  expected_validated_result.active_manifest().claim())));
+  EXPECT_THAT(ValidateManifestStore(result_manifest_store_bytes.Flatten()),
+              IsOkAndHolds(Pointee(EqualsProtoIgnoringFieldsAndOrdering(
+                  expected_validated_result))));
+}
+
+TEST_F(ManifestBoxEncoderTest, EncodesPartHashAssertionsWithRelativePath) {
+  ManifestBox manifest_box = {
+      .label = "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a",
+      .instance_id = "12345678-1234-1234-1234-123456789012",
+      .hard_binding_assertion = ParseTextProtoOrDie(R"pb(
+        label: "c2pa.hash.bmff.v3"
+        bmff_based_hash { exclusions { xpath: "" } }
+      )pb"),
+      .actions_assertion = ParseTextProtoOrDie(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"),
+      .part_hash_assertions = {ParseTextProtoOrDie(
+                                   R"pb(
+                                     label: "c2pa.hash.bmff.v3.part"
+                                     bmff_based_hash {
+                                       exclusions { xpath: "" }
+                                     }
+                                   )pb"),
+                               ParseTextProtoOrDie(
+                                   R"pb(
+                                     label: "c2pa.hash.bmff.v3.part__1"
+                                     bmff_based_hash {
+                                       exclusions { xpath: "" }
+                                     }
+                                   )pb")},
+      .multi_part_assertion = {ParseTextProtoOrDie(
+          R"pb(
+            label: "c2pa.hash.multi-asset"
+            multi_asset_hash {
+              parts {
+                location { byte_offset: 0 length: 10 }
+                hash_assertion {
+                  url: "self#jumbf=c2pa.assertions/c2pa.hash.bmff.v3.part"
+                  algorithm: "sha256"
+                  hash: "some hash"
+                }
+              }
+              parts {
+                location { byte_offset: 10 length: 10 }
+                hash_assertion {
+                  url: "self#jumbf=c2pa.assertions/c2pa.hash.bmff.v3.part__1"
+                  algorithm: "sha256"
+                  hash: "some hash"
+                }
+              }
+              pad: "\000\000"
+              pad2: ""
+            }
+          )pb")},
+      .ingredient_assertions = {},
+      .manifest_store_builder = ManifestStoreBuilder(&hasher_factory_),
+  };
+  PartialValidationResultProto expected_validated_result = ParseTextProtoOrDie(
+      R"pb(
+        active_manifest {
+          label: "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a"
+          claim {
+            claim_generator_info {
+              name: "Test generator"
+              spec_version: "2.2.0"
+            }
+            signature_info {
+              issuer: "OU=Test Unit,O=Test Org,CN=Test Claim Signer"
+            }
+            created_assertions {
+              url: "self#jumbf=c2pa.assertions/c2pa.actions.v2"
+              algorithm: "sha256"
+            }
+            created_assertions {
+              url: "self#jumbf=c2pa.assertions/c2pa.hash.bmff.v3"
+              algorithm: "sha256"
+            }
+            created_assertions {
+              url: "self#jumbf=c2pa.assertions/c2pa.hash.bmff.v3.part"
+              algorithm: "sha256"
+            }
+            created_assertions {
+              url: "self#jumbf=c2pa.assertions/c2pa.hash.bmff.v3.part__1"
+              algorithm: "sha256"
+            }
+            created_assertions {
+              url: "self#jumbf=c2pa.assertions/c2pa.hash.multi-asset"
+              algorithm: "sha256"
+            }
+            default_algorithm: "sha256"
+            label: "c2pa.claim.v2"
+            instance_id: "12345678-1234-1234-1234-123456789012"
+          }
+          assertions {
+            label: "c2pa.actions.v2"
+            actions {
+              actions {
+                action: "c2pa.created"
+                description: "created for testing"
+                digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture"
+                software_agent { name: "test generator" }
+              }
+            }
+          }
+          assertions {
+            label: "c2pa.hash.bmff.v3"
+            bmff_based_hash { exclusions { xpath: "" } }
+          }
+          assertions {
+            label: "c2pa.hash.bmff.v3.part"
+            bmff_based_hash { exclusions { xpath: "" } }
+          }
+          assertions {
+            label: "c2pa.hash.bmff.v3.part__1"
+            bmff_based_hash { exclusions { xpath: "" } }
+          }
+          assertions {
+            label: "c2pa.hash.multi-asset"
+            multi_asset_hash {
+              parts {
+                location { byte_offset: 0 length: 10 }
+                hash_assertion {
+                  url: "self#jumbf=c2pa.assertions/c2pa.hash.bmff.v3.part"
+                }
+              }
+              parts {
+                location { byte_offset: 10 length: 10 }
+                hash_assertion {
+                  url: "self#jumbf=c2pa.assertions/c2pa.hash.bmff.v3.part__1"
+                }
+              }
+              pad: "\000\000"
+              pad2: ""
+            }
+          }
+          validation {
+            successes {
+              code: "timeStamp.validated"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature"
+            }
+            successes {
+              code: "timeStamp.trusted"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature"
+            }
+            successes {
+              code: "signingCredential.trusted"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature"
+            }
+            successes {
+              code: "claimSignature.insideValidity"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature"
+            }
+            successes {
+              code: "claimSignature.validated"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature"
+            }
+            successes {
+              code: "assertion.hashedURI.match"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.hash.multi-asset"
+            }
+            successes {
+              code: "assertion.hashedURI.match"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.hash.bmff.v3"
+            }
+            successes {
+              code: "assertion.hashedURI.match"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.actions.v2"
+            }
+            successes {
+              code: "assertion.hashedURI.match"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.hash.bmff.v3.part"
+            }
+            successes {
+              code: "assertion.hashedURI.match"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.hash.bmff.v3.part__1"
+            }
+            informationals {
+              code: "signingCredential.ocsp.skipped"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature"
+            }
+          }
+        }
+        hard_binding_uri: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.hash.bmff.v3"
+        multi_asset_hash_uri: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.hash.multi-asset"
+      )pb");
+  AssertionReferencer assertion_reference(&hasher_factory_);
+  LocalClaimSigner claim_signer(crypto_writer_.get(), &hasher_factory_);
+  ManifestBoxEncoder manifest_box_encoder(
+      &assertion_reference, &generator_info_, &claim_signer,
+      &assertion_cbor_encoder_, &claim_cbor_encoder_, &manifest_box,
+      hasher_factory_.algorithm());
+  absl::StatusOr<jumbf::BuiltSuperBox> built_manifest_store_box =
+      Encode(manifest_box_encoder);
+  ABSL_ASSERT_OK(built_manifest_store_box);
+  absl::Cord result_manifest_store_bytes =
+      std::move(*built_manifest_store_box).AsCord();
+  EXPECT_THAT(ValidateClaim(result_manifest_store_bytes.Flatten()),
+              IsOkAndHolds(EqualsProtoIgnoringFieldsAndOrdering(
+                  expected_validated_result.active_manifest().claim())));
+  EXPECT_THAT(ValidateManifestStore(result_manifest_store_bytes.Flatten()),
+              IsOkAndHolds(Pointee(EqualsProtoIgnoringFieldsAndOrdering(
+                  expected_validated_result))));
+}
+
+TEST_F(ManifestBoxEncoderTest, EncodeManifestWithGatheredAssertions) {
+  std::string label = "com.example.gathered";
+  std::string json_data = "{\"test\": \"data\"}";
+  GatheredAssertion gathered;
+  gathered.set_label(label);
+  gathered.set_data(json_data);
+  gathered.set_content_type(GatheredAssertion::CONTENT_TYPE_JSON);
+
+  ManifestBox manifest_box = {
+      .label = "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a",
+      .instance_id = "test_instance_id",
+      .hard_binding_assertion = ParseTextProtoOrDie(R"pb(
+        label: "c2pa.hash.bmff.v3"
+        bmff_based_hash { exclusions { xpath: "" } }
+      )pb"),
+      .actions_assertion = ParseTextProtoOrDie(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"),
+      .ingredient_assertions = {},
+      .gathered_assertions = {gathered},
+      .manifest_store_builder = ManifestStoreBuilder(&hasher_factory_),
+  };
+
+  AssertionReferencer assertion_reference(&hasher_factory_);
+  LocalClaimSigner claim_signer(crypto_writer_.get(), &hasher_factory_);
+  ManifestBoxEncoder manifest_box_encoder(
+      &assertion_reference, &generator_info_, &claim_signer,
+      &assertion_cbor_encoder_, &claim_cbor_encoder_, &manifest_box,
+      hasher_factory_.algorithm());
+
+  absl::StatusOr<jumbf::BuiltSuperBox> built_manifest_store_box =
+      Encode(manifest_box_encoder);
+  ABSL_ASSERT_OK(built_manifest_store_box);
+  absl::Cord result_manifest_store_bytes =
+      std::move(*built_manifest_store_box).AsCord();
+  absl::StatusOr<Claim> claim =
+      ValidateClaim(result_manifest_store_bytes.Flatten());
+  ABSL_ASSERT_OK(claim);
+
+  ASSERT_EQ(claim->gathered_assertions_size(), 1);
+  EXPECT_EQ(claim->gathered_assertions(0).url(),
+            "self#jumbf=c2pa.assertions/com.example.gathered");
+
+  absl::StatusOr<std::unique_ptr<PartialValidationResultProto>> validated =
+      ValidateManifestStore(result_manifest_store_bytes.Flatten());
+  ABSL_ASSERT_OK(validated);
+  ASSERT_TRUE((*validated)->has_active_manifest());
+
+  bool found_match = false;
+  for (const auto& success :
+       (*validated)->active_manifest().validation().successes()) {
+    if (success.code() == "assertion.hashedURI.match" &&
+        success.url() ==
+            "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/"
+            "c2pa.assertions/com.example.gathered") {
+      found_match = true;
+      break;
+    }
+  }
+  EXPECT_TRUE(found_match);
+}
+
+TEST_F(ManifestBoxEncoderTest,
+       EncodeManifestWithInvalidGatheredAssertionContentTypeInvalid) {
+  GatheredAssertion gathered;
+  gathered.set_label("com.example.gathered");
+  gathered.set_data("{}");
+  gathered.set_content_type(GatheredAssertion::CONTENT_TYPE_UNSPECIFIED);
+
+  ManifestBox manifest_box = {
+      .label = "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a",
+      .instance_id = "test_instance_id",
+      .hard_binding_assertion = ParseTextProtoOrDie(R"pb(
+        label: "c2pa.hash.bmff.v3"
+        bmff_based_hash { exclusions { xpath: "" } }
+      )pb"),
+      .actions_assertion = ParseTextProtoOrDie(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"),
+      .ingredient_assertions = {},
+      .gathered_assertions = {gathered},
+      .manifest_store_builder = ManifestStoreBuilder(&hasher_factory_),
+  };
+
+  AssertionReferencer assertion_reference(&hasher_factory_);
+  LocalClaimSigner claim_signer(crypto_writer_.get(), &hasher_factory_);
+  ManifestBoxEncoder manifest_box_encoder(
+      &assertion_reference, &generator_info_, &claim_signer,
+      &assertion_cbor_encoder_, &claim_cbor_encoder_, &manifest_box,
+      hasher_factory_.algorithm());
+
+  EXPECT_THAT(Encode(manifest_box_encoder),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("content type must be JSON")));
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/claim/manifest_store_builder.cc b/claim/manifest_store_builder.cc
new file mode 100644
index 0000000..629b1a6
--- /dev/null
+++ b/claim/manifest_store_builder.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 "claim/manifest_store_builder.h"
+
+#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/cord.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/string_view.h"
+#include "claim/manifest_store_view.h"
+#include "constants/labels.h"
+#include "crypto/hash.h"
+#include "jumbf/box.h"
+#include "jumbf/box_builder.h"
+#include "jumbf/encode_params.h"
+#include "jumbf/utils.h"
+#include "proto/hashed_uri.pb.h"
+
+namespace credentio {
+
+namespace {
+bool IsManifestBox(const jumbf::SuperBox& super_box) {
+  return super_box.description.type_uuid == kStandardManifestUuid ||
+         super_box.description.type_uuid == kCompressedManifestUuid ||
+         super_box.description.type_uuid == kUpdateManifestUuid ||
+         super_box.description.type_uuid == kTimestampManifestUuid;
+}
+
+absl::StatusOr<HashedUri> CreateHashedUriForBox(
+    absl::string_view url, absl::string_view box_raw_bytes,
+    const HasherFactory& hasher_factory) {
+  ABSL_ASSIGN_OR_RETURN(absl::string_view content,
+                        jumbf::StripBoxHeaders(box_raw_bytes));
+  ABSL_ASSIGN_OR_RETURN(auto hasher, hasher_factory.Create());
+  hasher->Update(content);
+  HashedUri hashed_uri;
+  hashed_uri.set_url(url);
+  hashed_uri.set_hash(hasher->Digest());
+  return hashed_uri;
+}
+
+}  // namespace
+
+absl::StatusOr<IngredientManifestUris>
+ManifestStoreBuilder::AddIngredientManifestStore(
+    absl::string_view manifest_store_bytes) {
+  ABSL_ASSIGN_OR_RETURN(auto manifest_store_view,
+                        ManifestStoreView::Create(manifest_store_bytes));
+  ABSL_ASSIGN_OR_RETURN(auto active_manifest_view,
+                        manifest_store_view.GetActiveManifest());
+  std::string active_manifest_url =
+      absl::StrCat(kManifestStoreUrlPrefix, active_manifest_view.label());
+
+  IngredientManifestUris ingredient_manifest_uris;
+  ABSL_ASSIGN_OR_RETURN(
+      ingredient_manifest_uris.active_manifest,
+      CreateHashedUriForBox(active_manifest_url,
+                            active_manifest_view.superbox().raw_bytes,
+                            hasher_factory_));
+
+  ABSL_ASSIGN_OR_RETURN(auto claim_signature_view,
+                        active_manifest_view.GetClaimSignature());
+  ABSL_ASSIGN_OR_RETURN(
+      ingredient_manifest_uris.claim_signature,
+      CreateHashedUriForBox(
+          absl::StrCat(active_manifest_url, kManifestLabelDelimiter,
+                       kClaimSignatureLabel),
+          claim_signature_view.superbox().raw_bytes, hasher_factory_));
+
+  ingredient_manifest_stores_.push_back(manifest_store_view.superbox());
+  return ingredient_manifest_uris;
+}
+
+absl::StatusOr<jumbf::BuiltSuperBox> ManifestStoreBuilder::Finalize(
+    jumbf::BuiltSuperBox&& active_manifest) && {
+  std::vector<absl::Cord> ingredient_manifests;
+  for (const jumbf::SuperBox& manifest_store : ingredient_manifest_stores_) {
+    for (const jumbf::ContentBox& box : manifest_store.contents) {
+      if (!box.Holds<jumbf::SuperBox>()) {
+        continue;
+      }
+      const jumbf::SuperBox& super_box = box.Get<jumbf::SuperBox>();
+      if (!IsManifestBox(super_box)) {
+        continue;
+      }
+      if (!super_box.description.label.has_value()) {
+        continue;
+      }
+      ManifestInfo& manifest_info =
+          manifest_info_[*super_box.description.label];
+      if (manifest_info.copied_to_manifest_store) {
+        continue;
+      }
+      ingredient_manifests.emplace_back(super_box.raw_bytes);
+      manifest_info.copied_to_manifest_store = true;
+    }
+  }
+  if (!active_manifest.description_label().has_value()) {
+    return absl::InvalidArgumentError("active manifest label is missing");
+  }
+  if (manifest_info_[*active_manifest.description_label()]
+          .copied_to_manifest_store) {
+    // Shouldn't happen since manifest labels are generated randomly and
+    // the active manifest can't be an ingredient of itself.
+    return absl::InternalError(
+        "active manifest label is also an ingredient manifest label");
+  }
+  jumbf::SuperBoxBuilder manifest_store_builder(
+      kManifestStoreUuid,
+      jumbf::DescriptionBoxOptions{.label = absl::Cord(kManifestStoreLabel)});
+  // SuperBoxBuilder reverses the order of boxes added via AddChild when
+  // finalized. We iterate in reverse order to preserve the order of the
+  // ingredient manifests in the new manifest store.
+  ABSL_RETURN_IF_ERROR(
+      manifest_store_builder.AddChild(std::move(active_manifest)));
+  for (int i = ingredient_manifests.size() - 1; i >= 0; --i) {
+    ABSL_RETURN_IF_ERROR(
+        manifest_store_builder.AddChild(std::move(ingredient_manifests[i])));
+  }
+  return std::move(manifest_store_builder).Finalize();
+}
+}  // namespace credentio
diff --git a/claim/manifest_store_builder.h b/claim/manifest_store_builder.h
new file mode 100644
index 0000000..b667d09
--- /dev/null
+++ b/claim/manifest_store_builder.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_CLAIM_MANIFEST_STORE_BUILDER_H_
+#define THIRD_PARTY_CREDENTIO_CLAIM_MANIFEST_STORE_BUILDER_H_
+
+#include <string>
+#include <vector>
+
+#include "absl/container/flat_hash_map.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 "crypto/hash.h"
+#include "jumbf/box.h"
+#include "jumbf/box_builder.h"
+#include "proto/hashed_uri.pb.h"
+
+namespace credentio {
+
+// Struct holding the HashedUris for an ingredient's active manifest.
+struct IngredientManifestUris {
+  HashedUri active_manifest;
+  HashedUri claim_signature;
+};
+
+// Builder for a manifest store.
+class ManifestStoreBuilder {
+ public:
+  explicit ManifestStoreBuilder(const HasherFactory* hasher_factory)
+      : hasher_factory_(*ABSL_DIE_IF_NULL(hasher_factory)) {};
+
+  // Adds an ingredient manifest store via its serialized bytes.
+  // Returns the HashedUris computed on the ingredient's active manifest.
+  absl::StatusOr<IngredientManifestUris> AddIngredientManifestStore(
+      absl::string_view manifest_store_bytes);
+
+  // Finish writing the manifest store. This consumes the builder, use
+  // `std::move(builder).Finalize()`;
+  absl::StatusOr<jumbf::BuiltSuperBox> Finalize(
+      jumbf::BuiltSuperBox&& active_manifest) &&;
+
+ private:
+  struct ManifestInfo {
+    bool copied_to_manifest_store = false;  // Already copied by Finalize?
+  };
+
+  absl::flat_hash_map<std::string, ManifestInfo> manifest_info_;
+  // Factory for creating hashers. Used to compute HashedUris.
+  const HasherFactory& hasher_factory_;
+  // Ingredient manifests added to the manifest store.
+  std::vector<jumbf::SuperBox> ingredient_manifest_stores_;
+};
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_CLAIM_MANIFEST_STORE_BUILDER_H_
diff --git a/claim/manifest_store_builder_test.cc b/claim/manifest_store_builder_test.cc
new file mode 100644
index 0000000..bced268
--- /dev/null
+++ b/claim/manifest_store_builder_test.cc
@@ -0,0 +1,378 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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_builder.h"
+
+#include <memory>
+#include <optional>
+#include <string>
+#include <utility>
+#include <vector>
+
+#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/cord.h"
+#include "absl/strings/string_view.h"
+#include "assertion/assertion_encoder.h"
+#include "constants/labels.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/box_builder.h"
+#include "jumbf/encode_params.h"
+#include "jumbf/parse.h"
+#include "jumbf/test_utils.h"
+#include "proto/assertion.pb.h"
+#include "proto/hashed_uri.pb.h"
+#include "testing/proto_test_utils.h"
+#include "uuid/uuid.h"
+
+namespace credentio {
+namespace {
+
+using ::absl_testing::IsOkAndHolds;
+using ::absl_testing::StatusIs;
+using ::credentio_testing::EqualsProto;
+using ::credentio_testing::ParseTextProtoOrDie;
+using ::testing::ElementsAre;
+using ::testing::HasSubstr;
+
+inline constexpr Uuid kUnknownUuid(
+    Uuid::FromStringOrDie("00000000-0000-0000-0000-000000000000"));
+
+class FakeHasherFactory : public HasherFactory {
+ public:
+  absl::StatusOr<std::unique_ptr<Hasher>> Create() const override {
+    return CreateHasher("sha256");
+  }
+  HashAlgorithm algorithm() const override { return HashAlgorithm::kSha256; }
+};
+
+bool IsManifestStore(const jumbf::SuperBox& superbox) {
+  return superbox.description.label == kManifestStoreLabel &&
+         superbox.description.type_uuid == kManifestStoreUuid;
+}
+
+bool IsManifest(const jumbf::SuperBox& superbox) {
+  const auto& uuid = superbox.description.type_uuid;
+  return uuid == kStandardManifestUuid || uuid == kUpdateManifestUuid ||
+         uuid == kCompressedManifestUuid || uuid == kTimestampManifestUuid;
+}
+
+absl::StatusOr<std::vector<std::string>> ExtractManifestLabels(
+    absl::string_view manifest_store) {
+  std::vector<std::string> labels;
+  ABSL_ASSIGN_OR_RETURN(
+      auto ms_superbox,
+      jumbf::ConsumeSuperBox(&manifest_store, /*recursion_limit=*/9));
+  if (!IsManifestStore(ms_superbox)) {
+    return absl::NotFoundError(
+        "JUMBF SuperBox does not contain a manifest store");
+  }
+  for (const auto& content : ms_superbox.contents) {
+    if (content.Holds<jumbf::SuperBox>()) {
+      const auto& superbox = content.Get<jumbf::SuperBox>();
+      if (IsManifest(superbox)) {
+        labels.push_back(std::string(superbox.description.label.value_or("")));
+      }
+    }
+  }
+  return labels;
+}
+
+class ManifestStoreBuilderTest : public ::testing::Test {
+ protected:
+  ManifestStoreBuilderTest() : hasher_factory_(), builder_(&hasher_factory_) {};
+  FakeHasherFactory hasher_factory_;
+  ManifestStoreBuilder builder_;
+  StandardAssertionEncoder cbor_encoder_;
+};
+
+TEST_F(ManifestStoreBuilderTest, AddActiveManifest) {
+  jumbf::SuperBoxBuilder active_manifest_builder(
+      kStandardManifestUuid,
+      jumbf::DescriptionBoxOptions{.label = absl::Cord("manifest_1")});
+  std::string cbor_box_1 = jumbf::EncodeCborBox("cbor_box_1");
+  ABSL_EXPECT_OK(active_manifest_builder.AddChild(absl::Cord(cbor_box_1)));
+  auto active_manifest_or = std::move(active_manifest_builder).Finalize();
+  ABSL_ASSERT_OK(active_manifest_or);
+  jumbf::BuiltSuperBox active_manifest = *std::move(active_manifest_or);
+
+  jumbf::SuperBoxBuilder expected_manifest_builder(
+      kStandardManifestUuid,
+      jumbf::DescriptionBoxOptions{.label = absl::Cord("manifest_1")});
+  std::string expected_cbor_box_1 = jumbf::EncodeCborBox("cbor_box_1");
+  ABSL_EXPECT_OK(expected_manifest_builder.AddChild(absl::Cord(cbor_box_1)));
+  auto expected_manifest_or = std::move(expected_manifest_builder).Finalize();
+  ABSL_ASSERT_OK(expected_manifest_or);
+  jumbf::BuiltSuperBox expected_manifest = *std::move(expected_manifest_or);
+  absl::Cord expected_manifest_cord = std::move(expected_manifest).AsCord();
+
+  auto manifest_store_or =
+      std::move(builder_).Finalize(std::move(active_manifest));
+  ABSL_ASSERT_OK(manifest_store_or);
+  jumbf::BuiltSuperBox manifest_store = *std::move(manifest_store_or);
+  absl::Cord manifest_store_cord = std::move(manifest_store).AsCord();
+  EXPECT_THAT(manifest_store_cord, HasSubstr(expected_manifest_cord));
+}
+
+TEST_F(ManifestStoreBuilderTest, AddIngredientManifestStoreNoManifests) {
+  std::string ingredient_cbor_box_1 =
+      jumbf::EncodeCborBox("ingredient_cbor_box_1");
+  std::string ingredient_manifest_store_1 = jumbf::EncodeSuperBox(
+      kManifestStoreUuid, kManifestStoreLabel, {ingredient_cbor_box_1});
+
+  EXPECT_THAT(builder_.AddIngredientManifestStore(ingredient_manifest_store_1),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("No active manifest found")));
+}
+
+TEST_F(ManifestStoreBuilderTest, AddIngredientManifestStoreNoClaimSignature) {
+  std::string ingredient_cbor_box_1 =
+      jumbf::EncodeCborBox("ingredient_cbor_box_1");
+  std::string ingredient_manifest_1 = jumbf::EncodeSuperBox(
+      kStandardManifestUuid, "ingredient_manifest_1", {ingredient_cbor_box_1});
+  std::string ingredient_manifest_store_1 = jumbf::EncodeSuperBox(
+      kManifestStoreUuid, kManifestStoreLabel, {ingredient_manifest_1});
+
+  EXPECT_THAT(builder_.AddIngredientManifestStore(ingredient_manifest_store_1),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("No claim signature found in the manifest")));
+}
+
+TEST_F(ManifestStoreBuilderTest, AddIngredientManifestStore) {
+  jumbf::SuperBoxBuilder active_manifest_builder(
+      kStandardManifestUuid,
+      jumbf::DescriptionBoxOptions{.label = absl::Cord("active_manifest")});
+  std::string active_cbor_box = jumbf::EncodeCborBox("active_cbor_box");
+  ABSL_EXPECT_OK(active_manifest_builder.AddChild(absl::Cord(active_cbor_box)));
+  auto active_manifest_or = std::move(active_manifest_builder).Finalize();
+  ABSL_ASSERT_OK(active_manifest_or);
+  jumbf::BuiltSuperBox active_manifest = *std::move(active_manifest_or);
+  std::string ingredient_cbor_box_1 =
+      jumbf::EncodeCborBox("ingredient_cbor_box_1");
+  std::string ingredient_signature_box_1 = jumbf::EncodeSuperBox(
+      kClaimSignatureUuid, kClaimSignatureLabel, {ingredient_cbor_box_1});
+  std::string ingredient_manifest_1 =
+      jumbf::EncodeSuperBox(kStandardManifestUuid, "ingredient_manifest_1",
+                            {ingredient_signature_box_1});
+  std::string ingredient_active_cbor_box_1 =
+      jumbf::EncodeCborBox("ingredient_cbor_box_1");
+  std::string ingredient_active_signature_box_1 =
+      jumbf::EncodeSuperBox(kClaimSignatureUuid, kClaimSignatureLabel,
+                            {ingredient_active_cbor_box_1});
+  std::string ingredient_active_manifest_1 =
+      jumbf::EncodeSuperBox(kUpdateManifestUuid, "ingredient_active_manifest_1",
+                            {ingredient_active_signature_box_1});
+  std::string ingredient_unknown_box_1 = jumbf::EncodeSuperBox(
+      kUnknownUuid, "ingredient_unknown_box_1", {ingredient_cbor_box_1});
+  std::string ingredient_manifest_store_1 = jumbf::EncodeSuperBox(
+      kManifestStoreUuid, kManifestStoreLabel,
+      {ingredient_manifest_1, ingredient_active_manifest_1,
+       ingredient_cbor_box_1, ingredient_unknown_box_1});
+  std::string ingredient_cbor_box_2 =
+      jumbf::EncodeCborBox("ingredient_cbor_box_2");
+  std::string ingredient_signature_box_2 = jumbf::EncodeSuperBox(
+      kClaimSignatureUuid, kClaimSignatureLabel, {ingredient_cbor_box_2});
+  std::string ingredient_manifest_2 =
+      jumbf::EncodeSuperBox(kCompressedManifestUuid, "ingredient_manifest_2",
+                            {ingredient_signature_box_2});
+  std::string ingredient_active_cbor_box_2 =
+      jumbf::EncodeCborBox("ingredient_cbor_box_2");
+  std::string ingredient_active_signature_box_2 =
+      jumbf::EncodeSuperBox(kClaimSignatureUuid, kClaimSignatureLabel,
+                            {ingredient_active_cbor_box_2});
+  std::string ingredient_active_manifest_2 = jumbf::EncodeSuperBox(
+      kTimestampManifestUuid, "ingredient_active_manifest_2",
+      {ingredient_active_signature_box_2});
+  std::string ingredient_manifest_store_2 = jumbf::EncodeSuperBox(
+      kManifestStoreUuid, kManifestStoreLabel,
+      {ingredient_manifest_2, ingredient_active_manifest_2});
+
+  jumbf::SuperBoxBuilder expected_manifest_builder(
+      kStandardManifestUuid,
+      jumbf::DescriptionBoxOptions{.label = absl::Cord("active_manifest")});
+  std::string expected_cbor_box = jumbf::EncodeCborBox("active_cbor_box");
+  ABSL_EXPECT_OK(
+      expected_manifest_builder.AddChild(absl::Cord(expected_cbor_box)));
+  auto expected_manifest_or = std::move(expected_manifest_builder).Finalize();
+  ABSL_ASSERT_OK(expected_manifest_or);
+  jumbf::BuiltSuperBox expected_manifest = *std::move(expected_manifest_or);
+  absl::Cord expected_manifest_cord = std::move(expected_manifest).AsCord();
+  std::string expected_manifest_store = jumbf::EncodeSuperBox(
+      kManifestStoreUuid, kManifestStoreLabel,
+      {ingredient_manifest_1, ingredient_active_manifest_1,
+       ingredient_manifest_2, ingredient_active_manifest_2,
+       std::string(expected_manifest_cord)},
+      /*requestable=*/true);
+
+  auto uris_1_or =
+      builder_.AddIngredientManifestStore(ingredient_manifest_store_1);
+  ABSL_ASSERT_OK(uris_1_or);
+  IngredientManifestUris ingredient_manifest_uris_1 = *std::move(uris_1_or);
+  auto expected_active_manifest_uri_1 = ParseTextProtoOrDie<HashedUri>(
+      R"pb(
+        url: "self#jumbf=/c2pa/ingredient_active_manifest_1"
+        hash: "=s\030\315\353\317\241\271c\342&\233\315gV72\270\344\200\371\"\326\237p\302\033n\223a\357\023"
+      )pb");
+  auto expected_claim_signature_uri_1 = ParseTextProtoOrDie<HashedUri>(
+      R"pb(
+        url: "self#jumbf=/c2pa/ingredient_active_manifest_1/c2pa.signature"
+        hash: "\370\347\000R\200\003\005\024\211ivc_(\243\013C\342\367\215\375\022v\206\257\023\363\251\221\270k\311"
+      )pb");
+  EXPECT_THAT(ingredient_manifest_uris_1.active_manifest,
+              EqualsProto(expected_active_manifest_uri_1));
+  EXPECT_THAT(ingredient_manifest_uris_1.claim_signature,
+              EqualsProto(expected_claim_signature_uri_1));
+  auto uris_2_or =
+      builder_.AddIngredientManifestStore(ingredient_manifest_store_2);
+  ABSL_ASSERT_OK(uris_2_or);
+  IngredientManifestUris ingredient_manifest_uris_2 = *std::move(uris_2_or);
+  auto expected_active_manifest_uri_2 = ParseTextProtoOrDie<HashedUri>(
+      R"pb(
+        url: "self#jumbf=/c2pa/ingredient_active_manifest_2"
+        hash: "\231\t\034\211\312\271D\262\317\006\314\206EAv\243\276\2335\254\357]\302\217\20208\214\360A@\""
+      )pb");
+  auto expected_claim_signature_uri_2 = ParseTextProtoOrDie<HashedUri>(
+      R"pb(
+        url: "self#jumbf=/c2pa/ingredient_active_manifest_2/c2pa.signature"
+        hash: "Q\234\255\026\232\274\373\314X\317\271N=\260`\030\355\343\024\245h\326^\306\364\205\202\367\200\233f\371"
+      )pb");
+  EXPECT_THAT(ingredient_manifest_uris_2.active_manifest,
+              EqualsProto(expected_active_manifest_uri_2));
+  EXPECT_THAT(ingredient_manifest_uris_2.claim_signature,
+              EqualsProto(expected_claim_signature_uri_2));
+  auto manifest_store_or =
+      std::move(builder_).Finalize(std::move(active_manifest));
+  ABSL_ASSERT_OK(manifest_store_or);
+  jumbf::BuiltSuperBox manifest_store = *std::move(manifest_store_or);
+  absl::Cord manifest_store_cord = std::move(manifest_store).AsCord();
+  EXPECT_THAT(std::string(manifest_store_cord), expected_manifest_store);
+}
+
+TEST_F(ManifestStoreBuilderTest, AddIngredientManifestStore_Deduplicates) {
+  jumbf::SuperBoxBuilder active_manifest_builder(
+      kStandardManifestUuid,
+      jumbf::DescriptionBoxOptions{.label = absl::Cord("active_manifest")});
+  ABSL_EXPECT_OK(active_manifest_builder.AddChild(
+      absl::Cord(jumbf::EncodeCborBox("active_cbor_box"))));
+  auto active_manifest_or = std::move(active_manifest_builder).Finalize();
+  ABSL_ASSERT_OK(active_manifest_or);
+  jumbf::BuiltSuperBox active_manifest = *std::move(active_manifest_or);
+
+  std::string signature_1 =
+      jumbf::EncodeSuperBox(kClaimSignatureUuid, kClaimSignatureLabel,
+                            {jumbf::EncodeCborBox("signature_1")});
+  std::string signature_2 =
+      jumbf::EncodeSuperBox(kClaimSignatureUuid, kClaimSignatureLabel,
+                            {jumbf::EncodeCborBox("signature_2")});
+  std::string signature_3 =
+      jumbf::EncodeSuperBox(kClaimSignatureUuid, kClaimSignatureLabel,
+                            {jumbf::EncodeCborBox("signature_3")});
+
+  std::string ingredient_manifest_1 = jumbf::EncodeSuperBox(
+      kStandardManifestUuid, "ingredient_manifest_1", {signature_1});
+  std::string ingredient_manifest_2 = jumbf::EncodeSuperBox(
+      kStandardManifestUuid, "ingredient_manifest_2", {signature_2});
+  std::string ingredient_manifest_3 = jumbf::EncodeSuperBox(
+      kStandardManifestUuid, "ingredient_manifest_3", {signature_3});
+
+  // These two ingredient manifest stores have ingredient_manifest_1 in common.
+  std::string ingredient_manifest_store_3 =
+      jumbf::EncodeSuperBox(kManifestStoreUuid, kManifestStoreLabel,
+                            {ingredient_manifest_1, ingredient_manifest_3});
+  std::string ingredient_manifest_store_2 =
+      jumbf::EncodeSuperBox(kManifestStoreUuid, kManifestStoreLabel,
+                            {ingredient_manifest_1, ingredient_manifest_2});
+
+  auto uris_2_or =
+      builder_.AddIngredientManifestStore(ingredient_manifest_store_2);
+  ABSL_ASSERT_OK(uris_2_or);
+  auto uris_3a_or =
+      builder_.AddIngredientManifestStore(ingredient_manifest_store_3);
+  ABSL_ASSERT_OK(uris_3a_or);
+  // Add the same ingredient manifest store again.
+  auto uris_3b_or =
+      builder_.AddIngredientManifestStore(ingredient_manifest_store_3);
+  ABSL_ASSERT_OK(uris_3b_or);
+
+  auto manifest_store_or =
+      std::move(builder_).Finalize(std::move(active_manifest));
+  ABSL_ASSERT_OK(manifest_store_or);
+  jumbf::BuiltSuperBox manifest_store = *std::move(manifest_store_or);
+  absl::Cord manifest_store_cord = std::move(manifest_store).AsCord();
+  EXPECT_THAT(
+      ExtractManifestLabels(manifest_store_cord.Flatten()),
+      IsOkAndHolds(ElementsAre("ingredient_manifest_1", "ingredient_manifest_2",
+                               "ingredient_manifest_3", "active_manifest")));
+}
+
+TEST_F(ManifestStoreBuilderTest, FinalizeFailsWhenActiveManifestIsIngredient) {
+  jumbf::SuperBoxBuilder active_manifest_builder(
+      kStandardManifestUuid,
+      jumbf::DescriptionBoxOptions{.label = absl::Cord("active_manifest")});
+  ABSL_EXPECT_OK(active_manifest_builder.AddChild(
+      absl::Cord(jumbf::EncodeCborBox("active_cbor_box"))));
+  auto active_manifest_or = std::move(active_manifest_builder).Finalize();
+  ABSL_ASSERT_OK(active_manifest_or);
+  jumbf::BuiltSuperBox active_manifest = *std::move(active_manifest_or);
+
+  std::string signature_1 =
+      jumbf::EncodeSuperBox(kClaimSignatureUuid, kClaimSignatureLabel,
+                            {jumbf::EncodeCborBox("signature_1")});
+  std::string signature_2 =
+      jumbf::EncodeSuperBox(kClaimSignatureUuid, kClaimSignatureLabel,
+                            {jumbf::EncodeCborBox("signature_2")});
+  std::string ingredient_manifest_1 = jumbf::EncodeSuperBox(
+      kStandardManifestUuid, "ingredient_manifest", {signature_1});
+  std::string ingredient_manifest_with_active_manifest_label =
+      jumbf::EncodeSuperBox(kStandardManifestUuid, "active_manifest",
+                            {signature_2});
+  std::string ingredient_manifest_store = jumbf::EncodeSuperBox(
+      kManifestStoreUuid, kManifestStoreLabel,
+      {ingredient_manifest_1, ingredient_manifest_with_active_manifest_label});
+
+  ABSL_ASSERT_OK(
+      builder_.AddIngredientManifestStore(ingredient_manifest_store));
+
+  EXPECT_THAT(
+      std::move(builder_).Finalize(std::move(active_manifest)),
+      StatusIs(
+          absl::StatusCode::kInternal,
+          HasSubstr(
+              "active manifest label is also an ingredient manifest label")));
+}
+
+TEST_F(ManifestStoreBuilderTest, FinalizeFailsWhenActiveManifestHasNoLabel) {
+  jumbf::SuperBoxBuilder active_manifest_builder(
+      kStandardManifestUuid, jumbf::DescriptionBoxOptions{});
+  ABSL_EXPECT_OK(active_manifest_builder.AddChild(
+      absl::Cord(jumbf::EncodeCborBox("active_cbor_box"))));
+  auto active_manifest_or = std::move(active_manifest_builder).Finalize();
+  ABSL_ASSERT_OK(active_manifest_or);
+  jumbf::BuiltSuperBox active_manifest = *std::move(active_manifest_or);
+  EXPECT_THAT(std::move(builder_).Finalize(std::move(active_manifest)),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("active manifest label is missing")));
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/claim/manifest_store_view_test.cc b/claim/manifest_store_view_test.cc
index c8e0b3d..63f2884 100644
--- a/claim/manifest_store_view_test.cc
+++ b/claim/manifest_store_view_test.cc
@@ -31,7 +31,6 @@
 namespace credentio {
 namespace {
 
-using ::absl_testing::IsOk;
 using ::absl_testing::IsOkAndHolds;
 using ::absl_testing::StatusIs;
 using ::testing::HasSubstr;
@@ -58,9 +57,9 @@
   std::string manifest_store = jumbf::EncodeSuperBox(
       kManifestStoreUuid, kManifestStoreLabel, {manifest});
   auto view = ManifestStoreView::Create(manifest_store);
-  ASSERT_THAT(view, IsOk());
+  ABSL_ASSERT_OK(view);
   auto manifest_view = view->GetActiveManifest();
-  ASSERT_THAT(manifest_view, IsOk());
+  ABSL_ASSERT_OK(manifest_view);
   EXPECT_EQ(manifest_view->label(), "manifest1");
 }
 
@@ -75,9 +74,9 @@
       jumbf::EncodeSuperBox(kManifestStoreUuid, kManifestStoreLabel,
                             {manifest1, manifest2, manifest3});
   auto view = ManifestStoreView::Create(manifest_store);
-  ASSERT_THAT(view, IsOk());
+  ABSL_ASSERT_OK(view);
   auto manifest_view = view->GetActiveManifest();
-  ASSERT_THAT(manifest_view, IsOk());
+  ABSL_ASSERT_OK(manifest_view);
   EXPECT_EQ(manifest_view->label(), "manifest3");
 }
 
@@ -96,9 +95,9 @@
       jumbf::EncodeSuperBox(kManifestStoreUuid, kManifestStoreLabel,
                             {manifest1, other_box1, manifest2, other_box2});
   auto view = ManifestStoreView::Create(manifest_store);
-  ASSERT_THAT(view, IsOk());
+  ABSL_ASSERT_OK(view);
   auto manifest_view = view->GetActiveManifest();
-  ASSERT_THAT(manifest_view, IsOk());
+  ABSL_ASSERT_OK(manifest_view);
   EXPECT_EQ(manifest_view->label(), "manifest2");
 }
 
@@ -112,7 +111,7 @@
   std::string manifest_store = jumbf::EncodeSuperBox(
       kManifestStoreUuid, kManifestStoreLabel, {other_box1, other_box2});
   auto view = ManifestStoreView::Create(manifest_store);
-  ASSERT_THAT(view, IsOk());
+  ABSL_ASSERT_OK(view);
   EXPECT_THAT(view->GetActiveManifest(),
               StatusIs(absl::StatusCode::kInvalidArgument,
                        HasSubstr("No active manifest found")));
@@ -127,7 +126,7 @@
       jumbf::EncodeSuperBox(kManifestStoreUuid, kManifestStoreLabel,
                             {standard_manifest, compressed_manifest});
   auto view = ManifestStoreView::Create(manifest_store);
-  ASSERT_THAT(view, IsOk());
+  ABSL_ASSERT_OK(view);
   EXPECT_THAT(view->GetActiveManifest(),
               StatusIs(absl::StatusCode::kInvalidArgument,
                        HasSubstr("Compressed manifests are not supported")));
@@ -151,9 +150,9 @@
   std::string manifest_store = jumbf::EncodeSuperBox(
       kManifestStoreUuid, kManifestStoreLabel, {manifest});
   auto view = ManifestStoreView::Create(manifest_store);
-  ASSERT_THAT(view, IsOk());
+  ABSL_ASSERT_OK(view);
   auto manifest_view = view->GetActiveManifest();
-  ASSERT_THAT(manifest_view, IsOk());
+  ABSL_ASSERT_OK(manifest_view);
   auto result = manifest_view->GetClaimSignature();
   EXPECT_THAT(result.status(),
               StatusIs(absl::StatusCode::kInvalidArgument,
@@ -168,9 +167,9 @@
   std::string manifest_store = jumbf::EncodeSuperBox(
       kManifestStoreUuid, kManifestStoreLabel, {manifest});
   auto view = ManifestStoreView::Create(manifest_store);
-  ASSERT_THAT(view, IsOk());
+  ABSL_ASSERT_OK(view);
   auto manifest_view = view->GetActiveManifest();
-  ASSERT_THAT(manifest_view, IsOk());
+  ABSL_ASSERT_OK(manifest_view);
   auto result = manifest_view->GetClaimSignature();
   EXPECT_THAT(result.status(),
               StatusIs(absl::StatusCode::kInvalidArgument,
@@ -196,11 +195,11 @@
   std::string manifest_store = jumbf::EncodeSuperBox(
       kManifestStoreUuid, kManifestStoreLabel, {manifest});
   auto view = ManifestStoreView::Create(manifest_store);
-  ASSERT_THAT(view, IsOk());
+  ABSL_ASSERT_OK(view);
   auto manifest_view = view->GetActiveManifest();
-  ASSERT_THAT(manifest_view, IsOk());
+  ABSL_ASSERT_OK(manifest_view);
   auto claim_signature_view = manifest_view->GetClaimSignature();
-  ASSERT_THAT(claim_signature_view, IsOk());
+  ABSL_ASSERT_OK(claim_signature_view);
   EXPECT_THAT(claim_signature_view->GetPayload(),
               StatusIs(absl::StatusCode::kInvalidArgument,
                        HasSubstr("Invalid claim signature box")));
@@ -214,11 +213,11 @@
   std::string manifest_store = jumbf::EncodeSuperBox(
       kManifestStoreUuid, kManifestStoreLabel, {manifest});
   auto view = ManifestStoreView::Create(manifest_store);
-  ASSERT_THAT(view, IsOk());
+  ABSL_ASSERT_OK(view);
   auto manifest_view = view->GetActiveManifest();
-  ASSERT_THAT(manifest_view, IsOk());
+  ABSL_ASSERT_OK(manifest_view);
   auto claim_signature_view = manifest_view->GetClaimSignature();
-  ASSERT_THAT(claim_signature_view, IsOk());
+  ABSL_ASSERT_OK(claim_signature_view);
   EXPECT_THAT(claim_signature_view->GetPayload(), IsOkAndHolds("abc"));
 }
 
@@ -237,11 +236,11 @@
   std::string manifest_store = jumbf::EncodeSuperBox(
       kManifestStoreUuid, kManifestStoreLabel, {manifest});
   auto view = ManifestStoreView::Create(manifest_store);
-  ASSERT_THAT(view, IsOk());
+  ABSL_ASSERT_OK(view);
   auto manifest_view = view->GetActiveManifest();
-  ASSERT_THAT(manifest_view, IsOk());
+  ABSL_ASSERT_OK(manifest_view);
   auto claim_signature_view = manifest_view->GetClaimSignature();
-  ASSERT_THAT(claim_signature_view, IsOk());
+  ABSL_ASSERT_OK(claim_signature_view);
   EXPECT_THAT(claim_signature_view->GetStruct(), IsOkAndHolds(cose_sign1));
 }
 
diff --git a/claim/signer.h b/claim/signer.h
new file mode 100644
index 0000000..158c2e6
--- /dev/null
+++ b/claim/signer.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_CLAIM_SIGNER_H_
+#define THIRD_PARTY_CREDENTIO_CLAIM_SIGNER_H_
+
+#include <optional>
+#include <string>
+
+#include "absl/functional/any_invocable.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "async/async_context.h"
+
+namespace credentio {
+
+enum class TimeStampingMode {
+  kOptional,
+  kRequired,
+  kOmittedWithPadding,
+};
+
+// Interface for a signer to sign a Claim according to
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_signing_a_claim.
+class ClaimSigner {
+ public:
+  virtual ~ClaimSigner() = default;
+  struct Options {
+    TimeStampingMode time_stamping_mode = TimeStampingMode::kOptional;
+    AsyncContext async_context = AsyncContext();
+  };
+
+  struct SignResult {
+    // Serialized COSE_Sign1_Tagged structure.
+    std::string signature;
+  };
+
+  // Signs the serialized CBOR of the claim and returns the serialized
+  // COSE_Sign1_Tagged structure that will be written into the C2PA Claim
+  // Signature box. For more details, see
+  // https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_signing_a_claim.
+  // It guarantees that the size of the result is fixed and is a function of
+  // `max_signature_bytes_size()` and `max_timestamp_byte_size()` of the
+  // CryptoWriteHandler rather than of the actual signature and timestamp sizes.
+  virtual void Sign(
+      absl::string_view claim, Options options,
+      absl::AnyInvocable<void(absl::StatusOr<SignResult>) &&> cb) const = 0;
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_CLAIM_SIGNER_H_
diff --git a/claim/timestamp_updater.cc b/claim/timestamp_updater.cc
new file mode 100644
index 0000000..dd71eef
--- /dev/null
+++ b/claim/timestamp_updater.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 "claim/timestamp_updater.h"
+
+#include <cstddef>
+#include <memory>
+#include <string>
+#include <utility>
+
+#include "absl/base/nullability.h"
+#include "absl/functional/any_invocable.h"
+#include "absl/status/status.h"
+#include "absl/strings/string_view.h"
+#include "async/async_context.h"
+#include "claim/manifest_store_view.h"
+#include "cose/sig_structure.h"
+#include "cose/timestamp_updater.h"
+
+namespace credentio {
+
+void ClaimTimestampUpdater::InsertTimestamp(
+    std::string* absl_nonnull manifest_store, AsyncContext async_context,
+    absl::AnyInvocable<void(absl::Status) &&> cb) const {
+  auto manifest_store_view = ManifestStoreView::Create(*manifest_store);
+  if (!manifest_store_view.ok()) {
+    AsyncResolver<void>(async_context, std::move(cb))
+        .Return(manifest_store_view.status());
+    return;
+  }
+  auto manifest_view = manifest_store_view->GetActiveManifest();
+  if (!manifest_view.ok()) {
+    AsyncResolver<void>(async_context, std::move(cb))
+        .Return(manifest_view.status());
+    return;
+  }
+  auto claim_signature_view = manifest_view->GetClaimSignature();
+  if (!claim_signature_view.ok()) {
+    AsyncResolver<void>(async_context, std::move(cb))
+        .Return(claim_signature_view.status());
+    return;
+  }
+  auto cose_sign1 = claim_signature_view->GetStruct();
+  if (!cose_sign1.ok()) {
+    AsyncResolver<void>(async_context, std::move(cb))
+        .Return(cose_sign1.status());
+    return;
+  }
+  if (cose_sign1->unprotected_header.sig_tst2.has_value()) {
+    AsyncResolver<void>(async_context, std::move(cb)).Return(absl::OkStatus());
+    return;
+  }
+  auto signature = claim_signature_view->GetPayload();
+  if (!signature.ok()) {
+    AsyncResolver<void>(async_context, std::move(cb))
+        .Return(signature.status());
+    return;
+  }
+  auto updated_signature = std::make_shared<std::string>(*signature);
+  absl::AnyInvocable<void(absl::Status) &&> done_cb =
+      [manifest_store, signature = *signature, updated_signature, async_context,
+       cb = std::move(cb)](absl::Status status) mutable {
+        AsyncResolver<void> resolver(async_context, std::move(cb));
+        if (!status.ok()) {
+          resolver.Return(status);
+          return;
+        }
+        if (updated_signature->size() != signature.size()) {
+          resolver.Return(
+              absl::InternalError("updated signature size does not match the "
+                                  "original size."));
+          return;
+        }
+        size_t signature_offset =
+            signature.begin() - absl::string_view(*manifest_store).begin();
+        manifest_store->replace(signature_offset, signature.size(),
+                                *updated_signature);
+        resolver.Return(absl::OkStatus());
+      };
+
+  TimestampUpdater updater(timestamp_creator_.get());
+  updater.UpdateTimestamp(updated_signature.get(), async_context,
+                          std::move(done_cb));
+}
+
+}  // namespace credentio
diff --git a/claim/timestamp_updater.h b/claim/timestamp_updater.h
new file mode 100644
index 0000000..3a26593
--- /dev/null
+++ b/claim/timestamp_updater.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_CLAIM_TIMESTAMP_UPDATER_H_
+#define THIRD_PARTY_CREDENTIO_CLAIM_TIMESTAMP_UPDATER_H_
+
+#include <memory>
+#include <string>
+#include <utility>
+
+#include "absl/base/nullability.h"
+#include "absl/functional/any_invocable.h"
+#include "absl/status/status.h"
+#include "async/async_context.h"
+#include "cose/timestamp_creator.h"
+
+namespace credentio {
+// Updater of claim timestamps in C2PA manifest stores.
+class ClaimTimestampUpdater {
+ public:
+  explicit ClaimTimestampUpdater(
+      std::unique_ptr<TimestampCreator> timestamp_creator)
+      : timestamp_creator_(std::move(timestamp_creator)) {}
+
+  // Inserts a timestamp to the active manifest in the given manifest store, if
+  // a timestamp is not found; otherwise, makes no change. If a timestamp is
+  // inserted, it resizes the `pad` in the unprotected header to ensure the size
+  // of the result is the same as the input.
+  //
+  // Returns an error if space is insufficient for padding.
+  void InsertTimestamp(std::string* absl_nonnull manifest_store,
+                       AsyncContext async_context,
+                       absl::AnyInvocable<void(absl::Status) &&> cb) const;
+
+ private:
+  std::unique_ptr<TimestampCreator> timestamp_creator_;
+};
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_CLAIM_TIMESTAMP_UPDATER_H_
diff --git a/claim/timestamp_updater_test.cc b/claim/timestamp_updater_test.cc
new file mode 100644
index 0000000..0c6e0aa
--- /dev/null
+++ b/claim/timestamp_updater_test.cc
@@ -0,0 +1,173 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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/timestamp_updater.h"
+
+#include <memory>
+#include <string>
+#include <utility>
+
+#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/synchronization/notification.h"
+#include "absl/time/clock_interface.h"
+#include "absl/time/simulated_clock.h"
+#include "absl/time/time.h"
+#include "async/async_context.h"
+#include "constants/labels.h"
+#include "cose/sig_structure.h"
+#include "cose/timestamp_creator.h"
+#include "crypto/crypto_read_handler.h"
+#include "crypto/default/default_crypto_read_handler.h"
+#include "crypto/default/hasher.h"
+#include "crypto/hash.h"
+#include "crypto/timestamp_write_handler.h"
+#include "gmock/gmock.h"
+#include "google/protobuf/timestamp.pb.h"
+#include "gtest/gtest.h"
+#include "jumbf/test_utils.h"
+#include "proto/validation_result.pb.h"
+#include "testing/create_test_crypto_write_handler.h"
+#include "testing/manifest_store_test_utils.h"
+#include "validator/manifest_store_validator_impl.h"
+#include "validator/validator_options.h"
+
+namespace credentio {
+namespace {
+
+using ::absl_testing::IsOkAndHolds;
+
+constexpr absl::Time kTestTime = absl::FromUnixSeconds(1741403167);
+
+absl::StatusOr<absl::Time> ValidateAndGetTime(
+    absl::string_view manifest_store) {
+  ABSL_ASSIGN_OR_RETURN(
+      std::unique_ptr<CryptoReadHandler> crypto_read_handler,
+      CreateDefaultCryptoReadHandler({
+          .tsa_trust_anchors_pem = DefaultTestPki().root_ca.cert_pem,
+          .skip_claim_signer_trust_checks_for_test = true,
+      }));
+  ManifestStoreValidatorImpl validator(ValidatorOptions{
+      .crypto_read_handler = std::move(crypto_read_handler),
+  });
+  ABSL_ASSIGN_OR_RETURN(std::unique_ptr<PartialValidationResultProto> result,
+                        validator.Validate(manifest_store));
+  const google::protobuf::Timestamp& ts =
+      result->active_manifest().claim().signature_info().timestamping_time();
+  return absl::FromUnixSeconds(ts.seconds()) + absl::Nanoseconds(ts.nanos());
+}
+
+absl::StatusOr<std::unique_ptr<TimestampWriteHandler>> CreateTimestampHandler(
+    absl::Clock* clock) {
+  return CreateTestCryptoWriteHandler({.clock = clock});
+}
+
+absl::StatusOr<std::string> InsertTimestamp(ClaimTimestampUpdater& updater,
+                                            absl::string_view manifest_store) {
+  std::string updated_manifest_store(manifest_store);
+  absl::Notification done;
+  absl::Status status;
+  updater.InsertTimestamp(&updated_manifest_store, AsyncContext(),
+                          [&](absl::Status s) {
+                            status = std::move(s);
+                            done.Notify();
+                          });
+  done.WaitForNotification();
+  if (!status.ok()) return status;
+  return updated_manifest_store;
+}
+
+TEST(ClaimTimestampUpdater, InsertTimestampStandardManifest) {
+  absl::StatusOr<std::string> manifest_store =
+      CreateSimpleManifestStoreBytes({.disable_timestamping = true});
+  ABSL_ASSERT_OK(manifest_store);
+
+  absl::SimulatedClock clock;
+  clock.SetTime(kTestTime);
+  absl::StatusOr<std::unique_ptr<TimestampWriteHandler>> timestamp_handler =
+      CreateTimestampHandler(&clock);
+  ABSL_ASSERT_OK(timestamp_handler);
+  auto timestamp_creator = std::make_unique<TimestampCreator>(
+      timestamp_handler->get(), &DefaultHasherFactory());
+  ClaimTimestampUpdater updater(std::move(timestamp_creator));
+  absl::StatusOr<std::string> updated_manifest_store =
+      InsertTimestamp(updater, *manifest_store);
+  ABSL_ASSERT_OK(updated_manifest_store);
+  EXPECT_THAT(ValidateAndGetTime(*updated_manifest_store),
+              IsOkAndHolds(kTestTime));
+
+  EXPECT_EQ(updated_manifest_store->size(), manifest_store->size());
+}
+
+TEST(ClaimTimestampUpdater, InsertTimestampInUpdateManifest) {
+  absl::SimulatedClock clock;
+  clock.SetTime(kTestTime);
+  absl::StatusOr<std::unique_ptr<TimestampWriteHandler>> timestamp_handler =
+      CreateTimestampHandler(&clock);
+  ABSL_ASSERT_OK(timestamp_handler);
+
+  std::string timestamp_placeholder(
+      (*timestamp_handler)
+          ->max_timestamp_byte_size(DefaultHasherFactory().algorithm()),
+      '\0');
+  CoseSign1TaggedStructure cose_sign1{
+      .unprotected_header =
+          {.sig_tst2 =
+               TstContainer{.tst_tokens = {{.val = timestamp_placeholder}}}},
+      .signature = "abc",
+  };
+  std::string claim_signature = jumbf::EncodeSuperBox(
+      kClaimSignatureUuid, kClaimSignatureLabel,
+      {jumbf::EncodeCborBox(EncodeCoseSign1TaggedStructure(cose_sign1))});
+  std::string manifest = jumbf::EncodeSuperBox(
+      kUpdateManifestUuid, "update_manifest", {claim_signature});
+  std::string manifest_store = jumbf::EncodeSuperBox(
+      kManifestStoreUuid, kManifestStoreLabel, {manifest});
+
+  auto timestamp_creator = std::make_unique<TimestampCreator>(
+      timestamp_handler->get(), &DefaultHasherFactory());
+  ClaimTimestampUpdater updater(std::move(timestamp_creator));
+  absl::StatusOr<std::string> updated_manifest_store =
+      InsertTimestamp(updater, manifest_store);
+  ABSL_ASSERT_OK(updated_manifest_store);
+
+  EXPECT_EQ(updated_manifest_store->size(), manifest_store.size());
+}
+
+TEST(ClaimTimestampUpdater, TimestampExistsNoOp) {
+  absl::SimulatedClock clock;
+  clock.SetTime(kTestTime);
+  absl::StatusOr<std::string> manifest_store = CreateSimpleManifestStoreBytes(
+      {.clock = &clock, .disable_timestamping = false});
+  ABSL_ASSERT_OK(manifest_store);
+
+  clock.AdvanceTime(absl::Seconds(100));
+  absl::StatusOr<std::unique_ptr<TimestampWriteHandler>> timestamp_handler =
+      CreateTimestampHandler(&clock);
+  ABSL_ASSERT_OK(timestamp_handler);
+  auto timestamp_creator = std::make_unique<TimestampCreator>(
+      timestamp_handler->get(), &DefaultHasherFactory());
+  ClaimTimestampUpdater updater(std::move(timestamp_creator));
+  absl::StatusOr<std::string> updated_manifest_store =
+      InsertTimestamp(updater, *manifest_store);
+  ABSL_ASSERT_OK(updated_manifest_store);
+  EXPECT_EQ(*updated_manifest_store, *manifest_store);
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/claim/validator.cc b/claim/validator.cc
index 777cd27..139a5dd 100644
--- a/claim/validator.cc
+++ b/claim/validator.cc
@@ -19,7 +19,7 @@
 #include <string>
 #include <vector>
 
-#include "absl/log/log.h"
+#include "absl/log/absl_log.h"
 #include "absl/status/status.h"
 #include "absl/status/statusor.h"
 #include "absl/strings/str_cat.h"
@@ -30,7 +30,6 @@
 #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"
@@ -193,20 +192,15 @@
   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;
 }
 
+bool IsClaimBox(absl::string_view label, const Uuid& uuid) {
+  return (label == kClaimV2Label && uuid == kClaimUuid) ||
+         IsLegacyClaimBox(label, uuid);
+}
+
 // 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,
@@ -232,30 +226,29 @@
 
   // 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"});
+    if (!box.Holds<SuperBox>()) {
+      continue;
+    }
+    const SuperBox& super_box = box.Get<SuperBox>();
+    const auto& description = super_box.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});
         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 = &super_box;
+      claim_box_label = label;
+    } else if (label == kClaimSignatureLabel && uid == kClaimSignatureUuid) {
+      claim_signature_box = &super_box;
     }
   }
 
@@ -267,6 +260,7 @@
   if (!claim_box.has_value()) {
     validation_tracker.RecordFailure(FailureStatusCode::kClaimMissing,
                                      {.url = claim_box_label});
+    return std::nullopt;
   } else {
     auto result = ExtractCbor(*claim_box);
     if (!result.ok()) {
@@ -359,8 +353,8 @@
     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";
+      ABSL_LOG_EVERY_N_SEC(ERROR, 60) << "Signature verification "
+                                         "failed but no failure was recorded";
       validation_tracker.RecordFailure(FailureStatusCode::kGoogleInternalError,
                                        {.url = kClaimSignatureLabel});
     }
@@ -369,21 +363,15 @@
                                      {.url = kClaimSignatureLabel});
   }
 
-  ClaimValidatorImpl::ValidateAssertionResult validate_assertion_result;
-  if (claim_box.has_value()) {
-    validate_assertion_result =
-        ValidateAssertions(assertion_uris, validation_tracker);
-  }
+  ClaimValidatorImpl::ValidateAssertionResult 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(""))
-              : "",
+      .label = claim_box_label,
       .claim_map = *claim_map,
       .created_assertions =
           absl::MakeSpan(validate_assertion_result.created_assertions),
@@ -401,26 +389,21 @@
 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);
+    for (const auto& a : ParseHashedUriArray(*assertion_uris.created_assertions,
+                                             validation_tracker)) {
+      result.created_assertions.push_back(
+          a.ToProto(assertion_uris.default_algorithm));
+    }
   }
   if (assertion_uris.gathered_assertions.has_value()) {
-    gathered_assertions = ParseHashedUriArray(
-        *assertion_uris.gathered_assertions, validation_tracker);
+    for (const auto& a : ParseHashedUriArray(
+             *assertion_uris.gathered_assertions, validation_tracker)) {
+      result.gathered_assertions.push_back(
+          a.ToProto(assertion_uris.default_algorithm));
+    }
   }
-  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);
@@ -428,11 +411,10 @@
         validation_tracker.RecordFailure(FailureStatusCode::kHashedUriMissing,
                                          {});
       } else {
-        redacted_assertions.push_back(std::string(*uri));
+        result.redacted_assertions.emplace_back(*uri);
       }
     }
   }
-  result.redacted_assertions = redacted_assertions;
   return result;
 }
 
diff --git a/claim/validator.h b/claim/validator.h
index fd5d45f..b3472e6 100644
--- a/claim/validator.h
+++ b/claim/validator.h
@@ -24,7 +24,6 @@
 #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"
 
diff --git a/claim/validator_test.cc b/claim/validator_test.cc
index cc948c1..4d34823 100644
--- a/claim/validator_test.cc
+++ b/claim/validator_test.cc
@@ -46,7 +46,6 @@
 namespace credentio {
 namespace {
 
-using ::absl_testing::IsOk;
 using ::cbor::FromJson;
 using ::credentio_testing::EqualsProto;
 using ::credentio_testing::ParseTextProtoOrDie;
@@ -170,7 +169,7 @@
 
 TEST(ValidateClaimTest, ClaimMissing) {
   auto manifest = CreateManifestBuilder();
-  ASSERT_THAT(manifest.AddChild(CreateClaimSignatureBox("some cbor")), IsOk());
+  ABSL_ASSERT_OK(manifest.AddChild(CreateClaimSignatureBox("some cbor")));
   MockCoseVerifier mock_cose_verifier;
   TestValidationTracker t;
   ClaimValidatorImpl validator(&mock_cose_verifier);
@@ -184,7 +183,7 @@
 
 TEST(ValidateClaimTest, ClaimSignatureMissing) {
   auto manifest = CreateManifestBuilder();
-  ASSERT_THAT(manifest.AddChild(CreateClaimBoxFromCbor("some cbor")), IsOk());
+  ABSL_ASSERT_OK(manifest.AddChild(CreateClaimBoxFromCbor("some cbor")));
   MockCoseVerifier mock_cose_verifier;
   TestValidationTracker t;
   ClaimValidatorImpl validator(&mock_cose_verifier);
@@ -198,7 +197,7 @@
 
 TEST(ValidateClaimTest, BothClaimAndSignatureMissing) {
   auto manifest = CreateManifestBuilder();
-  ASSERT_THAT(manifest.AddChild(CreateAssertionStoreBox()), IsOk());
+  ABSL_ASSERT_OK(manifest.AddChild(CreateAssertionStoreBox()));
   MockCoseVerifier mock_cose_verifier;
   TestValidationTracker t;
   ClaimValidatorImpl validator(&mock_cose_verifier);
@@ -207,8 +206,6 @@
   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));
 }
@@ -216,18 +213,17 @@
 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());
+  EXPECT_CALL(mock_cose_verifier, Verify).Times(0);
+  ABSL_ASSERT_OK(manifest.AddChild(CreateClaimBoxFromCbor("some cbor")));
+  ABSL_ASSERT_OK(manifest.AddChild(CreateClaimBoxFromCbor("some other cbor")));
+  ABSL_ASSERT_OK(manifest.AddChild(CreateClaimSignatureBox("some cbor")));
   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.GetFailures(), SizeIs(1));
   EXPECT_THAT(t.GetSuccesses(), IsEmpty());
   EXPECT_THAT(result, Eq(std::nullopt));
 }
@@ -236,8 +232,8 @@
   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());
+  ABSL_ASSERT_OK(manifest.AddChild(std::move(claim_box)));
+  ABSL_ASSERT_OK(manifest.AddChild(CreateClaimSignatureBox("some cbor")));
   MockCoseVerifier mock_cose_verifier;
   TestValidationTracker t;
   ClaimValidatorImpl validator(&mock_cose_verifier);
@@ -255,11 +251,10 @@
       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());
+  ABSL_ASSERT_OK(child_box.AddContent(jumbf::kCborBoxType, absl::Cord("foo")));
+  ABSL_ASSERT_OK(claim_box.AddChild(std::move(child_box)));
+  ABSL_ASSERT_OK(manifest.AddChild(std::move(claim_box)));
+  ABSL_ASSERT_OK(manifest.AddChild(CreateClaimSignatureBox("some cbor")));
   MockCoseVerifier mock_cose_verifier;
   TestValidationTracker t;
   ClaimValidatorImpl validator(&mock_cose_verifier);
@@ -273,10 +268,10 @@
 
 TEST(ValidateClaimTest, ClaimSignatureHasMultipleContents) {
   auto manifest = CreateManifestBuilder();
-  ASSERT_THAT(manifest.AddChild(CreateClaimBoxFromCbor("some cbor")), IsOk());
+  ABSL_ASSERT_OK(manifest.AddChild(CreateClaimBoxFromCbor("some cbor")));
   auto claim_signature_box = CreateClaimSignatureBoxWithMultipleContents(
       "some cbor", "some other cbor");
-  ASSERT_THAT(manifest.AddChild(std::move(claim_signature_box)), IsOk());
+  ABSL_ASSERT_OK(manifest.AddChild(std::move(claim_signature_box)));
   MockCoseVerifier mock_cose_verifier;
   TestValidationTracker t;
   ClaimValidatorImpl validator(&mock_cose_verifier);
@@ -290,15 +285,14 @@
 
 TEST(ValidateClaimTest, ClaimSignatureIsNotCborBox) {
   auto manifest = CreateManifestBuilder();
-  ASSERT_THAT(manifest.AddChild(CreateClaimBoxFromCbor("some_cbor")), IsOk());
+  ABSL_ASSERT_OK(manifest.AddChild(CreateClaimBoxFromCbor("some_cbor")));
   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());
+  ABSL_ASSERT_OK(child_box.AddContent(jumbf::kCborBoxType, absl::Cord("foo")));
+  ABSL_ASSERT_OK(claim_signature_box.AddChild(std::move(child_box)));
+  ABSL_ASSERT_OK(manifest.AddChild(std::move(claim_signature_box)));
   MockCoseVerifier mock_cose_verifier;
   TestValidationTracker t;
   ClaimValidatorImpl validator(&mock_cose_verifier);
@@ -312,11 +306,10 @@
 
 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());
+  ABSL_ASSERT_OK(
+      manifest.AddChild(CreateClaimBoxFromJson(R"json([1, 2, 3])json")));
+  ABSL_ASSERT_OK(
+      manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate")));
   MockCoseVerifier mock_cose_verifier;
   EXPECT_CALL(mock_cose_verifier, Verify)
       .WillOnce(Return(CoseVerificationResult()));
@@ -332,11 +325,9 @@
 
 TEST(ValidateClaimTest, ClaimHasIncorrectSignature) {
   auto manifest = CreateManifestBuilder();
-  ASSERT_THAT(manifest.AddChild(CreateClaimBox({.signature = "not_valid"})),
-              IsOk());
-  ASSERT_THAT(
-      manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate")),
-      IsOk());
+  ABSL_ASSERT_OK(manifest.AddChild(CreateClaimBox({.signature = "not_valid"})));
+  ABSL_ASSERT_OK(
+      manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate")));
   MockCoseVerifier mock_cose_verifier;
   EXPECT_CALL(mock_cose_verifier, Verify)
       .WillOnce(Return(CoseVerificationResult()));
@@ -352,12 +343,10 @@
 
 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());
+  ABSL_ASSERT_OK(manifest.AddChild(
+      CreateClaimBox({.signature = "self#jumbf=c2pa.signature"})));
+  ABSL_ASSERT_OK(
+      manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate")));
   MockCoseVerifier mock_cose_verifier;
   EXPECT_CALL(mock_cose_verifier, Verify)
       .WillOnce([](absl::string_view cose_certificate,
@@ -379,12 +368,10 @@
 
 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());
+  ABSL_ASSERT_OK(manifest.AddChild(
+      CreateClaimBox({.signature = "self#jumbf=c2pa.signature"})));
+  ABSL_ASSERT_OK(
+      manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate")));
   MockCoseVerifier mock_cose_verifier;
   EXPECT_CALL(mock_cose_verifier, Verify)
       .WillOnce(Return(CoseVerificationResult()));
@@ -406,11 +393,10 @@
     "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());
+  ABSL_ASSERT_OK(manifest.AddChild(CreateClaimBoxFromJson(claim_json)));
+  ABSL_ASSERT_OK(
+      manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate")));
+  ABSL_ASSERT_OK(manifest.AddChild(CreateAssertionStoreBox()));
   MockCoseVerifier mock_cose_verifier;
   EXPECT_CALL(mock_cose_verifier, Verify)
       .WillOnce(Return(CoseVerificationResult()));
@@ -434,11 +420,10 @@
     }],
     "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());
+  ABSL_ASSERT_OK(manifest.AddChild(CreateClaimBoxFromJson(claim_json)));
+  ABSL_ASSERT_OK(
+      manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate")));
+  ABSL_ASSERT_OK(manifest.AddChild(CreateAssertionStoreBox()));
   MockCoseVerifier mock_cose_verifier;
   EXPECT_CALL(mock_cose_verifier, Verify)
       .WillOnce(Return(CoseVerificationResult()));
@@ -461,11 +446,10 @@
     }],
     "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());
+  ABSL_ASSERT_OK(manifest.AddChild(CreateClaimBoxFromJson(claim_json)));
+  ABSL_ASSERT_OK(
+      manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate")));
+  ABSL_ASSERT_OK(manifest.AddChild(CreateAssertionStoreBox()));
   MockCoseVerifier mock_cose_verifier;
   EXPECT_CALL(mock_cose_verifier, Verify)
       .WillOnce(Return(CoseVerificationResult()));
@@ -492,11 +476,10 @@
     }],
     "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());
+  ABSL_ASSERT_OK(manifest.AddChild(CreateClaimBoxFromJson(claim_json)));
+  ABSL_ASSERT_OK(
+      manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate")));
+  ABSL_ASSERT_OK(manifest.AddChild(CreateAssertionStoreBox()));
   MockCoseVerifier mock_cose_verifier;
   EXPECT_CALL(mock_cose_verifier, Verify)
       .WillOnce(Return(CoseVerificationResult()));
@@ -520,11 +503,10 @@
     }],
     "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());
+  ABSL_ASSERT_OK(manifest.AddChild(CreateClaimBoxFromJson(claim_json)));
+  ABSL_ASSERT_OK(
+      manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate")));
+  ABSL_ASSERT_OK(manifest.AddChild(CreateAssertionStoreBox()));
   MockCoseVerifier mock_cose_verifier;
   EXPECT_CALL(mock_cose_verifier, Verify)
       .WillOnce(Return(CoseVerificationResult()));
@@ -563,12 +545,11 @@
       {"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());
+  ABSL_ASSERT_OK(manifest.AddChild(
+      CreateClaimBoxFromJson(claim_json)));  // Uses c2pa.claim.v2 label.
+  ABSL_ASSERT_OK(
+      manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate")));
+  ABSL_ASSERT_OK(manifest.AddChild(CreateAssertionStoreBox()));
   MockCoseVerifier mock_cose_verifier;
 
   SignatureInfo signature_info;
@@ -611,11 +592,10 @@
     ],
     "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());
+  ABSL_ASSERT_OK(manifest.AddChild(CreateOldClaimBoxFromJson(claim_json)));
+  ABSL_ASSERT_OK(
+      manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate")));
+  ABSL_ASSERT_OK(manifest.AddChild(CreateAssertionStoreBox()));
   MockCoseVerifier mock_cose_verifier;
   TestValidationTracker t;
   ClaimValidatorImpl validator(&mock_cose_verifier);
@@ -652,11 +632,10 @@
     }],
     "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());
+  ABSL_ASSERT_OK(manifest.AddChild(CreateClaimBoxFromJson(claim_json)));
+  ABSL_ASSERT_OK(
+      manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate")));
+  ABSL_ASSERT_OK(manifest.AddChild(CreateAssertionStoreBox()));
   MockCoseVerifier mock_cose_verifier;
   EXPECT_CALL(mock_cose_verifier, Verify)
       .WillOnce(Return(CoseVerificationResult()));
@@ -698,11 +677,10 @@
     }],
     "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());
+  ABSL_ASSERT_OK(manifest.AddChild(CreateClaimBoxFromJson(claim_json)));
+  ABSL_ASSERT_OK(
+      manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate")));
+  ABSL_ASSERT_OK(manifest.AddChild(CreateAssertionStoreBox()));
   MockCoseVerifier mock_cose_verifier;
   SignatureInfo signature_info;
   signature_info.set_issuer("fake_issuer");
@@ -760,11 +738,10 @@
     }],
     "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());
+  ABSL_ASSERT_OK(manifest.AddChild(CreateClaimBoxFromJson(claim_json)));
+  ABSL_ASSERT_OK(
+      manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate")));
+  ABSL_ASSERT_OK(manifest.AddChild(CreateAssertionStoreBox()));
   MockCoseVerifier mock_cose_verifier;
   SignatureInfo signature_info;
   signature_info.set_issuer("fake_issuer");
@@ -826,11 +803,10 @@
     }],
     "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());
+  ABSL_ASSERT_OK(manifest.AddChild(CreateOldClaimBoxFromJson(claim_json)));
+  ABSL_ASSERT_OK(
+      manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate")));
+  ABSL_ASSERT_OK(manifest.AddChild(CreateAssertionStoreBox()));
   MockCoseVerifier mock_cose_verifier;
   TestValidationTracker t;
   ClaimValidatorImpl validator(&mock_cose_verifier);
@@ -870,11 +846,10 @@
     }],
     "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());
+  ABSL_ASSERT_OK(manifest.AddChild(CreateClaimBoxFromJson(claim_json)));
+  ABSL_ASSERT_OK(
+      manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate")));
+  ABSL_ASSERT_OK(manifest.AddChild(CreateAssertionStoreBox()));
   MockCoseVerifier mock_cose_verifier;
   SignatureInfo signature_info;
   signature_info.set_issuer("fake_issuer");
@@ -922,11 +897,10 @@
     }],
     "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());
+  ABSL_ASSERT_OK(manifest.AddChild(CreateClaimBoxFromJson(claim_json)));
+  ABSL_ASSERT_OK(
+      manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate")));
+  ABSL_ASSERT_OK(manifest.AddChild(CreateAssertionStoreBox()));
   MockCoseVerifier mock_cose_verifier;
   SignatureInfo signature_info;
   signature_info.set_issuer("fake_issuer");
@@ -968,11 +942,10 @@
     }],
     "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());
+  ABSL_ASSERT_OK(manifest.AddChild(CreateClaimBoxFromJson(claim_json)));
+  ABSL_ASSERT_OK(
+      manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate")));
+  ABSL_ASSERT_OK(manifest.AddChild(CreateAssertionStoreBox()));
   MockCoseVerifier mock_cose_verifier;
   SignatureInfo signature_info;
   signature_info.set_issuer("fake_issuer");
@@ -1012,11 +985,10 @@
     }],
     "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());
+  ABSL_ASSERT_OK(manifest.AddChild(CreateClaimBoxFromJson(claim_json)));
+  ABSL_ASSERT_OK(
+      manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate")));
+  ABSL_ASSERT_OK(manifest.AddChild(CreateAssertionStoreBox()));
   MockCoseVerifier mock_cose_verifier;
   SignatureInfo signature_info;
   signature_info.set_issuer("fake_issuer");
@@ -1060,11 +1032,10 @@
     }],
     "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());
+  ABSL_ASSERT_OK(manifest.AddChild(CreateClaimBoxFromJson(claim_json)));
+  ABSL_ASSERT_OK(
+      manifest.AddChild(CreateClaimSignatureBox("fake_cose_certificate")));
+  ABSL_ASSERT_OK(manifest.AddChild(CreateAssertionStoreBox()));
   MockCoseVerifier mock_cose_verifier;
   SignatureInfo signature_info;
   signature_info.set_issuer("fake_issuer");
diff --git a/constants/labels.h b/constants/labels.h
index 748d9ae..e1f9320 100644
--- a/constants/labels.h
+++ b/constants/labels.h
@@ -122,6 +122,8 @@
 inline constexpr absl::string_view kThumbnailIngredientAssertionLabelPrefix =
     "c2pa.thumbnail.ingredient.";
 inline constexpr absl::string_view kMetadataAssertionLabelSuffix = ".metadata";
+inline constexpr absl::string_view kExifAssertionLabel = "stds.exif";
+inline constexpr absl::string_view kIptcAssertionLabel = "stds.iptc";
 
 // The UUID of the C2PA BMFF box is D8FEC3D6-1B0E-483C-9297-5828877EC481.
 // See
diff --git a/constants/status_codes.h b/constants/status_codes.h
index 1c70668..b2a49cc 100644
--- a/constants/status_codes.h
+++ b/constants/status_codes.h
@@ -69,10 +69,7 @@
   // 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
+  kTimestampOutOfRange,  // com.google.timeStamp.outOfRange
   // go/keep-sorted end
 
   // Standard informational codes
@@ -260,12 +257,6 @@
     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;
diff --git a/cose/BUILD b/cose/BUILD
index 1968d65..f811b95 100644
--- a/cose/BUILD
+++ b/cose/BUILD
@@ -204,3 +204,91 @@
         "@googletest//:gtest_main",
     ],
 )
+
+cc_library(
+    name = "timestamp_creator",
+    srcs = ["timestamp_creator.cc"],
+    hdrs = ["timestamp_creator.h"],
+    deps = [
+        ":sig_structure",
+        "//async:async_context",
+        "//crypto:hash",
+        "//crypto:timestamp_write_handler",
+        "@abseil-cpp//absl/base:nullability",
+        "@abseil-cpp//absl/functional:any_invocable",
+        "@abseil-cpp//absl/log:die_if_null",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings:string_view",
+        "@libcppbor",
+    ],
+)
+
+cc_test(
+    name = "timestamp_creator_test",
+    srcs = ["timestamp_creator_test.cc"],
+    deps = [
+        ":sig_structure",
+        ":simple_cms_parser",
+        ":timestamp_creator",
+        "//crypto:timestamp_write_handler",
+        "//crypto/default:hasher",
+        "//testing:create_test_crypto_write_handler",
+        "//testing:local_key_crypto_write_handler",
+        "//tsp:parsed_timestamp_token",
+        "//tsp:timestamp_verifier",
+        "//tsp:verified_timestamp",
+        "@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/time:clock_interface",
+        "@abseil-cpp//absl/time:simulated_clock",
+        "@googletest//:gtest_main",
+    ],
+)
+
+cc_library(
+    name = "timestamp_updater",
+    srcs = ["timestamp_updater.cc"],
+    hdrs = ["timestamp_updater.h"],
+    deps = [
+        ":sig_structure",
+        ":timestamp_creator",
+        "//async:async_context",
+        "@abseil-cpp//absl/base:nullability",
+        "@abseil-cpp//absl/functional:any_invocable",
+        "@abseil-cpp//absl/log:die_if_null",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:statusor",
+        "@libcppbor",
+    ],
+)
+
+cc_test(
+    name = "timestamp_updater_test",
+    srcs = ["timestamp_updater_test.cc"],
+    deps = [
+        ":sig_structure",
+        ":simple_cms_parser",
+        ":timestamp_creator",
+        ":timestamp_updater",
+        "//async:async_context",
+        "//crypto:timestamp_write_handler",
+        "//crypto/default:hasher",
+        "//testing:create_test_crypto_write_handler",
+        "//tsp:parsed_timestamp_token",
+        "//tsp:timestamp_verifier",
+        "//tsp:verified_timestamp",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings:string_view",
+        "@abseil-cpp//absl/synchronization",
+        "@abseil-cpp//absl/time",
+        "@abseil-cpp//absl/time:clock_interface",
+        "@abseil-cpp//absl/time:simulated_clock",
+        "@googletest//:gtest_main",
+    ],
+)
diff --git a/cose/cose_sign1.cc b/cose/cose_sign1.cc
index 5af913c..0ef7d4c 100644
--- a/cose/cose_sign1.cc
+++ b/cose/cose_sign1.cc
@@ -154,37 +154,14 @@
     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,
+    absl::string_view signature_bstr,
     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::kV1:
+      return CheckMessageImprintHash(
+          hash_checker_factory, status_set, oldest_trusted_timestamp.timestamp,
+          ConstructSigStructure(protected_headers_bstr, external_payload,
+                                "CounterSignature"));
     case TimestampVersion::kV2:
       return CheckMessageImprintHash(
           hash_checker_factory, status_set, oldest_trusted_timestamp.timestamp,
@@ -221,52 +198,38 @@
   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);
+  auto timestamp_token = ParseTimestampResp(timestamp_resp);
+  if (!timestamp_token.ok()) {
+    return RecordInformationalAndReturnStatus(timestamp_token.status(),
+                                              status_set);
   }
-  return result;
+  auto verified_timestamp =
+      crypto_read_handler.VerifyTimestamp(*timestamp_token);
+  if (!verified_timestamp.ok()) {
+    return RecordInformationalAndReturnStatus(verified_timestamp.status(),
+                                              status_set);
+  }
+  return VersionedVerifiedTimestamp{.timestamp = *std::move(verified_timestamp),
+                                    .version = TimestampVersion::kV1};
 }
 
 absl::StatusOr<VersionedVerifiedTimestamp> VerifyV2Timestamp(
     absl::string_view timestamp_token,
     const CryptoReadHandler& crypto_read_handler,
     ValidationStatusSet* status_set) {
-  absl::StatusOr<VerifiedTimestamp> default_attempt =
+  absl::StatusOr<VerifiedTimestamp> attempt =
       crypto_read_handler.VerifyTimestamp(timestamp_token);
 
-  if (default_attempt.ok()) {
-    return VersionedVerifiedTimestamp{.timestamp = *std::move(default_attempt),
+  if (attempt.ok()) {
+    return VersionedVerifiedTimestamp{.timestamp = *std::move(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);
+  return RecordInformationalAndReturnStatus(attempt.status(), status_set);
 }
 
 // Returns the oldest valid timestamp found in the `sigTst2` or `sigTst`
@@ -379,7 +342,7 @@
     if (check_message_imprint &&
         !MessageImprintMatch(external_payload, hash_checker_factory_,
                              status_set, cose_sign1.protected_header,
-                             cose_sign1.signature_bstr(), cose_sign1.signature,
+                             cose_sign1.signature_bstr(),
                              *oldest_trusted_timestamp)) {
       oldest_trusted_timestamp = std::nullopt;
     } else {
@@ -482,6 +445,11 @@
                    {.url = kClaimSignatureLabel});
     }
   }
+  absl::StatusOr<std::string> serial_number =
+      parsed_certs.GetSerialNumberHex(0);
+  if (serial_number.ok()) {
+    signature_info.set_cert_serial_number(*serial_number);
+  }
   absl::StatusOr<std::string> subject = parsed_certs.GetSubject(0);
   if (subject.ok()) {
     signature_info.set_issuer(*subject);
diff --git a/cose/timestamp_creator.cc b/cose/timestamp_creator.cc
new file mode 100644
index 0000000..1423823
--- /dev/null
+++ b/cose/timestamp_creator.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 "cose/timestamp_creator.h"
+
+#include <memory>
+#include <optional>
+#include <string>
+#include <utility>
+
+#include "absl/functional/any_invocable.h"
+#include "absl/status/status_macros.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "async/async_context.h"
+#include "cose/sig_structure.h"
+#include "cppbor/cppbor.h"
+#include "crypto/hash.h"
+#include "crypto/timestamp_write_handler.h"
+
+namespace credentio {
+namespace {
+
+absl::StatusOr<std::string> MessageImprintForTimestamp(
+    const HasherFactory& hasher_factory, absl::string_view body_protected,
+    absl::string_view message) {
+  // https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_obtaining_the_time_stamp
+  std::string encoded_message = EncodeSig1Structure({
+      .context = "CounterSignature",
+      .body_protected = std::string(body_protected),
+      .external_aad = "",
+      .payload = std::string(message),
+  });
+  ABSL_ASSIGN_OR_RETURN(auto hasher, hasher_factory.Create());
+  hasher->Update(encoded_message);
+  return hasher->Digest();
+}
+}  // namespace
+
+void TimestampCreator::CreateTimestamp(
+    const CoseSign1TaggedStructure& cose_sign1, Options options,
+    absl::AnyInvocable<void(absl::StatusOr<std::string>) &&> cb) const {
+  auto signature = cppbor::Bstr(cose_sign1.signature).toString();
+  auto hashed_message = MessageImprintForTimestamp(
+      hasher_factory_, cose_sign1.protected_header, std::move(signature));
+  if (!hashed_message.ok()) {
+    AsyncResolver<std::string>(options.async_context, std::move(cb))
+        .Return(hashed_message.status());
+    return;
+  }
+  TimestampWriteHandler::Options timestamp_handler_options;
+  timestamp_handler_options.async_context = std::move(options.async_context);
+
+  timestamp_handler_.CreateTimestamp(hasher_factory_.algorithm(),
+                                     std::move(*hashed_message),
+                                     timestamp_handler_options, std::move(cb));
+}
+
+}  // namespace credentio
diff --git a/cose/timestamp_creator.h b/cose/timestamp_creator.h
new file mode 100644
index 0000000..817ad23
--- /dev/null
+++ b/cose/timestamp_creator.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_TIMESTAMP_CREATOR_H_
+#define THIRD_PARTY_CREDENTIO_COSE_TIMESTAMP_CREATOR_H_
+
+#include <optional>
+#include <string>
+
+#include "absl/base/nullability.h"
+#include "absl/functional/any_invocable.h"
+#include "absl/log/die_if_null.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "async/async_context.h"
+#include "cose/sig_structure.h"
+#include "crypto/hash.h"
+#include "crypto/timestamp_write_handler.h"
+
+namespace credentio {
+// Creator of timestamps for COSE C2PA claim signatures.
+class TimestampCreator {
+ public:
+  struct Options {
+    AsyncContext async_context = AsyncContext();
+  };
+  explicit TimestampCreator(
+      const TimestampWriteHandler* absl_nonnull timestamp_creator,
+      const HasherFactory* absl_nonnull hasher_factory)
+      : timestamp_handler_(*ABSL_DIE_IF_NULL(timestamp_creator)),
+        hasher_factory_(*ABSL_DIE_IF_NULL(hasher_factory)) {}
+
+  // Creates a TimeStampToken that countersigns the given
+  // CoseSign1TaggedStructure.
+  void CreateTimestamp(
+      const CoseSign1TaggedStructure& cose_sign1, Options options,
+      absl::AnyInvocable<void(absl::StatusOr<std::string>) &&> cb) const;
+
+ private:
+  const TimestampWriteHandler& timestamp_handler_;
+  const HasherFactory& hasher_factory_;
+};
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_COSE_TIMESTAMP_CREATOR_H_
diff --git a/cose/timestamp_creator_test.cc b/cose/timestamp_creator_test.cc
new file mode 100644
index 0000000..fe2abab
--- /dev/null
+++ b/cose/timestamp_creator_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 "cose/timestamp_creator.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/string_view.h"
+#include "absl/time/clock_interface.h"
+#include "absl/time/simulated_clock.h"
+#include "absl/time/time.h"
+#include "cose/sig_structure.h"
+#include "cose/simple_cms_parser.h"
+#include "crypto/default/hasher.h"
+#include "crypto/timestamp_write_handler.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "testing/create_test_crypto_write_handler.h"
+#include "testing/local_key_crypto_write_handler.h"
+#include "tsp/parsed_timestamp_token.h"
+#include "tsp/timestamp_verifier.h"
+#include "tsp/verified_timestamp.h"
+
+namespace credentio {
+namespace {
+
+constexpr absl::Time kTestTime = absl::FromUnixSeconds(1741403167);
+
+absl::StatusOr<std::unique_ptr<TimestampWriteHandler>> CreateTimestampHandler(
+    absl::Clock* clock) {
+  return CreateTestCryptoWriteHandler({.clock = clock});
+}
+
+absl::StatusOr<std::string> CreateTimestamp(
+    TimestampCreator& creator, TimestampCreator::Options options,
+    CoseSign1TaggedStructure cose_sign1) {
+  absl::StatusOr<std::string> result =
+      absl::InternalError("Callback not called");
+  // We pass a ref to this temporary struct to catch bugs where the
+  // provided ref might be accessed after CreateTimestamp returns.
+  CoseSign1TaggedStructure cose_sign1_copy = cose_sign1;
+  creator.CreateTimestamp(cose_sign1_copy, std::move(options),
+                          [&](absl::StatusOr<std::string> cb_result) {
+                            result = std::move(cb_result);
+                          });
+  return result;
+}
+
+TEST(TimestampCreator, CreateTimestamp) {
+  // Create a timestamp creator.
+  absl::SimulatedClock clock;
+  clock.SetTime(kTestTime);
+  absl::StatusOr<std::unique_ptr<TimestampWriteHandler>> timestamp_handler =
+      CreateTimestampHandler(&clock);
+  ABSL_ASSERT_OK(timestamp_handler);
+  TimestampCreator timestamp_creator(timestamp_handler->get(),
+                                     &DefaultHasherFactory());
+  // Create a timestamp.
+  CoseSign1TaggedStructure cose_sign1{.protected_header = "def",
+                                      .signature = "abc"};
+  absl::StatusOr<std::string> timestamp =
+      CreateTimestamp(timestamp_creator, /*options=*/{}, cose_sign1);
+  ABSL_ASSERT_OK(timestamp);
+  // Verify the timestamp.
+  TimestampVerifier verifier(/*tsa_roots=*/nullptr);
+  SimpleCmsParser parser;
+  absl::StatusOr<std::unique_ptr<ParsedTimestampToken>> parsed_token =
+      parser.ParseTimestampToken(*timestamp);
+  ABSL_ASSERT_OK(parsed_token);
+  absl::StatusOr<VerifiedTimestamp> verified_timestamp =
+      verifier.VerifyTimestampToken(**parsed_token);
+  ABSL_ASSERT_OK(verified_timestamp);
+  EXPECT_EQ(verified_timestamp->asserted_time(), kTestTime);
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/cose/timestamp_updater.cc b/cose/timestamp_updater.cc
new file mode 100644
index 0000000..d8b49a1
--- /dev/null
+++ b/cose/timestamp_updater.cc
@@ -0,0 +1,84 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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/timestamp_updater.h"
+
+#include <cstdint>
+#include <string>
+#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/async_context.h"
+#include "cose/sig_structure.h"
+#include "cose/timestamp_creator.h"
+#include "cppbor/cppbor.h"
+
+namespace credentio {
+
+void TimestampUpdater::UpdateTimestamp(
+    std::string* absl_nonnull encoded_cose_sign1, AsyncContext async_context,
+    absl::AnyInvocable<void(absl::Status) &&> cb) const {
+  int64_t original_size = encoded_cose_sign1->size();
+  auto cose_sign1_or = DecodeCoseSign1TaggedStructure(*encoded_cose_sign1);
+  if (!cose_sign1_or.ok()) {
+    AsyncResolver<void>(async_context, std::move(cb))
+        .Return(cose_sign1_or.status());
+    return;
+  }
+  CoseSign1TaggedStructure cose_sign1 = *std::move(cose_sign1_or);
+
+  absl::AnyInvocable<void(absl::StatusOr<std::string>) &&> done_cb =
+      [cose_sign1 = cose_sign1, original_size, encoded_cose_sign1,
+       async_context, cb = std::move(cb)](
+          absl::StatusOr<std::string> status_or_timestamp) mutable {
+        AsyncResolver<void> resolver(async_context, std::move(cb));
+        if (!status_or_timestamp.ok()) {
+          resolver.Return(status_or_timestamp.status());
+          return;
+        }
+        cose_sign1.unprotected_header = {
+            .sig_tst2 =
+                TstContainer{
+                    .tst_tokens = {{.val = std::move(*status_or_timestamp)}}},
+            .pad = "",
+        };
+        int64_t delta =
+            original_size - EncodeCoseSign1TaggedStructure(cose_sign1).size();
+        if (delta < 0) {
+          resolver.Return(
+              absl::InvalidArgumentError("Not enough space for timestamp."));
+          return;
+        }
+        // Adjust padding to account for the encoded size of the `pad` field
+        // (size zero is encoded with 1 byte).
+        int64_t pad_size = delta - cppbor::Uint(delta).encodedSize() + 1;
+        cose_sign1.unprotected_header.pad = std::string(pad_size, '\0');
+        *encoded_cose_sign1 = EncodeCoseSign1TaggedStructure(cose_sign1);
+        if (encoded_cose_sign1->size() != original_size) {
+          resolver.Return(
+              absl::InternalError("wrong size after updating timestamp"));
+          return;
+        }
+        resolver.Return(absl::OkStatus());
+      };
+  timestamp_creator_.CreateTimestamp(
+      cose_sign1, TimestampCreator::Options{.async_context = async_context},
+      std::move(done_cb));
+}
+
+}  // namespace credentio
diff --git a/cose/timestamp_updater.h b/cose/timestamp_updater.h
new file mode 100644
index 0000000..f7200bc
--- /dev/null
+++ b/cose/timestamp_updater.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_COSE_TIMESTAMP_UPDATER_H_
+#define THIRD_PARTY_CREDENTIO_COSE_TIMESTAMP_UPDATER_H_
+
+#include <string>
+
+#include "absl/base/nullability.h"
+#include "absl/functional/any_invocable.h"
+#include "absl/log/die_if_null.h"
+#include "absl/status/status.h"
+#include "async/async_context.h"
+#include "cose/timestamp_creator.h"
+
+namespace credentio {
+
+// Updates the timestamp in a COSE C2PA claim signature.
+class TimestampUpdater {
+ public:
+  explicit TimestampUpdater(
+      const TimestampCreator* absl_nonnull timestamp_creator)
+      : timestamp_creator_(*ABSL_DIE_IF_NULL(timestamp_creator)) {}
+
+  // Creates a timestamp, and if successful, updates the timestamp in the
+  // provided serialized COSE_Sign1_Tagged structure. It guarantees that the
+  // size of the serialized structure does not change.
+  void UpdateTimestamp(std::string* absl_nonnull encoded_cose_sign1,
+                       AsyncContext async_context,
+                       absl::AnyInvocable<void(absl::Status) &&> cb) const;
+
+ private:
+  const TimestampCreator& timestamp_creator_;
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_COSE_TIMESTAMP_UPDATER_H_
diff --git a/cose/timestamp_updater_test.cc b/cose/timestamp_updater_test.cc
new file mode 100644
index 0000000..50511f2
--- /dev/null
+++ b/cose/timestamp_updater_test.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 "cose/timestamp_updater.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/string_view.h"
+#include "absl/synchronization/notification.h"
+#include "absl/time/clock_interface.h"
+#include "absl/time/simulated_clock.h"
+#include "absl/time/time.h"
+#include "async/async_context.h"
+#include "cose/sig_structure.h"
+#include "cose/simple_cms_parser.h"
+#include "cose/timestamp_creator.h"
+#include "crypto/default/hasher.h"
+#include "crypto/timestamp_write_handler.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "testing/create_test_crypto_write_handler.h"
+#include "tsp/parsed_timestamp_token.h"
+#include "tsp/timestamp_verifier.h"
+#include "tsp/verified_timestamp.h"
+
+namespace credentio {
+namespace {
+
+using ::absl_testing::StatusIs;
+using ::testing::HasSubstr;
+using ::testing::IsEmpty;
+using ::testing::Not;
+using ::testing::SizeIs;
+
+constexpr absl::Time kTestTime = absl::FromUnixSeconds(1741403167);
+
+absl::StatusOr<std::unique_ptr<TimestampWriteHandler>> CreateTimestampHandler(
+    absl::Clock* clock) {
+  return CreateTestCryptoWriteHandler({.clock = clock});
+}
+
+absl::StatusOr<std::string> UpdateTimestamp(
+    TimestampUpdater& updater, absl::string_view encoded_cose_sign1) {
+  std::string updated_encoded_cose_sign1(encoded_cose_sign1);
+  absl::Notification done;
+  absl::Status status;
+  updater.UpdateTimestamp(&updated_encoded_cose_sign1, AsyncContext(),
+                          [&](absl::Status s) {
+                            status = std::move(s);
+                            done.Notify();
+                          });
+  done.WaitForNotification();
+  if (!status.ok()) return status;
+  return updated_encoded_cose_sign1;
+}
+
+TEST(TimestampUpdater, UpdateTimestamp) {
+  // Create a timestamp creator.
+  absl::SimulatedClock clock;
+  clock.SetTime(kTestTime);
+  absl::StatusOr<std::unique_ptr<TimestampWriteHandler>> timestamp_handler =
+      CreateTimestampHandler(&clock);
+  ABSL_ASSERT_OK(timestamp_handler);
+  TimestampCreator timestamp_creator(timestamp_handler->get(),
+                                     &DefaultHasherFactory());
+  TimestampUpdater timestamp_updater(&timestamp_creator);
+
+  // Create a timestamp placeholder.
+  CoseSign1TaggedStructure to_be_updated{
+      .protected_header = "def",
+      .unprotected_header =
+          {
+              .sig_tst2 = TstContainer{.tst_tokens = {{.val = std::string(
+                                                           1200, '\0')}}},
+              .pad = std::string(25, '\0'),
+          },
+      .signature = "abc",
+  };
+  std::string encoded_cose_sign1 =
+      EncodeCoseSign1TaggedStructure(to_be_updated);
+  // Update the timestamp.
+  absl::StatusOr<std::string> updated_cose_sign1 =
+      UpdateTimestamp(timestamp_updater, encoded_cose_sign1);
+  ABSL_ASSERT_OK(updated_cose_sign1);
+  EXPECT_EQ(updated_cose_sign1->size(), encoded_cose_sign1.size());
+
+  // Verify the updated timestamp.
+  TimestampVerifier verifier(/*tsa_roots=*/nullptr);
+  SimpleCmsParser parser;
+  absl::StatusOr<CoseSign1TaggedStructure> decoded =
+      DecodeCoseSign1TaggedStructure(*updated_cose_sign1);
+  ABSL_ASSERT_OK(decoded);
+  ASSERT_THAT(decoded->unprotected_header.sig_tst2->tst_tokens, SizeIs(1));
+  ASSERT_THAT(decoded->unprotected_header.sig_tst2->tst_tokens[0].val,
+              Not(IsEmpty()));
+  absl::StatusOr<std::unique_ptr<ParsedTimestampToken>> parsed_token =
+      parser.ParseTimestampToken(
+          decoded->unprotected_header.sig_tst2->tst_tokens[0].val);
+  ABSL_ASSERT_OK(parsed_token);
+  absl::StatusOr<VerifiedTimestamp> updated_verified_timestamp =
+      verifier.VerifyTimestampToken(**parsed_token);
+  ABSL_ASSERT_OK(updated_verified_timestamp);
+  EXPECT_EQ(updated_verified_timestamp->asserted_time(), kTestTime);
+}
+
+TEST(TimestampUpdater, UpdateTimestampNotEnoughSpace) {
+  // Create a timestamp creator.
+  absl::SimulatedClock clock;
+  clock.SetTime(kTestTime);
+  absl::StatusOr<std::unique_ptr<TimestampWriteHandler>> timestamp_handler =
+      CreateTimestampHandler(&clock);
+  ABSL_ASSERT_OK(timestamp_handler);
+  TimestampCreator timestamp_creator(timestamp_handler->get(),
+                                     &DefaultHasherFactory());
+  TimestampUpdater timestamp_updater(&timestamp_creator);
+  // Create a timestamp.
+  CoseSign1TaggedStructure cose_sign1;
+  EXPECT_THAT(UpdateTimestamp(timestamp_updater,
+                              EncodeCoseSign1TaggedStructure(cose_sign1)),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("Not enough space")));
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/crypto/BUILD b/crypto/BUILD
index 37ee2ca..260fa40 100644
--- a/crypto/BUILD
+++ b/crypto/BUILD
@@ -111,3 +111,61 @@
         "@googletest//:gtest_main",
     ],
 )
+
+cc_library(
+    name = "timestamp_write_handler",
+    hdrs = ["timestamp_write_handler.h"],
+    deps = [
+        ":algorithms",
+        "//async:async_context",
+        "@abseil-cpp//absl/functional:any_invocable",
+        "@abseil-cpp//absl/status:statusor",
+    ],
+)
+
+cc_library(
+    name = "crypto_write_handler",
+    hdrs = ["crypto_write_handler.h"],
+    deps = [
+        ":algorithms",
+        ":timestamp_write_handler",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings:string_view",
+    ],
+)
+
+cc_library(
+    name = "templated_crypto_write_handler",
+    hdrs = ["templated_crypto_write_handler.h"],
+    deps = [
+        ":algorithms",
+        ":crypto_write_handler",
+        ":timestamp_write_handler",
+        "//async:async_context",
+        "@abseil-cpp//absl/base:nullability",
+        "@abseil-cpp//absl/functional:any_invocable",
+        "@abseil-cpp//absl/log:die_if_null",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings:string_view",
+    ],
+)
+
+cc_test(
+    name = "templated_crypto_write_handler_test",
+    srcs = ["templated_crypto_write_handler_test.cc"],
+    deps = [
+        ":algorithms",
+        ":crypto_write_handler",
+        ":hash",
+        ":templated_crypto_write_handler",
+        "//crypto/default:hasher",
+        "//testing:create_test_crypto_write_handler",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings:string_view",
+        "@abseil-cpp//absl/synchronization",
+        "@abseil-cpp//absl/time",
+        "@abseil-cpp//absl/time:simulated_clock",
+        "@googletest//:gtest_main",
+    ],
+)
diff --git a/crypto/crypto_read_handler.h b/crypto/crypto_read_handler.h
index d2ab5ea..fdeb26c 100644
--- a/crypto/crypto_read_handler.h
+++ b/crypto/crypto_read_handler.h
@@ -136,12 +136,14 @@
   // 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`).
+  // certificate (`certificate_der`) at the specified `asserted_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`.
+  // by the issuer or a delegated responder, conforms to RFC 6960 and C2PA
+  // requirements, matches the target certificate, and is valid for
+  // `asserted_time` and the current time (specifically, the current time is not
+  // earlier than `thisUpdate` and either `asserted_time < thisUpdate` or
+  // `asserted_time` is within the `[thisUpdate, nextUpdate)` interval).
   //
   // Arguments:
   //   ocsp_response_der: The DER-encoded OCSP response.
@@ -149,22 +151,24 @@
   //     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.
+  //   asserted_time: The time at which the certificate's revocation status is
+  //     evaluated. This is the time from the trusted timestamp, if any, and
+  //     otherwise the current time.
   //
   // 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`).
+  //   - OUT_OF_RANGE if `asserted_time` or the current 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;
+      absl::Time asserted_time) const = 0;
 
   // Returns the trust environment of this handler.
   virtual TrustEnvironment trust_environment() const = 0;
diff --git a/crypto/crypto_write_handler.h b/crypto/crypto_write_handler.h
new file mode 100644
index 0000000..8837324
--- /dev/null
+++ b/crypto/crypto_write_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_CRYPTO_WRITE_HANDLER_H_
+#define THIRD_PARTY_CREDENTIO_CRYPTO_CRYPTO_WRITE_HANDLER_H_
+
+#include <cstdint>
+#include <string>
+#include <vector>
+
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "crypto/algorithms.h"
+#include "crypto/timestamp_write_handler.h"
+
+namespace credentio {
+
+struct SignResponse {
+  // For COSE usage, this is the `signature` field of the COSE_Sign1[_Tagged]
+  // structure.
+  std::string signature;
+};
+
+class FixedKeySigner {
+ public:
+  virtual ~FixedKeySigner() = default;
+  // Returns the signing algorithm.
+  virtual SigningAlgorithm algorithm() const = 0;
+  // Returns the expected maximum size of signatures produced by Sign().
+  virtual uint64_t max_signature_byte_size() const = 0;
+  // Returns the DER-encoded certificate chain.
+  virtual const std::vector<std::string>& certificate_chain() const = 0;
+  // Returns OCSP responses to staple, for certificates in the certificate
+  // chain.
+  virtual const std::vector<std::string>& ocsp_responses() const = 0;
+  // For COSE usage, `to_be_signed` is the serialized Sig_Structure, as
+  // specified in RFC 8152, Section 14.
+  virtual absl::StatusOr<SignResponse> Sign(
+      absl::string_view to_be_signed) const = 0;
+};
+
+// A handler that performs cryptographic operations required for producing
+// C2PA claim signatures (specifically, signing and timestamping).
+class CryptoWriteHandler : public TimestampWriteHandler {
+ public:
+  ~CryptoWriteHandler() override = default;
+  virtual const FixedKeySigner& GetSigner() = 0;
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_CRYPTO_CRYPTO_WRITE_HANDLER_H_
diff --git a/crypto/default/BUILD b/crypto/default/BUILD
index 85a1f98..f98ef6d 100644
--- a/crypto/default/BUILD
+++ b/crypto/default/BUILD
@@ -110,6 +110,7 @@
         ":compliance_checker",
         "//crypto:algorithms",
         "@abseil-cpp//absl/base:no_destructor",
+        "@abseil-cpp//absl/functional:overload",
         "@abseil-cpp//absl/status",
         "@abseil-cpp//absl/status:status_macros",
         "@abseil-cpp//absl/status:statusor",
@@ -118,10 +119,19 @@
         "@abseil-cpp//absl/time",
         "@boringssl//:crypto",
         "@tink_cc//proto:common_cc_proto",
-        "@tink_cc//tink:cleartext_keyset_handle",
+        "@tink_cc//tink:key_status",
+        "@tink_cc//tink:keyset_handle",
+        "@tink_cc//tink:partial_key_access",
         "@tink_cc//tink:public_key_verify",
+        "@tink_cc//tink/pem:signature_key_parser",
         "@tink_cc//tink/signature:config_v0",
-        "@tink_cc//tink/signature:signature_pem_keyset_reader",
+        "@tink_cc//tink/signature:ecdsa_parameters",
+        "@tink_cc//tink/signature:ecdsa_public_key",
+        "@tink_cc//tink/signature:ed25519_parameters",
+        "@tink_cc//tink/signature:ed25519_public_key",
+        "@tink_cc//tink/signature:rsa_ssa_pss_parameters",
+        "@tink_cc//tink/signature:rsa_ssa_pss_public_key",
+        "@tink_cc//tink/signature:signature_config",
     ],
 )
 
@@ -131,6 +141,7 @@
     deps = [
         ":x509_certificate",
         "//crypto:algorithms",
+        "@abseil-cpp//absl/log:absl_check",
         "@abseil-cpp//absl/log:check",
         "@abseil-cpp//absl/status",
         "@abseil-cpp//absl/status:status_matchers",
@@ -140,7 +151,9 @@
         "@boringssl//:crypto",
         "@googletest//:gtest_main",
         "@tink_cc//proto:common_cc_proto",
-        "@tink_cc//tink/signature:signature_pem_keyset_reader",
+        "@tink_cc//tink/signature:ecdsa_parameters",
+        "@tink_cc//tink/signature:ed25519_parameters",
+        "@tink_cc//tink/signature:rsa_ssa_pss_parameters",
     ],
 )
 
@@ -278,6 +291,7 @@
         "@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",
         "@boringssl//:pki",
diff --git a/crypto/default/cms/BUILD b/crypto/default/cms/BUILD
index 3ab85a1..a94c25f 100644
--- a/crypto/default/cms/BUILD
+++ b/crypto/default/cms/BUILD
@@ -61,7 +61,7 @@
     deps = [
         ":cms_parser",
         "//testing:test_file_utils",
-        "@abseil-cpp//absl/log:check",
+        "@abseil-cpp//absl/log:absl_check",
         "@abseil-cpp//absl/strings",
         "@boringssl//:crypto",
     ],
diff --git a/crypto/default/cms/cms_test_helper.cc b/crypto/default/cms/cms_test_helper.cc
index d224183..2e4ab48 100644
--- a/crypto/default/cms/cms_test_helper.cc
+++ b/crypto/default/cms/cms_test_helper.cc
@@ -20,7 +20,7 @@
 #include <memory>
 #include <string>
 
-#include "absl/log/check.h"
+#include "absl/log/absl_check.h"
 #include "absl/strings/escaping.h"
 #include "absl/strings/str_cat.h"
 #include "absl/strings/string_view.h"
@@ -35,14 +35,14 @@
 
 namespace credentio_cms {
 
-constexpr absl::string_view kTestRepoDir = "c2pa/";
+constexpr absl::string_view kTestRepoDir = "credentio/";
 
 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();
+  ABSL_CHECK(contents.ok())
+      << "Failed to load: " << path << ", " << contents.status();
   return *contents;
 }
 
@@ -81,9 +81,9 @@
   if (pos == std::string::npos) {
     pos = message.find("name=smime.p7c");
   }
-  CHECK(pos != std::string::npos);
+  ABSL_CHECK(pos != std::string::npos);
   auto start = message.find("\x0d\x0a\x0d\x0a", pos);
-  CHECK(start != std::string::npos);
+  ABSL_CHECK(start != std::string::npos);
   start += 2;
   auto end = message.find('-', start);
   if (end == std::string::npos) {
@@ -93,7 +93,8 @@
     end = message.size();
   }
   std::string decoded;
-  CHECK(absl::Base64Unescape(message.substr(start, end - start), &decoded));
+  ABSL_CHECK(
+      absl::Base64Unescape(message.substr(start, end - start), &decoded));
   return decoded;
 }
 
@@ -104,25 +105,25 @@
   int boundary_end;
   if (boundary_start == std::string::npos) {
     boundary_start = contents.find(kUnquotedBoundary);
-    CHECK_NE(std::string::npos, boundary_start);
+    ABSL_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);
+  ABSL_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);
+  ABSL_CHECK_NE(std::string::npos, part_start);
   part_start = contents.find("\r\n", part_start + boundary.size());
-  CHECK_NE(std::string::npos, part_start);
+  ABSL_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);
+  ABSL_CHECK_NE(std::string::npos, part_end);
+  ABSL_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);
 }
@@ -137,7 +138,7 @@
   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));
+  ABSL_CHECK_EQ(size, BIO_read(bio, buffer.get(), size));
   line.assign(&buffer[0], size);
   X509_NAME_free(name);
   BIO_free_all(bio);
@@ -163,17 +164,17 @@
 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));
+  ABSL_CHECK(CBB_init(&cbb, 0));
+  ABSL_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));
+  ABSL_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));
+  ABSL_CHECK(CBS_copy_bytes(
+      &raw_oid_cbs, reinterpret_cast<uint8_t*>(ret.data()), raw_oid_len));
   CBB_cleanup(&cbb);
   return ret;
 }
diff --git a/crypto/default/default_crypto_read_handler.cc b/crypto/default/default_crypto_read_handler.cc
index b92dd8d..bbd4ff3 100644
--- a/crypto/default/default_crypto_read_handler.cc
+++ b/crypto/default/default_crypto_read_handler.cc
@@ -15,7 +15,10 @@
 
 #include "crypto/default/default_crypto_read_handler.h"
 
+#include <algorithm>
 #include <cstddef>
+#include <cstdint>
+#include <ctime>
 #include <memory>
 #include <optional>
 #include <string>
@@ -32,6 +35,7 @@
 #include "absl/status/statusor.h"
 #include "absl/strings/str_cat.h"
 #include "absl/strings/string_view.h"
+#include "absl/time/clock_interface.h"
 #include "absl/time/time.h"
 #include "absl/types/span.h"
 #include "cose/simple_cms_parser.h"
@@ -40,15 +44,73 @@
 #include "crypto/default/eku_verifier.h"
 #include "crypto/default/trust_store.h"
 #include "crypto/default/x509_certificate.h"
+#include "openssl/bytestring.h"
 #include "openssl/pki/ocsp.h"
 #include "openssl/pki/verify.h"
 #include "openssl/pki/verify_error.h"
+#include "openssl/posix_time.h"
 #include "tsp/timestamp_verifier.h"
 #include "tsp/verified_timestamp.h"
 
 namespace credentio {
 namespace {
 
+// Maximum clock skew tolerated when checking `current_time >= thisUpdate`.
+constexpr absl::Duration kOcspClockSkewTolerance = absl::Minutes(10);
+
+// Extracts the `thisUpdate` time from the first `SingleResponse` in a
+// DER-encoded `OCSPResponse` (RFC 6960 Section 4.2.1), or returns
+// `std::nullopt` if the response cannot be parsed.
+std::optional<absl::Time> ParseOcspThisUpdate(
+    absl::string_view ocsp_response_der) {
+  CBS cbs;
+  CBS_init(&cbs, reinterpret_cast<const uint8_t*>(ocsp_response_der.data()),
+           ocsp_response_der.size());
+  CBS response_seq, response_bytes_wrapper, response_bytes,
+      basic_response_octets, basic_response, tbs_response_data;
+  if (!CBS_get_asn1(&cbs, &response_seq, CBS_ASN1_SEQUENCE) ||
+      !CBS_get_asn1(&response_seq, /*out=*/nullptr, CBS_ASN1_ENUMERATED) ||
+      !CBS_get_asn1(&response_seq, &response_bytes_wrapper,
+                    CBS_ASN1_CONTEXT_SPECIFIC | CBS_ASN1_CONSTRUCTED | 0) ||
+      !CBS_get_asn1(&response_bytes_wrapper, &response_bytes,
+                    CBS_ASN1_SEQUENCE) ||
+      !CBS_get_asn1(&response_bytes, /*out=*/nullptr, CBS_ASN1_OBJECT) ||
+      !CBS_get_asn1(&response_bytes, &basic_response_octets,
+                    CBS_ASN1_OCTETSTRING) ||
+      !CBS_get_asn1(&basic_response_octets, &basic_response,
+                    CBS_ASN1_SEQUENCE) ||
+      !CBS_get_asn1(&basic_response, &tbs_response_data, CBS_ASN1_SEQUENCE)) {
+    return std::nullopt;
+  }
+
+  CBS responses, single_response, this_update_cbs;
+  if (!CBS_get_optional_asn1(
+          &tbs_response_data, /*out=*/nullptr, /*out_present=*/nullptr,
+          CBS_ASN1_CONTEXT_SPECIFIC | CBS_ASN1_CONSTRUCTED | 0) ||
+      !CBS_get_any_asn1(&tbs_response_data, /*out=*/nullptr,
+                        /*out_tag=*/nullptr) ||
+      !CBS_get_asn1(&tbs_response_data, /*out=*/nullptr,
+                    CBS_ASN1_GENERALIZEDTIME) ||
+      !CBS_get_asn1(&tbs_response_data, &responses, CBS_ASN1_SEQUENCE) ||
+      !CBS_get_asn1(&responses, &single_response, CBS_ASN1_SEQUENCE) ||
+      !CBS_get_asn1(&single_response, /*out=*/nullptr, CBS_ASN1_SEQUENCE) ||
+      !CBS_get_any_asn1(&single_response, /*out=*/nullptr,
+                        /*out_tag=*/nullptr) ||
+      !CBS_get_asn1(&single_response, &this_update_cbs,
+                    CBS_ASN1_GENERALIZEDTIME)) {
+    return std::nullopt;
+  }
+
+  struct tm tm;
+  int64_t this_update_posix;
+  if (!CBS_parse_generalized_time(&this_update_cbs, &tm,
+                                  /*allow_timezone_offset=*/0) ||
+      !OPENSSL_tm_to_posix(&tm, &this_update_posix)) {
+    return std::nullopt;
+  }
+  return absl::FromUnixSeconds(this_update_posix);
+}
+
 class DefaultParsedCertificates : public ParsedCertificates {
  public:
   DefaultParsedCertificates() = default;
@@ -227,14 +289,15 @@
       absl_nullable std::unique_ptr<bssl::VerifyTrustStore>
           legacy_claim_signer_roots,
       absl_nullable std::unique_ptr<bssl::VerifyTrustStore> tsa_roots,
-      TrustEnvironment trust_environment)
+      TrustEnvironment trust_environment, absl::Clock* absl_nonnull clock)
       : 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) {}
+        trust_environment_(trust_environment),
+        clock_(*clock) {}
   ~DefaultCryptoReadHandler() override = default;
 
   absl::StatusOr<std::unique_ptr<ParsedCertificates>> ParseCertificatesDer(
@@ -243,7 +306,26 @@
   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 {
+      absl::Time asserted_time) const override {
+    absl::Time verify_time = asserted_time;
+    if (std::optional<absl::Time> this_update =
+            ParseOcspThisUpdate(ocsp_response_der);
+        this_update.has_value()) {
+      // Per C2PA 2.4 Section 15.9.1, an OCSP response is valid if:
+      // 1. `current_time >= thisUpdate` (with `kOcspClockSkewTolerance`
+      //    tolerance for clock skew), and
+      // 2. `asserted_time < thisUpdate` OR `asserted_time` falls within
+      //    `[thisUpdate, nextUpdate)`.
+      if (clock_.TimeNow() + kOcspClockSkewTolerance < *this_update) {
+        return absl::OutOfRangeError(
+            "current_time is earlier than the OCSP response's thisUpdate.");
+      }
+      // `bssl::CheckOCSP` checks `thisUpdate <= verify_time < nextUpdate`, so
+      // clamping `verify_time` to at least `thisUpdate` allows
+      // `asserted_time < thisUpdate` while still checking
+      // `asserted_time < nextUpdate`.
+      verify_time = std::max(asserted_time, *this_update);
+    }
     bssl::OCSPVerifyResult::ResponseStatus response_status;
     bssl::OCSPRevocationStatus revocation_status = bssl::CheckOCSP(
         ocsp_response_der, certificate_der, issuer_certificate_der,
@@ -295,6 +377,7 @@
   std::unique_ptr<TimestampVerifier> timestamp_verifier_;
   std::unique_ptr<SimpleCmsParser> cms_parser_;
   TrustEnvironment trust_environment_;
+  absl::Clock& clock_;
 };
 
 }  // namespace
@@ -331,9 +414,13 @@
 
 absl::StatusOr<std::unique_ptr<CryptoReadHandler>>
 CreateDefaultCryptoReadHandler(const DefaultCryptoReadHandlerOptions& options) {
+  if (options.clock == nullptr) {
+    return absl::InvalidArgumentError("clock must not be null");
+  }
   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.skip_claim_signer_trust_checks_for_test &&
+      options.trust_environment != CryptoReadHandler::TrustEnvironment::kAny) {
     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(
@@ -346,14 +433,15 @@
                               options.legacy_claim_signer_trust_anchors_pem)));
   }
   std::unique_ptr<bssl::VerifyTrustStore> tsa_roots;
-  if (!options.skip_tsa_trust_checks_for_test) {
+  if (!options.skip_tsa_trust_checks_for_test &&
+      options.trust_environment != CryptoReadHandler::TrustEnvironment::kAny) {
     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);
+      std::move(tsa_roots), options.trust_environment, options.clock);
 }
 
 }  // namespace credentio
diff --git a/crypto/default/default_crypto_read_handler.h b/crypto/default/default_crypto_read_handler.h
index 005872a..63edcf6 100644
--- a/crypto/default/default_crypto_read_handler.h
+++ b/crypto/default/default_crypto_read_handler.h
@@ -20,6 +20,7 @@
 
 #include "absl/status/statusor.h"
 #include "absl/strings/string_view.h"
+#include "absl/time/clock_interface.h"
 #include "crypto/crypto_read_handler.h"
 
 namespace credentio {
@@ -30,7 +31,8 @@
   //
   // 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.
+  // `skip_claim_signer_trust_checks_for_test` is true or `trust_environment`
+  // is `TrustEnvironment::kAny`.
   absl::string_view claim_signer_trust_anchors_pem;
 
   // The content of the PEM file containing legacy claim signer trust anchors,
@@ -40,7 +42,8 @@
   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.
+  // Required unless `skip_tsa_trust_checks_for_test` is true or
+  // `trust_environment` is `TrustEnvironment::kAny`.
   absl::string_view tsa_trust_anchors_pem;
 
   // Skips claim signer trust checks.
@@ -52,6 +55,9 @@
   // The trust level of the handler.
   CryptoReadHandler::TrustEnvironment trust_environment =
       CryptoReadHandler::TrustEnvironment::kUnspecified;
+
+  // Clock to use (e.g., for OCSP response validity checks).
+  absl::Clock* clock = &absl::Clock::GetRealClock();
 };
 
 // Creates an implementation of CryptoReadHandler intended for use in
diff --git a/crypto/default/trust_store_test.cc b/crypto/default/trust_store_test.cc
index d5bafb5..e6a53da 100644
--- a/crypto/default/trust_store_test.cc
+++ b/crypto/default/trust_store_test.cc
@@ -32,7 +32,7 @@
 using ::testing::HasSubstr;
 
 constexpr absl::string_view kTrustAnchors =
-    "c2pa/testing/testdata/keys/trust_anchors.pem";
+    "credentio/testing/testdata/keys/trust_anchors.pem";
 
 TEST(LoadTrustStore, Success) {
   auto pem = credentio_testing::GetContents(kTrustAnchors);
diff --git a/crypto/default/x509_certificate.cc b/crypto/default/x509_certificate.cc
index 7f42d85..d976b5c 100644
--- a/crypto/default/x509_certificate.cc
+++ b/crypto/default/x509_certificate.cc
@@ -20,9 +20,11 @@
 #include <memory>
 #include <string>
 #include <utility>
+#include <variant>
 #include <vector>
 
 #include "absl/base/no_destructor.h"
+#include "absl/functional/overload.h"
 #include "absl/status/status.h"
 #include "absl/status/status_macros.h"
 #include "absl/status/statusor.h"
@@ -44,16 +46,29 @@
 #include "openssl/rsa.h"
 #include "openssl/x509.h"
 #include "proto/common.pb.h"
-#include "tink/cleartext_keyset_handle.h"
+#include "tink/key_status.h"
+#include "tink/keyset_handle.h"
+#include "tink/partial_key_access.h"
+#include "tink/pem/signature_key_parser.h"
 #include "tink/public_key_verify.h"
 #include "tink/signature/config_v0.h"
-#include "tink/signature/signature_pem_keyset_reader.h"
+#include "tink/signature/ecdsa_parameters.h"
+#include "tink/signature/ecdsa_public_key.h"
+#include "tink/signature/ed25519_parameters.h"
+#include "tink/signature/ed25519_public_key.h"
+#include "tink/signature/rsa_ssa_pss_parameters.h"
+#include "tink/signature/rsa_ssa_pss_public_key.h"
+#include "tink/signature/signature_config.h"
 
 namespace credentio {
 namespace {
 
-using ::crypto::tink::PemKeyParams;
-using ::crypto::tink::SignaturePemKeysetReaderBuilder;
+using ::crypto::tink::BigInteger;
+using ::crypto::tink::EcdsaParameters;
+using ::crypto::tink::Ed25519Parameters;
+using ::crypto::tink::GetPartialKeyAccess;
+using ::crypto::tink::KeysetHandleBuilder;
+using ::crypto::tink::RsaSsaPssParameters;
 
 // c2pa-al from
 // https://github.com/c2pa-org/conformance-public/blob/main/docs/current/schemas/mib/oid.txt
@@ -194,6 +209,9 @@
 absl::Status X509Certificate::VerifySignature(
     absl::string_view signature, absl::string_view data,
     SigningAlgorithm algorithm) const {
+  static const absl::NoDestructor<absl::Status> registration_status(
+      []() { return crypto::tink::SignatureConfig::Register(); }());
+  ABSL_RETURN_IF_ERROR(*registration_status);
   bssl::UniquePtr<EVP_PKEY> pub_key(X509_get_pubkey(cert_.get()));
   if (pub_key == nullptr) {
     return absl::InvalidArgumentError(
@@ -201,31 +219,58 @@
   }
   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);
+  absl::StatusOr<std::vector<X509Certificate::TinkKeyParameters>> 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"));
+        params.status().code(),
+        absl::StrCat("Tink verification failed: ", params.status().message()));
   }
-  auto handle = crypto::tink::CleartextKeysetHandle::Read(*std::move(reader));
+  crypto::tink::KeysetHandleBuilder builder;
+  bool is_primary = true;
+
+  using TinkPublicKey = std::variant<crypto::tink::EcdsaPublicKey,
+                                     crypto::tink::RsaSsaPssPublicKey,
+                                     crypto::tink::Ed25519PublicKey>;
+
+  for (const auto& param : *params) {
+    absl::StatusOr<TinkPublicKey> public_key =
+        std::visit(absl::Overload(
+                       [&pem_str](const EcdsaParameters& params)
+                           -> absl::StatusOr<TinkPublicKey> {
+                         return tink_pem::PemToEcdsaPublicKey(
+                             pem_str, params, GetPartialKeyAccess());
+                       },
+                       [&pem_str](const RsaSsaPssParameters& params)
+                           -> absl::StatusOr<TinkPublicKey> {
+                         return tink_pem::PemToRsaSsaPssPublicKey(
+                             pem_str, params, GetPartialKeyAccess());
+                       },
+                       [&pem_str](const Ed25519Parameters& params)
+                           -> absl::StatusOr<TinkPublicKey> {
+                         return tink_pem::PemToEd25519PublicKey(
+                             pem_str, params, GetPartialKeyAccess());
+                       }),
+                   param);
+    if (!public_key.ok()) {
+      return public_key.status();
+    }
+    std::visit(
+        [&builder, is_primary](const auto& key) {
+          builder.AddEntry(KeysetHandleBuilder::Entry::CreateFromCopyableKey(
+              key, crypto::tink::KeyStatus::kEnabled, is_primary));
+        },
+        *public_key);
+    is_primary = false;
+  }
+
+  auto handle = builder.Build();
   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>(
+  auto verifier = handle->GetPrimitive<crypto::tink::PublicKeyVerify>(
       crypto::tink::ConfigSignatureV0());
   if (!verifier.ok()) {
     return absl::Status(
@@ -278,87 +323,139 @@
   return absl::FromUnixSeconds(posix_time);
 }
 
-absl::StatusOr<std::vector<PemKeyParams>> X509Certificate::GetPemKeyParams(
-    SigningAlgorithm algorithm) const {
+namespace {
+
+absl::StatusOr<RsaSsaPssParameters> GetRsaSsaPssParameters(
+    size_t key_size_in_bits, SigningAlgorithm algorithm,
+    const BigInteger& public_exponent) {
+  crypto::tink::RsaSsaPssParameters::HashType hash_type;
+  int salt_length;
+  switch (algorithm) {
+    case SigningAlgorithm::kPs256:
+      hash_type = RsaSsaPssParameters::HashType::kSha256;
+      salt_length = 32;
+      break;
+    case SigningAlgorithm::kPs384:
+      hash_type = RsaSsaPssParameters::HashType::kSha384;
+      salt_length = 48;
+      break;
+    case SigningAlgorithm::kPs512:
+      hash_type = RsaSsaPssParameters::HashType::kSha512;
+      salt_length = 64;
+      break;
+    default:
+      return absl::InvalidArgumentError(
+          absl::StrCat("Invalid signing algorithm: ", algorithm));
+  }
+
+  return RsaSsaPssParameters::Builder()
+      .SetModulusSizeInBits(key_size_in_bits)
+      .SetPublicExponent(public_exponent)
+      .SetSigHashType(hash_type)
+      .SetMgf1HashType(hash_type)
+      .SetSaltLengthInBytes(salt_length)
+      .SetVariant(RsaSsaPssParameters::Variant::kNoPrefix)
+      .Build();
+}
+
+absl::StatusOr<BigInteger> GetPublicExponent(EVP_PKEY& pub_key) {
+  RSA* rsa = EVP_PKEY_get0_RSA(&pub_key);
+  if (rsa == nullptr) {
+    return absl::InvalidArgumentError("Invalid RSA public key");
+  }
+  const BIGNUM* e = nullptr;
+  RSA_get0_key(rsa, /*n=*/nullptr, &e, /*d=*/nullptr);
+  if (e == nullptr) {
+    return absl::InvalidArgumentError("Invalid RSA public key exponent");
+  }
+  std::string e_bytes(BN_num_bytes(e), '\0');
+  BN_bn2bin(e, reinterpret_cast<uint8_t*>(e_bytes.data()));
+  return BigInteger(e_bytes);
+}
+
+absl::StatusOr<std::pair<EcdsaParameters, EcdsaParameters>> GetEcdsaParameters(
+    SigningAlgorithm algorithm) {
+  EcdsaParameters::HashType hash_type;
+  EcdsaParameters::CurveType curve_type;
+  switch (algorithm) {
+    case SigningAlgorithm::kEs256:
+      hash_type = EcdsaParameters::HashType::kSha256;
+      curve_type = EcdsaParameters::CurveType::kNistP256;
+      break;
+    case SigningAlgorithm::kEs384:
+      hash_type = EcdsaParameters::HashType::kSha384;
+      curve_type = EcdsaParameters::CurveType::kNistP384;
+      break;
+    case SigningAlgorithm::kEs512:
+      hash_type = EcdsaParameters::HashType::kSha512;
+      curve_type = EcdsaParameters::CurveType::kNistP521;
+      break;
+    default:
+      return absl::InvalidArgumentError(
+          absl::StrCat("Invalid signing algorithm: ", algorithm));
+  }
+  ABSL_ASSIGN_OR_RETURN(
+      EcdsaParameters der_ecdsa_params,
+      EcdsaParameters::Builder()
+          .SetCurveType(curve_type)
+          .SetHashType(hash_type)
+          .SetSignatureEncoding(EcdsaParameters::SignatureEncoding::kDer)
+          .SetVariant(EcdsaParameters::Variant::kNoPrefix)
+          .Build());
+  ABSL_ASSIGN_OR_RETURN(
+      EcdsaParameters ieee_ecdsa_params,
+      EcdsaParameters::Builder()
+          .SetCurveType(curve_type)
+          .SetHashType(hash_type)
+          .SetSignatureEncoding(EcdsaParameters::SignatureEncoding::kIeeeP1363)
+          .SetVariant(EcdsaParameters::Variant::kNoPrefix)
+          .Build());
+  return std::make_pair(ieee_ecdsa_params, der_ecdsa_params);
+}
+
+}  // namespace
+
+absl::StatusOr<std::vector<X509Certificate::TinkKeyParameters>>
+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::kPs512: {
+      ABSL_ASSIGN_OR_RETURN(crypto::tink::BigInteger public_exponent,
+                            GetPublicExponent(*pub_key));
+      size_t key_size_in_bytes = EVP_PKEY_size(pub_key.get());
+      absl::StatusOr<RsaSsaPssParameters> params = GetRsaSsaPssParameters(
+          key_size_in_bytes * 8, algorithm, public_exponent);
+      if (!params.ok()) {
+        return absl::Status(params.status().code(),
+                            absl::StrCat(params.status().message(),
+                                         "; Failed to get RSA-PSS parameters"));
+      }
+      return std::vector<X509Certificate::TinkKeyParameters>{
+          *std::move(params)};
+    }
     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,
-      }};
+    case SigningAlgorithm::kEs512: {
+      ABSL_ASSIGN_OR_RETURN(auto params, GetEcdsaParameters(algorithm));
+      return std::vector<X509Certificate::TinkKeyParameters>{params.first,
+                                                             params.second};
+    }
+    case SigningAlgorithm::kEdDsa: {
+      ABSL_ASSIGN_OR_RETURN(
+          Ed25519Parameters params,
+          Ed25519Parameters::Create(Ed25519Parameters::Variant::kNoPrefix));
+      return std::vector<X509Certificate::TinkKeyParameters>{params};
+    }
+    default:
+      return absl::InvalidArgumentError(
+          absl::StrCat("Invalid signing algorithm: ", algorithm));
   }
 }
 
diff --git a/crypto/default/x509_certificate.h b/crypto/default/x509_certificate.h
index 13279cb..cda0521 100644
--- a/crypto/default/x509_certificate.h
+++ b/crypto/default/x509_certificate.h
@@ -19,6 +19,7 @@
 #include <memory>
 #include <string>
 #include <utility>
+#include <variant>
 #include <vector>
 
 #include "absl/status/status.h"
@@ -28,13 +29,19 @@
 #include "crypto/algorithms.h"
 #include "openssl/base.h"
 #include "openssl/x509.h"
-#include "tink/signature/signature_pem_keyset_reader.h"
+#include "tink/signature/ecdsa_parameters.h"
+#include "tink/signature/ed25519_parameters.h"
+#include "tink/signature/rsa_ssa_pss_parameters.h"
 
 namespace credentio {
 
 // Class encapsulating the OpenSSL X.509 certificate.
 class X509Certificate {
  public:
+  using TinkKeyParameters = std::variant<crypto::tink::EcdsaParameters,
+                                         crypto::tink::RsaSsaPssParameters,
+                                         crypto::tink::Ed25519Parameters>;
+
   explicit X509Certificate(bssl::UniquePtr<X509> cert)
       : cert_(std::move(cert)) {}
   std::string GetSubject() const;
@@ -51,7 +58,7 @@
                                SigningAlgorithm algorithm) const;
   absl::StatusOr<absl::Time> StartTime() const;
   absl::StatusOr<absl::Time> EndTime() const;
-  absl::StatusOr<std::vector<crypto::tink::PemKeyParams>> GetPemKeyParams(
+  absl::StatusOr<std::vector<TinkKeyParameters>> GetPemKeyParams(
       SigningAlgorithm algorithm) const;
   absl::StatusOr<std::string> GetSerialNumberHex() const;
   absl::StatusOr<std::string> GetAssuranceLevel() const;
diff --git a/crypto/default/x509_certificate_test.cc b/crypto/default/x509_certificate_test.cc
index 0cd976e..d970024 100644
--- a/crypto/default/x509_certificate_test.cc
+++ b/crypto/default/x509_certificate_test.cc
@@ -19,6 +19,7 @@
 #include <utility>
 #include <vector>
 
+#include "absl/log/absl_check.h"
 #include "absl/log/check.h"
 #include "absl/status/status.h"
 #include "absl/status/status_matchers.h"
@@ -34,7 +35,9 @@
 #include "openssl/obj.h"
 #include "openssl/x509.h"
 #include "proto/common.pb.h"
-#include "tink/signature/signature_pem_keyset_reader.h"
+#include "tink/signature/ecdsa_parameters.h"
+#include "tink/signature/ed25519_parameters.h"
+#include "tink/signature/rsa_ssa_pss_parameters.h"
 
 namespace credentio {
 namespace {
@@ -42,10 +45,13 @@
 using ::absl_testing::IsOk;
 using ::absl_testing::IsOkAndHolds;
 using ::absl_testing::StatusIs;
+using ::crypto::tink::EcdsaParameters;
+using ::crypto::tink::Ed25519Parameters;
+using ::crypto::tink::RsaSsaPssParameters;
+using ::testing::ElementsAre;
 using ::testing::HasSubstr;
 using ::testing::IsEmpty;
 using ::testing::NotNull;
-using ::testing::SizeIs;
 
 constexpr absl::string_view kDerRsaBase64 =
     "MIIGDDCCA/SgAwIBAgIQf/"
@@ -140,41 +146,60 @@
 
 std::string Base64Unescape(absl::string_view base64) {
   std::string bytes;
-  CHECK(absl::Base64Unescape(base64, &bytes));
+  ABSL_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;
+  ABSL_ASSERT_OK(x509.status());
 
-  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);
+  const crypto::tink::BigInteger exponent(std::string("\001\000\005", 3));
 
-  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);
+  absl::StatusOr<std::vector<X509Certificate::TinkKeyParameters>> params_ps256 =
+      (*x509)->GetPemKeyParams(SigningAlgorithm::kPs256);
+  ABSL_ASSERT_OK(params_ps256.status());
+  absl::StatusOr<RsaSsaPssParameters> want_ps256_params =
+      RsaSsaPssParameters::Builder()
+          .SetModulusSizeInBits(2048)
+          .SetPublicExponent(exponent)
+          .SetMgf1HashType(RsaSsaPssParameters::HashType::kSha256)
+          .SetSigHashType(RsaSsaPssParameters::HashType::kSha256)
+          .SetSaltLengthInBytes(32)
+          .SetVariant(RsaSsaPssParameters::Variant::kNoPrefix)
+          .Build();
+  ABSL_ASSERT_OK(want_ps256_params.status());
+  EXPECT_THAT(*params_ps256, ElementsAre(*want_ps256_params));
 
-  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);
+  absl::StatusOr<std::vector<X509Certificate::TinkKeyParameters>> params_ps384 =
+      (*x509)->GetPemKeyParams(SigningAlgorithm::kPs384);
+  ABSL_ASSERT_OK(params_ps384.status());
+  absl::StatusOr<RsaSsaPssParameters> want_ps384_params =
+      RsaSsaPssParameters::Builder()
+          .SetModulusSizeInBits(2048)
+          .SetPublicExponent(exponent)
+          .SetMgf1HashType(RsaSsaPssParameters::HashType::kSha384)
+          .SetSigHashType(RsaSsaPssParameters::HashType::kSha384)
+          .SetVariant(RsaSsaPssParameters::Variant::kNoPrefix)
+          .SetSaltLengthInBytes(48)
+          .Build();
+  ABSL_ASSERT_OK(want_ps384_params.status());
+  EXPECT_THAT(*params_ps384, ElementsAre(*want_ps384_params));
+
+  absl::StatusOr<std::vector<X509Certificate::TinkKeyParameters>> params_ps512 =
+      (*x509)->GetPemKeyParams(SigningAlgorithm::kPs512);
+  ABSL_ASSERT_OK(params_ps512.status());
+  absl::StatusOr<RsaSsaPssParameters> want_ps512_params =
+      RsaSsaPssParameters::Builder()
+          .SetModulusSizeInBits(2048)
+          .SetPublicExponent(exponent)
+          .SetMgf1HashType(RsaSsaPssParameters::HashType::kSha512)
+          .SetSigHashType(RsaSsaPssParameters::HashType::kSha512)
+          .SetVariant(RsaSsaPssParameters::Variant::kNoPrefix)
+          .SetSaltLengthInBytes(64)
+          .Build();
+  ABSL_ASSERT_OK(want_ps512_params.status());
+  EXPECT_THAT(*params_ps512, ElementsAre(*want_ps512_params));
 
   EXPECT_THAT((*x509)->GetPemKeyParams(SigningAlgorithm::kEs256),
               StatusIs(absl::StatusCode::kInvalidArgument,
@@ -186,47 +211,73 @@
 
 TEST(X509CertificateTest, GetPemKeyParamsForEcdsaKey) {
   auto x509 = X509Certificate::Create(Base64Unescape(kDerEcdsaBase64));
-  ASSERT_THAT(x509.status(), IsOk());
-  std::vector<crypto::tink::PemKeyParams> params;
+  ABSL_ASSERT_OK(x509.status());
 
-  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);
+  absl::StatusOr<std::vector<X509Certificate::TinkKeyParameters>> params_es256 =
+      (*x509)->GetPemKeyParams(SigningAlgorithm::kEs256);
+  ABSL_ASSERT_OK(params_es256.status());
+  absl::StatusOr<EcdsaParameters> want_p256_der_ecdsa_params =
+      EcdsaParameters::Builder()
+          .SetCurveType(EcdsaParameters::CurveType::kNistP256)
+          .SetHashType(EcdsaParameters::HashType::kSha256)
+          .SetSignatureEncoding(EcdsaParameters::SignatureEncoding::kDer)
+          .SetVariant(EcdsaParameters::Variant::kNoPrefix)
+          .Build();
+  ABSL_ASSERT_OK(want_p256_der_ecdsa_params.status());
+  absl::StatusOr<EcdsaParameters> want_p256_ieee_ecdsa_params =
+      EcdsaParameters::Builder()
+          .SetCurveType(EcdsaParameters::CurveType::kNistP256)
+          .SetHashType(EcdsaParameters::HashType::kSha256)
+          .SetSignatureEncoding(EcdsaParameters::SignatureEncoding::kIeeeP1363)
+          .SetVariant(EcdsaParameters::Variant::kNoPrefix)
+          .Build();
+  ABSL_ASSERT_OK(want_p256_ieee_ecdsa_params.status());
+  EXPECT_THAT(*params_es256, ElementsAre(*want_p256_ieee_ecdsa_params,
+                                         *want_p256_der_ecdsa_params));
 
-  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);
+  absl::StatusOr<std::vector<X509Certificate::TinkKeyParameters>> params_es384 =
+      (*x509)->GetPemKeyParams(SigningAlgorithm::kEs384);
+  ABSL_ASSERT_OK(params_es384.status());
+  absl::StatusOr<EcdsaParameters> want_p384_der_ecdsa_params =
+      EcdsaParameters::Builder()
+          .SetCurveType(EcdsaParameters::CurveType::kNistP384)
+          .SetHashType(EcdsaParameters::HashType::kSha384)
+          .SetSignatureEncoding(EcdsaParameters::SignatureEncoding::kDer)
+          .SetVariant(EcdsaParameters::Variant::kNoPrefix)
+          .Build();
+  ABSL_ASSERT_OK(want_p384_der_ecdsa_params.status());
+  absl::StatusOr<EcdsaParameters> want_p384_ieee_ecdsa_params =
+      EcdsaParameters::Builder()
+          .SetCurveType(EcdsaParameters::CurveType::kNistP384)
+          .SetHashType(EcdsaParameters::HashType::kSha384)
+          .SetSignatureEncoding(EcdsaParameters::SignatureEncoding::kIeeeP1363)
+          .SetVariant(EcdsaParameters::Variant::kNoPrefix)
+          .Build();
+  ABSL_ASSERT_OK(want_p384_ieee_ecdsa_params.status());
+  EXPECT_THAT(*params_es384, ElementsAre(*want_p384_ieee_ecdsa_params,
+                                         *want_p384_der_ecdsa_params));
 
-  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);
+  absl::StatusOr<std::vector<X509Certificate::TinkKeyParameters>> params_es521 =
+      (*x509)->GetPemKeyParams(SigningAlgorithm::kEs512);
+  ABSL_ASSERT_OK(params_es521.status());
+  absl::StatusOr<EcdsaParameters> want_p521_der_ecdsa_params =
+      EcdsaParameters::Builder()
+          .SetCurveType(EcdsaParameters::CurveType::kNistP521)
+          .SetHashType(EcdsaParameters::HashType::kSha512)
+          .SetSignatureEncoding(EcdsaParameters::SignatureEncoding::kDer)
+          .SetVariant(EcdsaParameters::Variant::kNoPrefix)
+          .Build();
+  ABSL_ASSERT_OK(want_p521_der_ecdsa_params.status());
+  absl::StatusOr<EcdsaParameters> want_p521_ieee_ecdsa_params =
+      EcdsaParameters::Builder()
+          .SetCurveType(EcdsaParameters::CurveType::kNistP521)
+          .SetHashType(EcdsaParameters::HashType::kSha512)
+          .SetSignatureEncoding(EcdsaParameters::SignatureEncoding::kIeeeP1363)
+          .SetVariant(EcdsaParameters::Variant::kNoPrefix)
+          .Build();
+  ABSL_ASSERT_OK(want_p521_ieee_ecdsa_params.status());
+  EXPECT_THAT(*params_es521, ElementsAre(*want_p521_ieee_ecdsa_params,
+                                         *want_p521_der_ecdsa_params));
 
   EXPECT_THAT((*x509)->GetPemKeyParams(SigningAlgorithm::kPs256),
               StatusIs(absl::StatusCode::kInvalidArgument,
@@ -238,19 +289,14 @@
 
 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);
+  ABSL_ASSERT_OK(x509.status());
+  absl::StatusOr<std::vector<X509Certificate::TinkKeyParameters>> params_eddsa =
+      (*x509)->GetPemKeyParams(SigningAlgorithm::kEdDsa);
+  ABSL_ASSERT_OK(params_eddsa.status());
+  absl::StatusOr<Ed25519Parameters> want_params =
+      Ed25519Parameters::Create(Ed25519Parameters::Variant::kNoPrefix);
+  ABSL_ASSERT_OK(want_params.status());
+  EXPECT_THAT(*params_eddsa, ElementsAre(*want_params));
 
   EXPECT_THAT((*x509)->GetPemKeyParams(SigningAlgorithm::kPs256),
               StatusIs(absl::StatusCode::kInvalidArgument,
@@ -261,7 +307,7 @@
 }
 TEST(X509CertificateTest, VerifyEdSignedData) {
   auto x509 = X509Certificate::Create(Base64Unescape(kDerEdBase64));
-  ASSERT_THAT(x509.status(), IsOk());
+  ABSL_ASSERT_OK(x509.status());
   EXPECT_THAT(
       (*x509)->VerifySignature(Base64Unescape(kEdSignatureBase64),
                                "this is a test", SigningAlgorithm::kEdDsa),
@@ -270,7 +316,7 @@
 
 TEST(X509CertificateTest, VerifySignatureWithRsaKey) {
   auto x509 = X509Certificate::Create(Base64Unescape(kDerRsaBase64));
-  ASSERT_THAT(x509.status(), IsOk());
+  ABSL_ASSERT_OK(x509.status());
   // 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.
@@ -282,17 +328,17 @@
 
 TEST(X509CertificateTest, GetStartTime) {
   auto x509 = X509Certificate::Create(Base64Unescape(kDerRsaBase64));
-  ASSERT_THAT(x509.status(), IsOk());
+  ABSL_ASSERT_OK(x509.status());
   auto start_time = (*x509)->StartTime();
-  ASSERT_THAT(start_time.status(), IsOk());
+  ABSL_ASSERT_OK(start_time.status());
   EXPECT_EQ(absl::ToUnixSeconds(*start_time), 1704931200);
 }
 
 TEST(X509CertificateTest, GetEndTime) {
   auto x509 = X509Certificate::Create(Base64Unescape(kDerRsaBase64));
-  ASSERT_THAT(x509.status(), IsOk());
+  ABSL_ASSERT_OK(x509.status());
   auto end_time = (*x509)->EndTime();
-  ASSERT_THAT(end_time.status(), IsOk());
+  ABSL_ASSERT_OK(end_time.status());
   EXPECT_EQ(absl::ToUnixSeconds(*end_time), 1736553599);
 }
 
@@ -305,31 +351,31 @@
 
 TEST(X509CertificateTest, GetSerialNumberHexRsa) {
   auto x509 = X509Certificate::Create(Base64Unescape(kDerRsaBase64));
-  ASSERT_THAT(x509.status(), IsOk());
+  ABSL_ASSERT_OK(x509.status());
   auto serial_hex = (*x509)->GetSerialNumberHex();
-  ASSERT_THAT(serial_hex.status(), IsOk());
+  ABSL_ASSERT_OK(serial_hex.status());
   EXPECT_EQ(*serial_hex, "7ff2741fd1c6de43b5947a8e65709e9e");
 }
 
 TEST(X509CertificateTest, GetSerialNumberHexEcdsa) {
   auto x509 = X509Certificate::Create(Base64Unescape(kDerEcdsaBase64));
-  ASSERT_THAT(x509.status(), IsOk());
+  ABSL_ASSERT_OK(x509.status());
   auto serial_hex = (*x509)->GetSerialNumberHex();
-  ASSERT_THAT(serial_hex.status(), IsOk());
+  ABSL_ASSERT_OK(serial_hex.status());
   EXPECT_EQ(*serial_hex, "7024e6247605f1d65f1b477551d4fafcb5ed91e6");
 }
 
 TEST(X509CertificateTest, GetSerialNumberHexEdDsa) {
   auto x509 = X509Certificate::Create(Base64Unescape(kDerEdBase64));
-  ASSERT_THAT(x509.status(), IsOk());
+  ABSL_ASSERT_OK(x509.status());
   auto serial_hex = (*x509)->GetSerialNumberHex();
-  ASSERT_THAT(serial_hex.status(), IsOk());
+  ABSL_ASSERT_OK(serial_hex.status());
   EXPECT_EQ(*serial_hex, "667ad6b27de4f95c9931d3adda31f103867a40d2");
 }
 
 TEST(X509CertificateTest, GetAssuranceLevelNotFound) {
   auto x509 = X509Certificate::Create(Base64Unescape(kDerRsaBase64));
-  ASSERT_THAT(x509.status(), IsOk());
+  ABSL_ASSERT_OK(x509.status());
   EXPECT_THAT((*x509)->GetAssuranceLevel(),
               StatusIs(absl::StatusCode::kNotFound));
 }
@@ -369,7 +415,7 @@
 
 TEST(X509CertificateTest, GetConformingProductIdNotFound) {
   auto x509 = X509Certificate::Create(Base64Unescape(kDerRsaBase64));
-  ASSERT_THAT(x509.status(), IsOk());
+  ABSL_ASSERT_OK(x509.status());
   EXPECT_THAT((*x509)->GetConformingProductId(),
               StatusIs(absl::StatusCode::kNotFound));
 }
diff --git a/crypto/templated_crypto_write_handler.h b/crypto/templated_crypto_write_handler.h
new file mode 100644
index 0000000..666a5cb
--- /dev/null
+++ b/crypto/templated_crypto_write_handler.h
@@ -0,0 +1,91 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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_TEMPLATED_CRYPTO_WRITE_HANDLER_H_
+#define THIRD_PARTY_CREDENTIO_CRYPTO_TEMPLATED_CRYPTO_WRITE_HANDLER_H_
+
+#include <cstdint>
+#include <string>
+#include <utility>
+#include <vector>
+
+#include "absl/base/nullability.h"
+#include "absl/functional/any_invocable.h"
+#include "absl/log/die_if_null.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "async/async_context.h"
+#include "crypto/algorithms.h"
+#include "crypto/crypto_write_handler.h"
+#include "crypto/timestamp_write_handler.h"
+
+namespace credentio {
+
+// A FixedKeySigner that returns mock signatures for size estimation.
+class TemplatedFixedKeySigner : public FixedKeySigner {
+ public:
+  explicit TemplatedFixedKeySigner(
+      CryptoWriteHandler* absl_nonnull real_handler)
+      : real_handler_(*ABSL_DIE_IF_NULL(real_handler)) {}
+  SigningAlgorithm algorithm() const override {
+    return real_handler_.GetSigner().algorithm();
+  }
+  const std::vector<std::string>& certificate_chain() const override {
+    return real_handler_.GetSigner().certificate_chain();
+  }
+  const std::vector<std::string>& ocsp_responses() const override {
+    return real_handler_.GetSigner().ocsp_responses();
+  }
+  uint64_t max_signature_byte_size() const override {
+    return real_handler_.GetSigner().max_signature_byte_size();
+  }
+  absl::StatusOr<SignResponse> Sign(
+      absl::string_view to_be_signed) const override {
+    return SignResponse{.signature =
+                            std::string(max_signature_byte_size(), '\0')};
+  }
+
+ private:
+  CryptoWriteHandler& real_handler_;
+};
+
+// A CryptoWriteHandler that returns mock signatures and timestamps for size
+// estimation.
+class TemplatedCryptoWriteHandler : public CryptoWriteHandler {
+ public:
+  explicit TemplatedCryptoWriteHandler(
+      CryptoWriteHandler* absl_nonnull real_handler)
+      : real_handler_(*ABSL_DIE_IF_NULL(real_handler)), signer_(real_handler) {}
+  const FixedKeySigner& GetSigner() override { return signer_; }
+  void CreateTimestamp(HashAlgorithm hash_algorithm, std::string hashed_message,
+                       TimestampWriteHandler::Options options,
+                       absl::AnyInvocable<void(absl::StatusOr<std::string>) &&>
+                           cb) const override {
+    AsyncResolver<std::string>(options.async_context, std::move(cb))
+        .Return(std::string(max_timestamp_byte_size(hash_algorithm), '\0'));
+  }
+  uint64_t max_timestamp_byte_size(
+      HashAlgorithm hash_algorithm) const override {
+    return real_handler_.max_timestamp_byte_size(hash_algorithm);
+  }
+
+ private:
+  CryptoWriteHandler& real_handler_;
+  TemplatedFixedKeySigner signer_;
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_CRYPTO_TEMPLATED_CRYPTO_WRITE_HANDLER_H_
diff --git a/crypto/templated_crypto_write_handler_test.cc b/crypto/templated_crypto_write_handler_test.cc
new file mode 100644
index 0000000..dd1d604
--- /dev/null
+++ b/crypto/templated_crypto_write_handler_test.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 "crypto/templated_crypto_write_handler.h"
+
+#include <memory>
+#include <string>
+#include <utility>
+
+#include "absl/status/status_matchers.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "absl/synchronization/notification.h"
+#include "absl/time/simulated_clock.h"
+#include "absl/time/time.h"
+#include "crypto/algorithms.h"
+#include "crypto/crypto_write_handler.h"
+#include "crypto/default/hasher.h"
+#include "crypto/hash.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "testing/create_test_crypto_write_handler.h"
+
+namespace credentio {
+namespace {
+
+using ::testing::ElementsAreArray;
+using ::testing::IsEmpty;
+
+absl::StatusOr<std::string> GetTimestamp(CryptoWriteHandler& handler,
+                                         HashAlgorithm hash_algorithm,
+                                         absl::string_view hashed_message) {
+  absl::Notification done;
+  absl::StatusOr<std::string> result;
+  handler.CreateTimestamp(hash_algorithm, std::string(hashed_message),
+                          /*options=*/{},
+                          [&result, &done](absl::StatusOr<std::string> res) {
+                            result = std::move(res);
+                            done.Notify();
+                          });
+  done.WaitForNotification();
+  return result;
+}
+
+TEST(TemplatedCryptoWriteHandlerTest, Create) {
+  absl::StatusOr<std::unique_ptr<CryptoWriteHandler>> sign_handler =
+      CreateTestCryptoWriteHandler({});
+  ABSL_ASSERT_OK(sign_handler);
+  TemplatedCryptoWriteHandler mock_handler(sign_handler->get());
+  const FixedKeySigner& signer = (*sign_handler)->GetSigner();
+  EXPECT_EQ(signer.algorithm(), mock_handler.GetSigner().algorithm());
+  EXPECT_EQ(signer.max_signature_byte_size(),
+            mock_handler.GetSigner().max_signature_byte_size());
+  EXPECT_THAT(signer.certificate_chain(),
+              ElementsAreArray(mock_handler.GetSigner().certificate_chain()));
+  EXPECT_THAT(signer.ocsp_responses(),
+              ElementsAreArray(mock_handler.GetSigner().ocsp_responses()));
+  absl::StatusOr<SignResponse> real_signature = signer.Sign("to_be_signed");
+  ABSL_ASSERT_OK(real_signature);
+  absl::StatusOr<SignResponse> mock_signature =
+      mock_handler.GetSigner().Sign("to_be_signed");
+  ABSL_ASSERT_OK(mock_signature);
+  EXPECT_EQ(mock_signature->signature.size(), signer.max_signature_byte_size());
+
+  absl::StatusOr<std::unique_ptr<Hasher>> hasher =
+      CreateHasher(HashAlgorithm::kSha384);
+  ABSL_ASSERT_OK(hasher);
+  (*hasher)->Update(real_signature->signature);
+  std::string hashed_message = (*hasher)->Digest();
+  absl::StatusOr<std::string> mock_timestamp =
+      GetTimestamp(mock_handler, HashAlgorithm::kSha384, hashed_message);
+  ABSL_ASSERT_OK(mock_timestamp);
+  EXPECT_EQ(mock_timestamp->size(),
+            (*sign_handler)->max_timestamp_byte_size(HashAlgorithm::kSha384));
+}
+
+TEST(TemplatedCryptoWriteHandlerTest, OcspExpired) {
+  absl::SimulatedClock clock;
+  absl::StatusOr<std::unique_ptr<CryptoWriteHandler>> sign_handler =
+      CreateTestCryptoWriteHandler({.clock = &clock});
+  ABSL_ASSERT_OK(sign_handler);
+  TemplatedCryptoWriteHandler mock_handler(sign_handler->get());
+
+  clock.AdvanceTime(absl::Hours(24 * 365));
+  EXPECT_THAT(mock_handler.GetSigner().ocsp_responses(), IsEmpty());
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/crypto/timestamp_write_handler.h b/crypto/timestamp_write_handler.h
new file mode 100644
index 0000000..8a6a7a7
--- /dev/null
+++ b/crypto/timestamp_write_handler.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_CRYPTO_TIMESTAMP_WRITE_HANDLER_H_
+#define THIRD_PARTY_CREDENTIO_CRYPTO_TIMESTAMP_WRITE_HANDLER_H_
+
+#include <cstdint>
+#include <optional>
+#include <string>
+
+#include "absl/functional/any_invocable.h"
+#include "absl/status/statusor.h"
+#include "async/async_context.h"
+#include "crypto/algorithms.h"
+
+namespace credentio {
+// A producer of RFC3161 TimeStampTokens.
+class TimestampWriteHandler {
+ public:
+  struct Options {
+    // If set, the async context to use for the timestamping request.
+    AsyncContext async_context = AsyncContext();
+  };
+  // Creates an RFC 3161 TimeStampToken.
+  //
+  // Returns:
+  //  - UNAVAILABLE if timestamping is not available.
+  virtual void CreateTimestamp(
+      HashAlgorithm hash_algorithm, std::string hashed_message,
+      TimestampWriteHandler::Options options,
+      absl::AnyInvocable<void(absl::StatusOr<std::string>) &&> cb) const = 0;
+  // Returns an upper bound on the size of the serialized TimeStampTokens
+  // produced by this instance's CreateTimestamp. Implementations can use
+  // the helpers in timestamp_size.h.
+  virtual uint64_t max_timestamp_byte_size(
+      HashAlgorithm hash_algorithm) const = 0;
+  virtual ~TimestampWriteHandler() = default;
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_CRYPTO_TIMESTAMP_WRITE_HANDLER_H_
diff --git a/formats/BUILD b/formats/BUILD
index 20f7bd9..a9f0f01 100644
--- a/formats/BUILD
+++ b/formats/BUILD
@@ -156,3 +156,19 @@
         "@abseil-cpp//absl/strings:string_view",
     ],
 )
+
+cc_library(
+    name = "hasher_data",
+    srcs = ["hasher_data.cc"],
+    hdrs = ["hasher_data.h"],
+    deps = [
+        "//bindings:input_hasher",
+        "//crypto:algorithms",
+        "//proto:data_hash_assertion_cc_proto",
+        "@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/formats/bmff/BUILD b/formats/bmff/BUILD
index 584fc68..4ab750b 100644
--- a/formats/bmff/BUILD
+++ b/formats/bmff/BUILD
@@ -43,6 +43,28 @@
     ],
 )
 
+cc_test(
+    name = "box_header_test",
+    srcs = ["box_header_test.cc"],
+    deps = [
+        ":box_header",
+        ":constants",
+        ":test_utils",
+        "//constants:labels",
+        "//formats:byte_range",
+        "@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/bytes:writer",
+    ],
+)
+
 cc_library(
     name = "constants",
     hdrs = ["constants.h"],
@@ -192,3 +214,129 @@
         "@abseil-cpp//absl/status:statusor",
     ],
 )
+
+cc_library(
+    name = "offset_adjustment",
+    srcs = ["offset_adjustment.cc"],
+    hdrs = ["offset_adjustment.h"],
+    deps = [
+        ":box_header",
+        "@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/bytes:writer",
+        "@riegeli//riegeli/endian:endian_reading",
+        "@riegeli//riegeli/endian:endian_writing",
+    ],
+)
+
+cc_test(
+    name = "offset_adjustment_test",
+    srcs = ["offset_adjustment_test.cc"],
+    deps = [
+        ":box_header",
+        ":offset_adjustment",
+        "//testing:test_string_utils",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@abseil-cpp//absl/strings",
+        "@googletest//:gtest_main",
+        "@riegeli//riegeli/bytes:string_reader",
+        "@riegeli//riegeli/bytes:string_writer",
+    ],
+)
+
+cc_library(
+    name = "embedder",
+    srcs = ["embedder.cc"],
+    hdrs = ["embedder.h"],
+    deps = [
+        ":box_header",
+        ":box_matcher",
+        ":offset_adjustment",
+        "//bindings:input_hasher",
+        "//constants:labels",
+        "//formats:byte_range",
+        "//proto:bmff_based_hash_assertion_cc_proto",
+        "//utils:byte_instruction",
+        "//utils:byte_writers",
+        "@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",
+        "@abseil-cpp//absl/types:span",
+        "@riegeli//riegeli/bytes:reader",
+        "@riegeli//riegeli/bytes:string_writer",
+        "@riegeli//riegeli/bytes:writer",
+        "@riegeli//riegeli/endian:endian_writing",
+    ],
+)
+
+cc_library(
+    name = "hasher",
+    srcs = ["hasher.cc"],
+    hdrs = ["hasher.h"],
+    deps = [
+        "//bindings:binding_hasher",
+        "//bindings:bmff_binding_hasher",
+        "//crypto:algorithms",
+        "//proto:bmff_based_hash_assertion_cc_proto",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/strings",
+        "@riegeli//riegeli/bytes:reader",
+    ],
+)
+
+cc_test(
+    name = "hasher_test",
+    srcs = ["hasher_test.cc"],
+    deps = [
+        ":hasher",
+        ":test_utils",
+        "//constants:labels",
+        "//crypto:algorithms",
+        "//proto:bmff_based_hash_assertion_cc_proto",
+        "//testing:proto_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/bytes:string_reader",
+    ],
+)
+
+cc_library(
+    name = "writer",
+    srcs = ["writer.cc"],
+    hdrs = ["writer.h"],
+    deps = [
+        ":box_header",
+        ":embedder",
+        ":hasher",
+        "//async:async_context",
+        "//crypto:algorithms",
+        "//formats:byte_range",
+        "//generator",
+        "//proto:bmff_based_hash_assertion_cc_proto",
+        "//proto:generator_cc_proto",
+        "//utils:byte_instruction",
+        "@abseil-cpp//absl/base:nullability",
+        "@abseil-cpp//absl/functional:any_invocable",
+        "@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/synchronization",
+        "@riegeli//riegeli/bytes:reader",
+        "@riegeli//riegeli/bytes:string_writer",
+        "@riegeli//riegeli/bytes:writer",
+    ],
+)
diff --git a/formats/bmff/box_header_test.cc b/formats/bmff/box_header_test.cc
new file mode 100644
index 0000000..7f906e7
--- /dev/null
+++ b/formats/bmff/box_header_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/bmff/box_header.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 "constants/labels.h"
+#include "formats/bmff/constants.h"
+#include "formats/bmff/test_utils.h"
+#include "formats/byte_range.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "riegeli/bytes/string_reader.h"
+#include "riegeli/bytes/string_writer.h"
+#include "riegeli/bytes/writer.h"
+
+namespace credentio {
+namespace {
+
+using ::absl_testing::IsOk;
+using ::absl_testing::IsOkAndHolds;
+using ::absl_testing::StatusIs;
+using ::credentio_testing::BadBox;
+using ::credentio_testing::BigBox;
+using ::credentio_testing::Box;
+using ::credentio_testing::BoxFlags;
+using ::credentio_testing::BoxVersion;
+using ::credentio_testing::C2paUuidBox;
+using ::credentio_testing::UuidBoxPayload;
+using ::testing::AnyOf;
+using ::testing::ElementsAre;
+using ::testing::Eq;
+using ::testing::HasSubstr;
+using ::testing::IsEmpty;
+
+constexpr absl::string_view kZeroFlags("\x00\x00\x00", 3);
+
+absl::StatusOr<std::vector<BmffBoxHeader>> ReadAllBmffBoxHeaders(
+    absl::string_view contents) {
+  riegeli::StringReader<> reader(contents);
+  return ReadBmffBoxHeaders(reader);
+}
+
+class BoxHeaderTest : public testing::Test {
+ public:
+  absl::StatusOr<int64_t> WriteBox(const BmffBox& box, absl::string_view data,
+                                   std::string& destination,
+                                   bool start_with_error = false) {
+    riegeli::StringWriter<> writer(&destination);
+    if (start_with_error) {
+      writer.Fail(absl::InternalError("failed to write box header"));
+    }
+
+    ABSL_ASSIGN_OR_RETURN(int64_t written, WriteBmffBoxHeader(box, writer));
+    if (!writer.Write(data)) {
+      return writer.status();
+    }
+
+    if (!writer.Close()) {
+      return writer.status();
+    }
+    return written;
+  }
+
+  absl::Status ReadBox(absl::string_view data, BmffBoxHeader& header) {
+    riegeli::StringReader<> reader(data);
+    ABSL_ASSIGN_OR_RETURN(header, ReadBmffBoxHeader(reader));
+    return absl::OkStatus();
+  }
+};
+
+TEST_F(BoxHeaderTest, ReadAllBoxesWithEmptyContent) {
+  EXPECT_THAT(ReadAllBmffBoxHeaders(""), IsOkAndHolds(IsEmpty()));
+}
+
+TEST_F(BoxHeaderTest, ReadAllBoxesUUIDBox) {
+  std::string uuid_box = Box(
+      "uuid", UuidBoxPayload(kC2paBmffBoxUuid, BoxVersion(1), BoxFlags(2), ""));
+  EXPECT_THAT(ReadAllBmffBoxHeaders(uuid_box),
+              IsOkAndHolds(ElementsAre(BmffBoxHeader{
+                  .start = 0,
+                  .box_size = 28,     // header_size + 4 (version+flags)
+                  .header_size = 24,  // 4 (size) + 4 (type) + 16 (UUID)
+                  .version_and_flags_size = 4,
+                  .type = "uuid",
+                  .xpath = "/uuid[1]",
+                  .user_type = std::string(kC2paBmffBoxUuid),
+                  .version = 1,
+                  .flags = BoxFlags(2)})));
+}
+
+TEST_F(BoxHeaderTest, ReadAllBoxesFullBox) {
+  std::string uuid_box =
+      Box("pdin", absl::StrCat(BoxVersion(0), BoxFlags(0), ""));
+  EXPECT_THAT(ReadAllBmffBoxHeaders(uuid_box),
+              IsOkAndHolds(ElementsAre(BmffBoxHeader{
+                  .start = 0,
+                  .box_size = 12,    // header_size + 4 (version+flags)
+                  .header_size = 8,  // 4 (size) + 4 (type)
+                  .version_and_flags_size = 4,
+                  .type = "pdin",
+                  .xpath = "/pdin[1]",
+                  .user_type = "",
+                  .version = 0,
+                  .flags = std::string(kZeroFlags)})));
+}
+
+TEST_F(BoxHeaderTest, ReadAllBoxesTwoBoxes) {
+  std::string uuid_box = Box(
+      "uuid", UuidBoxPayload(kC2paBmffBoxUuid, BoxVersion(0), BoxFlags(0), ""));
+  std::string other_box = Box("blah", "abcd");
+  EXPECT_THAT(ReadAllBmffBoxHeaders(absl::StrCat(uuid_box, other_box)),
+              IsOkAndHolds(ElementsAre(
+                  BmffBoxHeader{.start = 0,
+                                .box_size = 28,
+                                .header_size = 24,
+                                .version_and_flags_size = 4,
+                                .type = "uuid",
+                                .xpath = "/uuid[1]",
+                                .user_type = std::string(kC2paBmffBoxUuid),
+                                .version = 0,
+                                .flags = std::string(kZeroFlags)},
+                  BmffBoxHeader{.start = 28,
+                                .box_size = 12,
+                                .header_size = 8,
+                                .type = "blah",
+                                .xpath = "/blah[1]"})));
+}
+
+TEST_F(BoxHeaderTest, ReadAllBoxesWithSubBoxes) {
+  std::string uuid = Box(
+      "uuid", UuidBoxPayload(kC2paBmffBoxUuid, BoxVersion(0), BoxFlags(0), ""));
+  std::string trak_trak1_trak1 = Box("trak", "abcd");      // box_size = 4+4+4;
+  std::string trak_trak1 = Box("trak", trak_trak1_trak1);  // box_size = 4+4+12;
+  std::string trak_trak2 = BigBox("trak", "efgh");  // box_size = (4+8)+4+4;
+  std::string moov = Box("moov", "moov");           // box_size = 4+4+4;
+  std::string trak1 = BigBox(
+      "trak", absl::StrCat(trak_trak1, moov,
+                           trak_trak2));    // box_size = (4+8)+4+(20+20+12);
+  std::string trak2 = Box("trak", "ijkl");  // box_size = 4+4+4;
+  EXPECT_THAT(ReadAllBmffBoxHeaders(absl::StrCat(uuid, trak1, trak2)),
+              IsOkAndHolds(ElementsAre(
+                  BmffBoxHeader{.start = 0,
+                                .box_size = 28,
+                                .header_size = 24,
+                                .version_and_flags_size = 4,
+                                .type = "uuid",
+                                .xpath = "/uuid[1]",
+                                .user_type = std::string(kC2paBmffBoxUuid),
+                                .version = 0,
+                                .flags = std::string(kZeroFlags)},
+                  BmffBoxHeader{.start = 28,
+                                .box_size = 68,
+                                .header_size = 16,
+                                .type = "trak",
+                                .xpath = "/trak[1]"},
+                  BmffBoxHeader{.start = 44,
+                                .box_size = 20,
+                                .header_size = 8,
+                                .type = "trak",
+                                .xpath = "/trak[1]/trak[1]"},
+                  BmffBoxHeader{.start = 52,
+                                .box_size = 12,
+                                .header_size = 8,
+                                .type = "trak",
+                                .xpath = "/trak[1]/trak[1]/trak[1]"},
+                  BmffBoxHeader{.start = 64,
+                                .box_size = 12,
+                                .header_size = 8,
+                                .type = "moov",
+                                .xpath = "/trak[1]/moov[1]"},
+                  BmffBoxHeader{.start = 76,
+                                .box_size = 20,
+                                .header_size = 16,
+                                .type = "trak",
+                                .xpath = "/trak[1]/trak[2]"},
+                  BmffBoxHeader{.start = 96,
+                                .box_size = 12,
+                                .header_size = 8,
+                                .type = "trak",
+                                .xpath = "/trak[2]"})));
+}
+
+TEST_F(BoxHeaderTest, ReadAllBoxesBoxExtendsBeyondParent) {
+  std::string input = Box("moov", BadBox("trak", "ijkl", /*data_size=*/100));
+  EXPECT_THAT(ReadAllBmffBoxHeaders(input),
+              StatusIs(absl::StatusCode::kDataLoss,
+                       HasSubstr("sub-box extends beyond the parent")));
+}
+
+TEST_F(BoxHeaderTest, ReadAllBoxesBoxExtendsBeyondInput) {
+  std::string input =
+      absl::StrCat(BadBox("trak", "ijkl", /*data_size=*/100), Box("moov", ""));
+  EXPECT_THAT(
+      ReadAllBmffBoxHeaders(input),
+      StatusIs(absl::StatusCode::kDataLoss, HasSubstr("truncated BMFF box")));
+}
+
+TEST_F(BoxHeaderTest, ReadAllBoxesMissing64bitSize) {
+  std::string moov =
+      BadBox("moov", "ijkl",
+             /*data_size=*/1);  // box size set to 1, indicating a 64-bit size,
+                                // but does not have a 64-bit size.
+  EXPECT_THAT(ReadAllBmffBoxHeaders(moov),
+              StatusIs(absl::StatusCode::kDataLoss,
+                       AnyOf(HasSubstr("kUnexpectedEof"),
+                             HasSubstr("failed to read box size"))));
+}
+
+TEST_F(BoxHeaderTest, ReadAllBoxesBoxDataSizeTooSmall) {
+  std::string moov =
+      BadBox("moov", "ijkl", /*data_size=*/2);  // data size must be at least 8.
+  EXPECT_THAT(ReadAllBmffBoxHeaders(moov),
+              StatusIs(absl::StatusCode::kDataLoss,
+                       AnyOf(HasSubstr("kInvalidData"),
+                             HasSubstr("unsupported box size"))));
+}
+
+TEST_F(BoxHeaderTest, ReadsQuickTimeMetaBox) {
+  std::string data = Box(
+      "meta", Box("hdlr", absl::StrCat(BoxVersion(0), BoxFlags(0), "abcd")));
+  EXPECT_THAT(ReadAllBmffBoxHeaders(data), IsOkAndHolds(ElementsAre(
+                                               BmffBoxHeader{
+                                                   .start = 0,
+                                                   .box_size = 24,
+                                                   .header_size = 8,
+                                                   .version_and_flags_size = 0,
+                                                   .type = "meta",
+                                                   .xpath = "/meta[1]",
+                                                   .user_type = "",
+                                                   .version = 0,
+                                                   .flags = "",
+                                               },
+                                               BmffBoxHeader{
+                                                   .start = 8,
+                                                   .box_size = 16,
+                                                   .header_size = 8,
+                                                   .version_and_flags_size = 4,
+                                                   .type = "hdlr",
+                                                   .xpath = "/meta[1]/hdlr[1]",
+                                                   .user_type = "",
+                                                   .version = 0,
+                                                   .flags = BoxFlags(0),
+                                               })));
+}
+
+TEST_F(BoxHeaderTest, ReadsIsoMetaBox) {
+  std::string data =
+      absl::StrCat(Box("meta", absl::StrCat(BoxVersion(0), BoxFlags(0))),
+                   Box("free", "padding"));
+  EXPECT_THAT(ReadAllBmffBoxHeaders(data), IsOkAndHolds(ElementsAre(
+                                               BmffBoxHeader{
+                                                   .start = 0,
+                                                   .box_size = 12,
+                                                   .header_size = 8,
+                                                   .version_and_flags_size = 4,
+                                                   .type = "meta",
+                                                   .xpath = "/meta[1]",
+                                                   .user_type = "",
+                                                   .version = 0,
+                                                   .flags = BoxFlags(0),
+                                               },
+                                               BmffBoxHeader{
+                                                   .start = 12,
+                                                   .box_size = 15,
+                                                   .header_size = 8,
+                                                   .version_and_flags_size = 0,
+                                                   .type = "free",
+                                                   .xpath = "/free[1]",
+                                                   .user_type = "",
+                                                   .version = 0,
+                                                   .flags = "",
+                                               })));
+}
+
+TEST_F(BoxHeaderTest, StopsEarly) {
+  std::string uuid_box = Box(
+      "uuid", UuidBoxPayload(kC2paBmffBoxUuid, BoxVersion(0), BoxFlags(0), ""));
+  std::string other_box = Box("blah", "abcd");
+  std::string data = absl::StrCat(uuid_box, other_box);
+
+  std::vector<BmffBoxHeader> result;
+  riegeli::StringReader<> reader(data);
+
+  EXPECT_THAT(IterateOverBmffBoxes(reader,
+                                   [&result](const BmffBoxHeader& box) {
+                                     result.push_back(box);
+                                     return false;  // Stop early!
+                                   }),
+              IsOk());
+
+  EXPECT_THAT(result, ElementsAre(BmffBoxHeader{
+                          .start = 0,
+                          .box_size = 28,
+                          .header_size = 24,
+                          .version_and_flags_size = 4,
+                          .type = "uuid",
+                          .xpath = "/uuid[1]",
+                          .user_type = std::string(kC2paBmffBoxUuid),
+                          .version = 0,
+                          .flags = std::string(kZeroFlags)}));
+}
+
+TEST_F(BoxHeaderTest, PropagatesError) {
+  std::string data = Box("blah", "abcd");
+
+  riegeli::StringReader<> reader(data);
+
+  auto status = IterateOverBmffBoxes(reader, [](const BmffBoxHeader& box) {
+    return absl::InternalError("custom error");
+  });
+
+  EXPECT_THAT(status, StatusIs(absl::StatusCode::kInternal, "custom error"));
+}
+
+TEST_F(BoxHeaderTest, IterateOverBmffBoxesStopsEarlyInSubBox) {
+  std::string sub_box = Box("blah", "abcd");
+  std::string container_box = Box("moov", sub_box);
+  std::string data = container_box;
+
+  std::vector<BmffBoxHeader> result;
+  riegeli::StringReader<> reader(data);
+
+  EXPECT_THAT(IterateOverBmffBoxes(reader,
+                                   [&result](const BmffBoxHeader& box) {
+                                     result.push_back(box);
+                                     if (box.type == "blah") {
+                                       return false;  // Stop early in sub-box!
+                                     }
+                                     return true;
+                                   }),
+              IsOk());
+
+  EXPECT_THAT(result, ElementsAre(BmffBoxHeader{.start = 0,
+                                                .box_size = 20,
+                                                .header_size = 8,
+                                                .type = "moov",
+                                                .xpath = "/moov[1]"},
+                                  BmffBoxHeader{.start = 8,
+                                                .box_size = 12,
+                                                .header_size = 8,
+                                                .type = "blah",
+                                                .xpath = "/moov[1]/blah[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_F(BoxHeaderTest, IterateOverBmffBoxesRequiresSize) {
+  NoSizeReader reader;
+  EXPECT_THAT(
+      IterateOverBmffBoxes(reader, [](const BmffBoxHeader&) { return true; }),
+      StatusIs(absl::StatusCode::kInvalidArgument,
+               HasSubstr("reader size cannot be determined")));
+}
+
+TEST_F(BoxHeaderTest, BoxSizeTooSmallForUuidExploit) {
+  std::string custom_uuid = absl::StrCat(
+      absl::string_view("\x00\x00\x00\x08", 4), "free", std::string(8, '\x00'));
+  ASSERT_EQ(custom_uuid.size(), 16);
+
+  std::string payload = absl::StrCat(custom_uuid, BoxVersion(0), BoxFlags(0));
+  std::string input = BadBox("uuid", payload, 8);
+
+  riegeli::StringReader<> reader(input);
+  std::vector<std::string> parsed_types;
+  auto status = IterateOverBmffBoxes(reader, [&](const BmffBoxHeader& box) {
+    parsed_types.push_back(box.type);
+    return true;
+  });
+
+  // We expect it to fail and NOT process the "free" box.
+  EXPECT_FALSE(status.ok());
+  EXPECT_THAT(parsed_types, IsEmpty());
+}
+
+TEST_F(BoxHeaderTest, BoxSizeTooSmallFor64BitHeader) {
+  std::string input =
+      absl::StrCat(absl::string_view("\x00\x00\x00\x01", 4), "blah",
+                   absl::string_view("\x00\x00\x00\x00\x00\x00\x00\x08", 8));
+
+  riegeli::StringReader<> reader(input);
+  std::vector<std::string> parsed_types;
+  auto status = IterateOverBmffBoxes(reader, [&](const BmffBoxHeader& box) {
+    parsed_types.push_back(box.type);
+    return true;
+  });
+
+  EXPECT_FALSE(status.ok());
+  EXPECT_THAT(parsed_types, IsEmpty());
+}
+
+TEST_F(BoxHeaderTest, GenericUuidBoxAtEndWithoutVersionFlags) {
+  std::string custom_uuid(16, '\x00');
+  std::string input = BadBox("uuid", custom_uuid, 24);
+
+  EXPECT_THAT(
+      ReadAllBmffBoxHeaders(input),
+      IsOkAndHolds(ElementsAre(BmffBoxHeader{.start = 0,
+                                             .box_size = 24,
+                                             .header_size = 24,
+                                             .version_and_flags_size = 0,
+                                             .type = "uuid",
+                                             .xpath = "/uuid[1]",
+                                             .user_type = custom_uuid,
+                                             .version = 0,
+                                             .flags = ""})));
+}
+
+std::string NestBoxes(absl::string_view type, int depth) {
+  std::string current_data = "";
+  for (int i = 0; i < depth; ++i) {
+    current_data = Box(type, current_data);
+  }
+  return current_data;
+}
+
+TEST_F(BoxHeaderTest, DeeplyNestedBoxes) {
+  std::string input = NestBoxes("moov", 100);
+  EXPECT_THAT(ReadAllBmffBoxHeaders(input),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("Too many nested BMFF boxes")));
+}
+
+using BoxHeaderContainerTest = testing::TestWithParam<std::string>;
+
+INSTANTIATE_TEST_SUITE_P(, BoxHeaderContainerTest,
+                         testing::Values("moov", "trak", "mdia", "minf", "stbl",
+                                         "moof", "traf", "edts", "udta", "dinf",
+                                         "tref", "treg", "mvex", "mfra", "meta",
+                                         "schi"),
+                         [](const testing::TestParamInfo<std::string>& info) {
+                           return info.param.data();
+                         });
+
+TEST_P(BoxHeaderContainerTest, IsContainerBox) {
+  EXPECT_TRUE(BmffBoxHeader{.type = GetParam()}.IsContainerBox());
+}
+
+TEST(BoxHeader, IsRootBox) {
+  {
+    BmffBoxHeader header = {};
+    EXPECT_TRUE(header.IsRootBox());
+  }
+  {
+    BmffBoxHeader header = {.xpath = "/"};
+    EXPECT_TRUE(header.IsRootBox());
+  }
+  {
+    BmffBoxHeader header = {.xpath = "/uuid"};
+    EXPECT_TRUE(header.IsRootBox());
+  }
+  {
+    BmffBoxHeader header = {.xpath = "/trak[1]"};
+    EXPECT_TRUE(header.IsRootBox());
+  }
+  {
+    BmffBoxHeader header = {.xpath = "/trak/moov"};
+    EXPECT_FALSE(header.IsRootBox());
+  }
+  {
+    BmffBoxHeader header = {.xpath = "/trak[1]/moov"};
+    EXPECT_FALSE(header.IsRootBox());
+  }
+}
+
+TEST_F(BoxHeaderTest, WriteUUIDBox) {
+  std::string data = "abcd";
+  BmffBox box{.type = "uuid",
+              .user_type = std::string(kC2paBmffBoxUuid),
+              .version = 1,
+              .flags = BoxFlags(2),
+              .data_size = data.size()};
+  std::string serialized;
+
+  EXPECT_THAT(WriteBox(box, data, serialized), IsOkAndHolds(28));
+
+  EXPECT_EQ(serialized.size(), BmffBoxHeaderSize(box) + data.size());
+  EXPECT_THAT(
+      ReadAllBmffBoxHeaders(serialized),
+      IsOkAndHolds(ElementsAre(BmffBoxHeader{
+          .start = 0,
+          .box_size = 28 + data.size(),  // header_size + 4 (version+flags)
+          .header_size = 24,             // 4 (size) + 4 (type) + 16 (UUID)
+          .version_and_flags_size = 4,
+          .type = "uuid",
+          .xpath = "/uuid[1]",
+          .user_type = std::string(kC2paBmffBoxUuid),
+          .version = 1,
+          .flags = BoxFlags(2)})));
+}
+
+TEST_F(BoxHeaderTest, WriteFullBox) {
+  std::string data = "abcd";
+  BmffBox box{.type = "pdin",
+              .user_type = "",
+              .version = 0,
+              .flags = std::string(kZeroFlags),
+              .data_size = data.size()};
+  std::string serialized;
+  EXPECT_THAT(WriteBox(box, data, serialized), IsOkAndHolds(12));
+  EXPECT_EQ(serialized.size(), BmffBoxHeaderSize(box) + data.size());
+  EXPECT_THAT(
+      ReadAllBmffBoxHeaders(serialized),
+      IsOkAndHolds(ElementsAre(BmffBoxHeader{
+          .start = 0,
+          .box_size = 12 + data.size(),  // header_size + 4 (version+flags)
+          .header_size = 8,              // 4 (size) + 4 (type)
+          .version_and_flags_size = 4,
+          .type = "pdin",
+          .xpath = "/pdin[1]",
+          .user_type = "",
+          .version = 0,
+          .flags = std::string(kZeroFlags)})));
+}
+
+TEST_F(BoxHeaderTest, WriteNotFullBox) {
+  std::string data = "abcd";
+  BmffBox box{.type = "xxxx", .data_size = data.size()};
+  std::string serialized;
+  EXPECT_THAT(WriteBox(box, data, serialized), IsOkAndHolds(8));
+  EXPECT_EQ(serialized.size(), BmffBoxHeaderSize(box) + data.size());
+  EXPECT_THAT(ReadAllBmffBoxHeaders(serialized),
+              IsOkAndHolds(ElementsAre(
+                  BmffBoxHeader{.start = 0,
+                                .box_size = 8 + data.size(),
+                                .header_size = 8,  // 4 (size) + 4 (type)
+                                .type = "xxxx",
+                                .xpath = "/xxxx[1]"})));
+}
+
+TEST_F(BoxHeaderTest, WriteTwoBoxes) {
+  std::string data = "abcd";
+  BmffBox uuid_box{.type = "uuid",
+                   .user_type = std::string(kC2paBmffBoxUuid),
+                   .version = 1,
+                   .flags = BoxFlags(2),
+                   .data_size = data.size()};
+  BmffBox other_box{.type = "blah", .data_size = data.size()};
+  std::string uuid_serialized;
+  EXPECT_THAT(WriteBox(uuid_box, data, uuid_serialized), IsOkAndHolds(28));
+  std::string other_serialized;
+  EXPECT_THAT(WriteBox(other_box, data, other_serialized), IsOkAndHolds(8));
+  std::string serialized = absl::StrCat(uuid_serialized, other_serialized);
+  EXPECT_EQ(serialized.size(), BmffBoxHeaderSize(uuid_box) + data.size() +
+                                   BmffBoxHeaderSize(other_box) + data.size());
+  EXPECT_THAT(ReadAllBmffBoxHeaders(serialized),
+              IsOkAndHolds(ElementsAre(
+                  BmffBoxHeader{.start = 0,
+                                .box_size = 28 + data.size(),
+                                .header_size = 24,
+                                .version_and_flags_size = 4,
+                                .type = "uuid",
+                                .xpath = "/uuid[1]",
+                                .user_type = std::string(kC2paBmffBoxUuid),
+                                .version = 1,
+                                .flags = BoxFlags(2)},
+                  BmffBoxHeader{.start = 32,
+                                .box_size = 8 + data.size(),
+                                .header_size = 8,
+                                .type = "blah",
+                                .xpath = "/blah[1]"})));
+}
+
+TEST_F(BoxHeaderTest, WriteFailsWhenStatusNotOKBeforeWrite) {
+  std::string data = "abcd";
+  BmffBox box{.type = "xxxx", .data_size = data.size()};
+  std::string serialized;
+  EXPECT_THAT(WriteBox(box, data, serialized, /*start_with_error=*/true),
+              StatusIs(absl::StatusCode::kInternal));
+  EXPECT_TRUE(serialized.empty());
+}
+
+TEST_F(BoxHeaderTest, WriteSize64bits) {
+  uint64_t data_size = 4294967296;  // uint32_t max + 1
+  BmffBox box{.type = "xxxx", .data_size = data_size};
+  std::string serialized;
+  EXPECT_THAT(WriteBox(box, "", serialized), IsOkAndHolds(16));
+  EXPECT_EQ(serialized.size(), BmffBoxHeaderSize(box));
+
+  BmffBoxHeader read;
+  ABSL_ASSERT_OK(ReadBox(serialized, read));
+  BmffBoxHeader expected{
+      .start = 0,
+      .box_size = 8 + 8 + data_size,
+      .header_size = 8 + 8,  // 4 (size) + 4 (type) + 8 (64bits)
+      .type = "xxxx"};
+  EXPECT_EQ(read, expected);
+}
+
+struct LocateInsertionPointTestParam {
+  std::string test_name;
+  std::string contents;
+
+  absl::StatusOr<ByteRange> expected;
+};
+
+using LocateInsertionPointTest =
+    testing::TestWithParam<LocateInsertionPointTestParam>;
+
+INSTANTIATE_TEST_SUITE_P(
+    LocateInsertionPointTests, LocateInsertionPointTest,
+    testing::ValuesIn<LocateInsertionPointTestParam>({
+        {
+            .test_name = "BlankFile",
+            .contents = "",
+            .expected = absl::InvalidArgumentError("no mdat/moov box found"),
+        },
+        {
+            .test_name = "NoMdatOrMoov",
+            .contents = Box("free", "00"),
+            .expected = absl::InvalidArgumentError("no mdat/moov box found"),
+        },
+        {
+            .test_name = "OnlyMdat",
+            .contents = Box("mdat", "00"),
+            .expected = ByteRange{.offset = 0, .length = 0},
+        },
+        {
+            .test_name = "OnlyMoov",
+            .contents = Box("moov", "00"),
+            .expected = ByteRange{.offset = 0, .length = 0},
+        },
+        {
+            .test_name = "Mdat",
+            .contents = absl::StrCat(Box("ftyp", "00"), Box("mdat", "00")),
+            .expected = ByteRange{.offset = 10, .length = 0},
+        },
+        {
+            .test_name = "Moov",
+            .contents = absl::StrCat(Box("ftyp", "00"), Box("moov", "00")),
+            .expected = ByteRange{.offset = 10, .length = 0},
+        },
+        {
+            .test_name = "MdatAndMoov",
+            .contents = absl::StrCat(Box("ftyp", "00"), Box("mdat", "00"),
+                                     Box("moov", "00")),
+            .expected = ByteRange{.offset = 10, .length = 0},
+        },
+        {
+            .test_name = "FreeAfterMdat",
+            .contents = absl::StrCat(Box("ftyp", "00"), Box("mdat", "00"),
+                                     Box("free", "00")),
+            .expected = ByteRange{.offset = 10, .length = 0},
+        },
+        {
+            .test_name = "FreeAfterMoov",
+            .contents = absl::StrCat(Box("ftyp", "00"), Box("moov", "00"),
+                                     Box("free", "00")),
+            .expected = ByteRange{.offset = 10, .length = 0},
+        },
+        {
+            .test_name = "OneFreeBox",
+            .contents = absl::StrCat(Box("ftyp", "00"), Box("free", "00"),
+                                     Box("mdat", "00")),
+            .expected = ByteRange{.offset = 10, .length = 10},
+        },
+        {
+            .test_name = "TwoSeparateFreeBoxesFirstLarger",
+            .contents = absl::StrCat(
+                Box("ftyp", "00"), Box("free", "000000000000"),
+                Box("data", "00"), Box("free", "00"), Box("mdat", "00")),
+            .expected = ByteRange{.offset = 10, .length = 20},
+        },
+        {
+            .test_name = "TwoSeparateFreeBoxesSecondLarger",
+            .contents = absl::StrCat(
+                Box("ftyp", "00"), Box("free", "00"), Box("data", "00"),
+                Box("free", "000000000000"), Box("mdat", "00")),
+            .expected = ByteRange{.offset = 30, .length = 20},
+        },
+        {
+            .test_name = "AdjacentFreeBoxes",
+            .contents = absl::StrCat(Box("ftyp", "00"), Box("free", "00"),
+                                     Box("free", "00"), Box("mdat", "00")),
+            .expected = ByteRange{.offset = 10, .length = 20},
+        },
+        {
+            .test_name = "LargerFreeBoxAndSeparateC2paManifest",
+            .contents = absl::StrCat(
+                Box("ftyp", "00"), Box("free", std::string(102, '0')),
+                Box("data", "00"), C2paUuidBox("00000"), Box("mdat", "00")),
+            .expected = ByteRange{.offset = 130, .length = 50},
+        },
+        {
+            .test_name = "C2paManifestThenFreeBox",
+            .contents = absl::StrCat(Box("ftyp", "00"), C2paUuidBox("00000"),
+                                     Box("free", "00"), Box("mdat", "00")),
+            .expected = ByteRange{.offset = 10, .length = 60},
+        },
+        {
+            .test_name = "FreeBoxThenC2paManifest",
+            .contents = absl::StrCat(Box("ftyp", "00"), Box("free", "00"),
+                                     C2paUuidBox("00000"), Box("mdat", "00")),
+            .expected = ByteRange{.offset = 10, .length = 60},
+        },
+        {
+            .test_name = "C2paManifest",
+            .contents = absl::StrCat(Box("ftyp", "00"), C2paUuidBox("00000"),
+                                     Box("mdat", "00")),
+            .expected = ByteRange{.offset = 10, .length = 50},
+        },
+        {
+            .test_name = "C2paOriginal",
+            .contents = absl::StrCat(Box("ftyp", "00"),
+                                     C2paUuidBox("00000",
+                                                 kBmffC2paBoxPurposeOriginal),
+                                     Box("mdat", "00")),
+            .expected = ByteRange{.offset = 10, .length = 50},
+        },
+        {
+            .test_name = "C2paMerkle",
+            .contents = absl::StrCat(Box("ftyp", "00"),
+                                     C2paUuidBox("0000000",
+                                                 kBmffC2paBoxPurposeMerkle),
+                                     Box("mdat", "00")),
+            .expected = ByteRange{.offset = 60, .length = 0},
+        },
+        {
+            .test_name = "C2paUpdate",
+            .contents = absl::StrCat(Box("ftyp", "00"),
+                                     C2paUuidBox("0000000",
+                                                 kBmffC2paBoxPurposeUpdate),
+                                     Box("mdat", "00")),
+            .expected = ByteRange{.offset = 60, .length = 0},
+        },
+    }),
+    [](const testing::TestParamInfo<LocateInsertionPointTest::ParamType>&
+           info) { return info.param.test_name; });
+
+TEST_P(LocateInsertionPointTest, FindsInsertionPoint) {
+  riegeli::StringReader reader(GetParam().contents);
+
+  if (GetParam().expected.ok()) {
+    EXPECT_THAT(LocateManifestInsertionPoint(reader),
+                IsOkAndHolds(Eq(GetParam().expected.value())));
+  } else {
+    EXPECT_THAT(LocateManifestInsertionPoint(reader),
+                StatusIs(GetParam().expected.status().code(),
+                         HasSubstr(GetParam().expected.status().message())));
+  }
+}
+
+TEST(IsBmffFullBoxTest, True) {
+  for (const absl::string_view type : credentio_internal::kFullBoxTypes) {
+    EXPECT_TRUE(IsBmffFullBox(type));
+  }
+}
+
+TEST(IsBmffFullBoxTest, False) {
+  EXPECT_FALSE(IsBmffFullBox(""));
+  EXPECT_FALSE(IsBmffFullBox("a"));
+  EXPECT_FALSE(IsBmffFullBox("uri"));
+  EXPECT_FALSE(IsBmffFullBox("aaaa"));
+  EXPECT_FALSE(IsBmffFullBox("aaaaaaaaaaaaaaaaaaaa"));
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/formats/bmff/embedder.cc b/formats/bmff/embedder.cc
new file mode 100644
index 0000000..5c305a5
--- /dev/null
+++ b/formats/bmff/embedder.cc
@@ -0,0 +1,361 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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/embedder.h"
+
+#include <algorithm>
+#include <cstddef>
+#include <cstdint>
+#include <limits>
+#include <memory>
+#include <string>
+#include <utility>
+#include <vector>
+
+#include "absl/algorithm/container.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 "absl/types/span.h"
+#include "bindings/input_hasher.h"
+#include "constants/labels.h"
+#include "formats/bmff/box_header.h"
+#include "formats/bmff/box_matcher.h"
+#include "formats/bmff/offset_adjustment.h"
+#include "formats/byte_range.h"
+#include "proto/bmff_based_hash_assertion.pb.h"
+#include "riegeli/bytes/reader.h"
+#include "riegeli/bytes/string_writer.h"
+#include "riegeli/bytes/writer.h"
+#include "riegeli/endian/endian_writing.h"
+#include "utils/byte_instruction.h"
+#include "utils/byte_writers.h"
+
+namespace credentio {
+
+namespace {
+
+std::string Uint64ToBytes(uint64_t value) {
+  std::vector<uint8_t> bytes;
+  WriteUint64NetworkOrder(value, &bytes);
+  return std::string(bytes.begin(), bytes.end());
+}
+
+constexpr absl::string_view kZeroFlags("\x00\x00\x00", 3);
+constexpr absl::string_view kC2paBoxPurpose = "manifest";
+constexpr uint64_t kMaxChunkBytes = 1024 * 1024;  // 1 MiB
+
+BmffBox C2paBmffBoxHeader(uint64_t manifest_store_size) {
+  return BmffBox{
+      .type = "uuid",
+      .user_type = std::string(kC2paBmffBoxUuid),
+      .flags = std::string(kZeroFlags),
+      // Nul-terminated purpose + 8-byte merkle offset + manifest_store.
+      .data_size = kC2paBoxPurpose.size() + 1 + 8 + manifest_store_size,
+  };
+}
+
+bool BoxHasOffsets(absl::string_view type) {
+  return type == "stco" || type == "saio" || type == "co64" || type == "iloc";
+}
+
+// Adjusts the offsets in the given box by adding the size of the manifest
+// store and writes it to the destination.
+absl::Status FixOffsets(const BmffBoxHeader& box_header,
+                        riegeli::Reader& source, uint64_t byte_adjustment,
+                        riegeli::Writer& destination) {
+  ABSL_ASSIGN_OR_RETURN(
+      int64_t written_header_size,
+      WriteBmffBoxHeader(
+          BmffBox{
+              .type = box_header.type,
+              .user_type = box_header.user_type,
+              .version = box_header.version,
+              .flags = box_header.flags,
+              // The box_size includes the header size, the write method expects
+              // only the data size.
+              .data_size = box_header.box_size - box_header.header_size -
+                           box_header.version_and_flags_size,
+          },
+          destination));
+  // Since we didn't read the header from the source, we need to skip over those
+  // bytes.
+  if (!source.Seek(box_header.start + written_header_size)) {
+    return absl::DataLossError("failed to skip header size");
+  }
+
+  if (box_header.type == "iloc") {
+    ABSL_ASSIGN_OR_RETURN(IlocBox box, ParseIloc(source, box_header));
+    AdjustOffsets(box, byte_adjustment);
+    return SerializeIloc(box, box_header, destination);
+  }
+
+  if (box_header.type == "stco") {
+    ABSL_ASSIGN_OR_RETURN(StcoBox box, ParseStco(source));
+    AdjustOffsets(box, byte_adjustment);
+    return SerializeStco(box, destination);
+  }
+
+  if (box_header.type == "co64") {
+    ABSL_ASSIGN_OR_RETURN(Co64Box box, ParseCo64(source));
+    AdjustOffsets(box, byte_adjustment);
+    return SerializeCo64(box, destination);
+  }
+
+  if (box_header.type == "saio") {
+    ABSL_ASSIGN_OR_RETURN(SaioBox box, ParseSaio(source, box_header));
+    AdjustOffsets(box, byte_adjustment);
+    return SerializeSaio(box, box_header, destination);
+  }
+
+  return absl::InvalidArgumentError(
+      absl::StrCat("Unexpected box type for offset fixing: ", box_header.type));
+}
+
+absl::Status ProcessC2paAtom(const BmffBoxHeader& box, riegeli::Reader& source,
+                             const ByteRange& insertion_point,
+                             std::vector<ByteInstruction>& byte_instructions) {
+  if (box.start >= insertion_point.offset &&
+      box.start + box.box_size <=
+          insertion_point.offset + insertion_point.length) {
+    // This box is completely within the insertion point, it will be handled by
+    // the instruction which inserts the manifest store.
+    return absl::OkStatus();
+  }
+
+  // Just convert any other C2PA atom outside of the insertion point to a free
+  // box.
+  std::string free_box;
+  riegeli::StringWriter<> free_box_writer(&free_box);
+  ABSL_RETURN_IF_ERROR(CreateFreeBox(box.box_size, free_box_writer));
+  if (!free_box_writer.Close()) {
+    return absl::InternalError("failed to close string writer");
+  }
+
+  byte_instructions.push_back(
+      ByteInstruction{.operation = ByteInstruction::Operation::kReplace,
+                      .offset = box.start,
+                      .bytes = std::move(free_box)});
+
+  return absl::OkStatus();
+}
+
+absl::Status ProcessBox(const BmffBoxHeader& box, riegeli::Reader& source,
+                        bool can_replace_manifest,
+                        absl::Span<const BMFFBoxMatcher> exclusion_matchers,
+                        const ByteRange& insertion_point,
+                        uint64_t bytes_needed_to_add,
+                        std::vector<ByteInstruction>& byte_instructions,
+                        std::vector<std::string>& excluded_atoms,
+                        InputHasher& hasher) {
+  if (box.type == "uuid" && box.user_type == kC2paBmffBoxUuid) {
+    if (!can_replace_manifest) {
+      return absl::AlreadyExistsError(
+          "C2PA chunk already exists, but no `opened` action was provided");
+    }
+
+    ABSL_RETURN_IF_ERROR(
+        ProcessC2paAtom(box, source, insertion_point, byte_instructions));
+
+    // Skip this box
+    source.Seek(box.start + box.box_size);
+    return absl::OkStatus();
+  }
+
+  bool added_byte_instruction = false;
+  if (BoxHasOffsets(box.type) && bytes_needed_to_add > 0) {
+    // Move the current position to this box.
+    if (!source.Seek(box.start) || source.pos() != box.start) {
+      return absl::InternalError("failed to seek to box start");
+    }
+
+    auto byte_instruction = ByteInstruction{
+        .operation = ByteInstruction::Operation::kReplace, .offset = box.start};
+
+    riegeli::StringWriter<> writer(&byte_instruction.bytes);
+    ABSL_RETURN_IF_ERROR(FixOffsets(box, source, bytes_needed_to_add, writer));
+    if (!writer.Close()) {
+      return absl::InternalError("failed to close string writer");
+    }
+
+    byte_instructions.push_back(std::move(byte_instruction));
+    added_byte_instruction = true;
+  }
+
+  if (absl::c_any_of(excluded_atoms, [&box](absl::string_view excluded_atom) {
+        return absl::StartsWith(box.xpath, excluded_atom);
+      })) {
+    // Parent atom is excluded, so skip this box.
+    return absl::OkStatus();
+  }
+  for (const auto& matcher : exclusion_matchers) {
+    if (matcher.Matches(box)) {
+      excluded_atoms.push_back(box.xpath);
+
+      if (matcher.IsMatchingEntireBox()) {
+        // The entire box is excluded, so we can skip it.
+        return absl::OkStatus();
+      }
+      // Note: Excluding parts of a box is not supported, as best practice would
+      // see no additional exclusions aside from required exclusions.
+      // See:
+      // https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_exclusion_list_requirements
+      return absl::InvalidArgumentError(
+          "partial exclusions of atom are not supported");
+    }
+  }
+
+  if (box.IsRootBox()) {
+    int64_t adjusted_box_start = box.start >= insertion_point.offset
+                                     ? box.start + bytes_needed_to_add
+                                     : box.start;
+
+    hasher.Update(Uint64ToBytes(adjusted_box_start));
+  }
+
+  if (added_byte_instruction) {
+    hasher.Update(byte_instructions.back().bytes);
+    return absl::OkStatus();
+  }
+
+  if (box.IsContainerBox()) {
+    // The box is a container, so we need to hash the header then continue for
+    // the data.
+    int64_t data_start = source.pos();
+
+    source.Seek(box.start);
+    std::string header_data;
+    source.Read(data_start - box.start, header_data);
+    hasher.Update(header_data);
+
+    return absl::OkStatus();
+  }
+
+  // not a container, not excluded, and no offsets, so just hash the whole box
+  source.Seek(box.start);
+  uint64_t remaining_bytes = box.box_size;
+  while (remaining_bytes > 0) {
+    size_t chunk_size = std::min(remaining_bytes, kMaxChunkBytes);
+    std::string chunk_data;
+    if (!source.Read(chunk_size, chunk_data)) {
+      return absl::DataLossError("failed to read box data");
+    }
+    hasher.Update(chunk_data);
+    remaining_bytes -= chunk_size;
+  }
+
+  return absl::OkStatus();
+}
+
+}  // namespace
+
+absl::StatusOr<std::vector<ByteInstruction>> BmffEmbedder::InspectAndHash(
+    riegeli::Reader& source, uint64_t manifest_store_size,
+    const ByteRange& insertion_point, bool can_replace_manifest,
+    BmffBasedHashAssertion& assertion) const {
+  uint64_t c2pa_atom_size = CalculateC2paSizeBmff(manifest_store_size);
+  uint64_t bytes_needed_to_add = insertion_point.length >= c2pa_atom_size
+                                     ? 0
+                                     : c2pa_atom_size - insertion_point.length;
+
+  std::vector<BMFFBoxMatcher> exclusion_matchers;
+  for (const auto& bmff_range : assertion.exclusions()) {
+    exclusion_matchers.push_back(BMFFBoxMatcher(bmff_range, &source));
+  }
+
+  source.Seek(0);
+
+  ABSL_ASSIGN_OR_RETURN(auto hasher, InputHasher::Create(assertion.alg()));
+  if (!source.SupportsSize()) {
+    return absl::InvalidArgumentError(
+        "manifest store not embedded: source does not support size");
+  }
+
+  std::vector<ByteInstruction> byte_instructions;
+  std::vector<std::string> excluded_atoms;
+  ABSL_RETURN_IF_ERROR(IterateOverBmffBoxes(
+      source,
+      [&source, &insertion_point, &can_replace_manifest, &byte_instructions,
+       &exclusion_matchers, &excluded_atoms, &hasher,
+       &bytes_needed_to_add](const BmffBoxHeader& box) -> absl::StatusOr<bool> {
+        ABSL_RETURN_IF_ERROR(ProcessBox(box, source, can_replace_manifest,
+                                        exclusion_matchers, insertion_point,
+                                        bytes_needed_to_add, byte_instructions,
+                                        excluded_atoms, *hasher));
+        return true;
+      }));
+
+  assertion.set_hash(hasher->Digest());
+
+  return byte_instructions;
+}
+
+int64_t CalculateC2paSizeBmff(uint64_t manifest_store_size) {
+  BmffBox box = C2paBmffBoxHeader(manifest_store_size);
+  return BmffBoxHeaderSize(box) + box.data_size;
+}
+
+absl::Status WriteC2paBmff(absl::string_view manifest_store,
+                           riegeli::Writer& output) {
+  bool failed = false;
+  ABSL_RETURN_IF_ERROR(
+      WriteBmffBoxHeader(C2paBmffBoxHeader(manifest_store.size()), output)
+          .status());
+  failed |= !output.Write(kC2paBoxPurpose);
+  failed |= !output.WriteByte(0);
+  failed |= !riegeli::WriteBigEndian<uint64_t>(0, output);
+  failed |= !output.Write(manifest_store);
+  if (failed) {
+    return absl::InternalError("manifest store not embedded: write failed");
+  }
+  return absl::OkStatus();
+}
+
+absl::Status CreateFreeBox(uint64_t size, riegeli::Writer& output) {
+  if (size < 8) {
+    return absl::InvalidArgumentError(
+        absl::StrCat("Invalid free box size: ", size));
+  }
+
+  uint64_t data_size = 0;
+  if (size > std::numeric_limits<uint32_t>::max()) {
+    data_size = size - 16;
+  } else {
+    data_size = size - 8;
+  }
+
+  ABSL_RETURN_IF_ERROR(
+      WriteBmffBoxHeader(BmffBox{.type = "free", .data_size = data_size},
+                         output)
+          .status());
+
+  uint64_t remaining_padding = data_size;
+  constexpr size_t kChunkSize = 64 * 1024;  // 64 KiB
+  std::string zero_chunk(kChunkSize, '\0');
+  while (remaining_padding > 0) {
+    size_t write_size = std::min<uint64_t>(remaining_padding, kChunkSize);
+    if (!output.Write(absl::string_view(zero_chunk.data(), write_size))) {
+      return absl::InternalError("manifest store not embedded: write failed");
+    }
+    remaining_padding -= write_size;
+  }
+
+  return absl::OkStatus();
+}
+
+}  // namespace credentio
diff --git a/formats/bmff/embedder.h b/formats/bmff/embedder.h
new file mode 100644
index 0000000..b7ce749
--- /dev/null
+++ b/formats/bmff/embedder.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_BMFF_EMBEDDER_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_BMFF_EMBEDDER_H_
+
+#include <cstdint>
+#include <vector>
+
+#include "absl/status/status.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "formats/byte_range.h"
+#include "proto/bmff_based_hash_assertion.pb.h"
+#include "riegeli/bytes/reader.h"
+#include "riegeli/bytes/writer.h"
+#include "utils/byte_instruction.h"
+
+namespace credentio {
+
+// Embedder for inserting a C2PA manifest store into a BMFF file.
+class BmffEmbedder {
+ public:
+  // Reads through the source, locating where the manifest should be inserted
+  // and where any atoms needing offset adjustment are located. Simultaneously
+  // computes the hard binding hash of the asset.
+  absl::StatusOr<std::vector<ByteInstruction>> InspectAndHash(
+      riegeli::Reader& source, uint64_t manifest_store_size,
+      const ByteRange& insertion_point, bool can_replace_manifest,
+      BmffBasedHashAssertion& assertion) const;
+};
+
+// Calculates the size of the C2PA (UUID) box in the BMFF format.
+int64_t CalculateC2paSizeBmff(uint64_t manifest_store_size);
+// Writes the C2PA manifest store to the output in the BMFF format at the
+// current position and returns the offset of the appended box.
+//
+// NOTE: This shall be called before the first 'mdat' box in the file and
+// before any 'moov' box in the file. To accommodate major_brand and
+// compatible_brand verification, it shall be called after the 'ftyp' box.
+//
+// BMFF files with Merkle tree content bindings are not supported.
+absl::Status WriteC2paBmff(absl::string_view manifest_store,
+                           riegeli::Writer& output);
+
+// Creates a free box in the BMFF format at the current position.
+absl::Status CreateFreeBox(uint64_t size, riegeli::Writer& output);
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_BMFF_EMBEDDER_H_
diff --git a/formats/bmff/extractor_test.cc b/formats/bmff/extractor_test.cc
index d3dc19a..5672bef 100644
--- a/formats/bmff/extractor_test.cc
+++ b/formats/bmff/extractor_test.cc
@@ -42,7 +42,6 @@
 namespace credentio {
 namespace {
 
-using ::absl_testing::IsOk;
 using ::absl_testing::StatusIs;
 using ::credentio_testing::BigBox;
 using ::credentio_testing::Box;
@@ -232,7 +231,7 @@
 
   absl::StatusOr<ExtractorResult> expected_result = GetParam().result;
   if (expected_result.ok()) {
-    ASSERT_THAT(result, IsOk());
+    ABSL_ASSERT_OK(result);
     EXPECT_THAT(*result, Eq(expected_result->manifest_store));
   } else {
     EXPECT_THAT(result,
@@ -251,7 +250,7 @@
 
   absl::StatusOr<ExtractorResult> expected_result = GetParam().result;
   if (expected_result.ok()) {
-    ASSERT_THAT(result, IsOk());
+    ABSL_ASSERT_OK(result);
     EXPECT_THAT(*result, Eq(expected_result->manifest_store));
   } else {
     EXPECT_THAT(result,
@@ -268,7 +267,7 @@
 
   absl::StatusOr<ExtractorResult> expected_result = GetParam().result;
   if (expected_result.ok()) {
-    ASSERT_THAT(result, IsOk());
+    ABSL_ASSERT_OK(result);
     EXPECT_THAT(*result,
                 Eq(expected_result->asset_byte_info.manifest_store_location));
   } else {
diff --git a/formats/bmff/hasher.cc b/formats/bmff/hasher.cc
new file mode 100644
index 0000000..21af00f
--- /dev/null
+++ b/formats/bmff/hasher.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 "formats/bmff/hasher.h"
+
+#include <string>
+
+#include "absl/status/status.h"
+#include "absl/status/status_macros.h"
+#include "absl/strings/str_cat.h"
+#include "bindings/binding_hasher.h"
+#include "bindings/bmff_binding_hasher.h"
+#include "crypto/algorithms.h"
+#include "proto/bmff_based_hash_assertion.pb.h"
+#include "riegeli/bytes/reader.h"
+
+namespace credentio {
+
+BmffBasedHashAssertion GetBmffHashTemplate(HashAlgorithm alg) {
+  BmffBasedHashAssertion assertion;
+  assertion.set_name("BMFF file hash");
+  assertion.set_alg(absl::StrCat(alg));
+  std::string hash(HashSizeBytes(alg), '\0');
+  assertion.set_hash(hash);
+
+  // Add the required exclusions described in
+  // https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_exclusion_list_requirements.
+  assertion.add_exclusions()->set_xpath("/ftyp");
+  BmffRange* uuid_exclusion = assertion.add_exclusions();
+  uuid_exclusion->set_xpath("/uuid");
+  BmffData* data = uuid_exclusion->add_data();
+  data->set_offset(8);
+  // The C2PA manifest store extended box type value, which will always
+  // appear at offset 8 in the C2PA BMFF box
+  // (https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_definition).
+  data->set_value("\330\376\303\326\033\016H<\222\227X(\207~\304\201");
+  assertion.add_exclusions()->set_xpath("/mfra");
+  // Exclude 'free' boxes. A 'free' box is often used for a pre-allocated space
+  // before writing the final C2PA manifest store.
+  assertion.add_exclusions()->set_xpath("/free");
+
+  return assertion;
+}
+
+absl::Status DigestBmff(riegeli::Reader& input,
+                        BmffBasedHashAssertion& assertion_template) {
+  StatusBindingHasherTracker tracker;
+  BmffBindingHasher hasher(&assertion_template, &tracker);
+  auto digest = hasher.Digest(input).value_or("");
+  ABSL_RETURN_IF_ERROR(tracker.status());
+  if (digest.empty()) {
+    return absl::InternalError("digest is empty but tracker status is OK");
+  }
+  if (digest.size() != assertion_template.hash().size()) {
+    return absl::InternalError("digest size mismatch");
+  }
+  assertion_template.set_hash(digest);
+  return absl::OkStatus();
+}
+
+}  // namespace credentio
diff --git a/formats/bmff/hasher.h b/formats/bmff/hasher.h
new file mode 100644
index 0000000..b86aa3f
--- /dev/null
+++ b/formats/bmff/hasher.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_BMFF_HASHER_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_BMFF_HASHER_H_
+
+#include "absl/status/status.h"
+#include "crypto/algorithms.h"
+#include "proto/bmff_based_hash_assertion.pb.h"
+#include "riegeli/bytes/reader.h"
+
+namespace credentio {
+// Returns a BMFF hash assertion template for size estimation, for
+// non-fragmented BMFF assets.
+BmffBasedHashAssertion GetBmffHashTemplate(HashAlgorithm alg);
+// Digests a BMFF file and updates the given hash assertion template.
+// Note that the serialized size of the assertion will not change.
+absl::Status DigestBmff(riegeli::Reader& input,
+                        BmffBasedHashAssertion& assertion_template);
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_BMFF_HASHER_H_
diff --git a/formats/bmff/hasher_test.cc b/formats/bmff/hasher_test.cc
new file mode 100644
index 0000000..20a041f
--- /dev/null
+++ b/formats/bmff/hasher_test.cc
@@ -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.
+//
+
+#include "formats/bmff/hasher.h"
+
+#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 "constants/labels.h"
+#include "crypto/algorithms.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"
+#include "testing/proto_test_utils.h"
+
+namespace credentio {
+namespace {
+
+using ::absl_testing::StatusIs;
+using ::credentio::kC2paBmffBoxUuid;
+using ::credentio_testing::Box;
+using ::credentio_testing::BoxFlags;
+using ::credentio_testing::BoxVersion;
+using ::credentio_testing::EqualsProto;
+using ::credentio_testing::ParseTextProtoOrDie;
+using ::credentio_testing::UuidBoxPayload;
+using ::testing::HasSubstr;
+
+TEST(BmffHasherTest, GetHashAssertionTemplate) {
+  auto expected = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb(
+    exclusions { xpath: "/ftyp" }
+    exclusions {
+      xpath: "/uuid"
+      data {
+        offset: 8
+        value: "\330\376\303\326\033\016H<\222\227X(\207~\304\201"
+      }
+    }
+    exclusions { xpath: "/mfra" }
+    exclusions { xpath: "/free" }
+    alg: "sha256"
+    hash: "\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000"
+    name: "BMFF file hash"
+  )pb");
+  EXPECT_THAT(GetBmffHashTemplate(HashAlgorithm::kSha256),
+              EqualsProto(expected));
+}
+
+TEST(BmffHasherTest, Digest) {
+  auto assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb(
+    exclusions { xpath: "/ftyp" }
+    exclusions {
+      xpath: "/uuid"
+      data {
+        offset: 8
+        value: "\330\376\303\326\033\016H<\222\227X(\207~\304\201"
+      }
+    }
+    exclusions { xpath: "/mfra" }
+    exclusions { xpath: "/free" }
+    alg: "sha256"
+    hash: "\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000"
+    name: "BMFF file hash"
+  )pb");
+  std::string input_contents = absl::StrCat(
+      Box("uuid",
+          UuidBoxPayload(kC2paBmffBoxUuid, BoxVersion(0), BoxFlags(0), "")),
+      Box("trak", "ijkl"));
+  riegeli::StringReader<> input(input_contents);
+  ABSL_ASSERT_OK(DigestBmff(input, assertion));
+}
+
+TEST(BmffHasherTest, HashSizeMismatch) {
+  auto assertion = ParseTextProtoOrDie<BmffBasedHashAssertion>(R"pb(
+    exclusions { xpath: "/ftyp" }
+    exclusions {
+      xpath: "/uuid"
+      data {
+        offset: 8
+        value: "\330\376\303\326\033\016H<\222\227X(\207~\304\201"
+      }
+    }
+    exclusions { xpath: "/mfra" }
+    exclusions { xpath: "/free" }
+    alg: "sha256"
+    hash: "invalid hash"
+    name: "BMFF file hash"
+  )pb");
+  std::string input_contents = absl::StrCat(
+      Box("uuid",
+          UuidBoxPayload(kC2paBmffBoxUuid, BoxVersion(0), BoxFlags(0), "")),
+      Box("trak", "ijkl"));
+  riegeli::StringReader<> input(input_contents);
+  EXPECT_THAT(
+      DigestBmff(input, assertion),
+      StatusIs(absl::StatusCode::kInternal, HasSubstr("digest size mismatch")));
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/formats/bmff/offset_adjustment.cc b/formats/bmff/offset_adjustment.cc
new file mode 100644
index 0000000..749c86e
--- /dev/null
+++ b/formats/bmff/offset_adjustment.cc
@@ -0,0 +1,421 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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/offset_adjustment.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/str_cat.h"
+#include "formats/bmff/box_header.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 {
+
+absl::StatusOr<uint64_t> ReadVariableSize(riegeli::Reader& reader,
+                                          uint8_t size) {
+  if (size == 0) return 0;
+  if (size == 4) {
+    uint32_t val;
+    if (!riegeli::ReadBigEndian<uint32_t>(reader, val)) {
+      return absl::DataLossError("Failed to read 4 byte integer");
+    }
+    return val;
+  }
+  if (size == 8) {
+    uint64_t val;
+    if (!riegeli::ReadBigEndian<uint64_t>(reader, val)) {
+      return absl::DataLossError("Failed to read 8 byte integer");
+    }
+    return val;
+  }
+  return absl::DataLossError(absl::StrCat("invalid byte_size: ", size));
+}
+
+absl::Status WriteVariableSize(uint64_t value, uint8_t size,
+                               riegeli::Writer& writer) {
+  if (size == 0) return absl::OkStatus();
+  if (size == 4) {
+    if (value > 0xffffffff) {
+      return absl::InvalidArgumentError("Value too large for 4 bytes");
+    }
+    if (!riegeli::WriteBigEndian<uint32_t>(static_cast<uint32_t>(value),
+                                           writer)) {
+      return writer.status();
+    }
+    return absl::OkStatus();
+  }
+  if (size == 8) {
+    if (!riegeli::WriteBigEndian<uint64_t>(value, writer)) {
+      return writer.status();
+    }
+    return absl::OkStatus();
+  }
+  return absl::InvalidArgumentError(absl::StrCat("invalid byte_size: ", size));
+}
+
+}  // namespace
+
+absl::StatusOr<IlocBox> ParseIloc(riegeli::Reader& reader,
+                                  const BmffBoxHeader& header) {
+  IlocBox box;
+  uint8_t offset_and_length_size;
+  if (!riegeli::ReadBigEndian<uint8_t>(reader, offset_and_length_size)) {
+    return absl::DataLossError("Failed to read offset and length size");
+  }
+  box.offset_size = offset_and_length_size >> 4;
+  box.length_size = offset_and_length_size & 0xf;
+
+  uint8_t base_offset_and_index_size;
+  if (!riegeli::ReadBigEndian<uint8_t>(reader, base_offset_and_index_size)) {
+    return absl::DataLossError("Failed to read base offset and index size");
+  }
+  box.base_offset_size = base_offset_and_index_size >> 4;
+  box.index_size = base_offset_and_index_size & 0xf;
+
+  uint32_t item_count = 0;
+  if (header.version < 2) {
+    uint16_t count;
+    if (!riegeli::ReadBigEndian<uint16_t>(reader, count)) {
+      return absl::DataLossError("Failed to read item count");
+    }
+    item_count = count;
+  } else if (header.version == 2) {
+    if (!riegeli::ReadBigEndian<uint32_t>(reader, item_count)) {
+      return absl::DataLossError("Failed to read item count");
+    }
+  }
+
+  box.items.reserve(item_count);
+  for (uint32_t i = 0; i < item_count; ++i) {
+    IlocItem item;
+    if (header.version < 2) {
+      uint16_t item_id;
+      if (!riegeli::ReadBigEndian<uint16_t>(reader, item_id)) {
+        return absl::DataLossError("Failed to read item ID");
+      }
+      item.item_id = item_id;
+    } else if (header.version == 2) {
+      if (!riegeli::ReadBigEndian<uint32_t>(reader, item.item_id)) {
+        return absl::DataLossError("Failed to read item ID");
+      }
+    }
+
+    if (header.version == 1 || header.version == 2) {
+      if (!riegeli::ReadBigEndian<uint16_t>(reader, item.construction_method)) {
+        return absl::DataLossError(
+            "Failed to read reserved and construction method");
+      }
+    }
+
+    if (!riegeli::ReadBigEndian<uint16_t>(reader, item.data_reference_index)) {
+      return absl::DataLossError("Failed to read data reference index");
+    }
+
+    ABSL_ASSIGN_OR_RETURN(item.base_offset,
+                          ReadVariableSize(reader, box.base_offset_size));
+
+    uint16_t extent_count;
+    if (!riegeli::ReadBigEndian<uint16_t>(reader, extent_count)) {
+      return absl::DataLossError("Failed to read extent count");
+    }
+
+    item.extents.reserve(extent_count);
+    for (int j = 0; j < extent_count; ++j) {
+      IlocExtent extent;
+      if ((header.version == 1 || header.version == 2) && box.index_size > 0) {
+        ABSL_ASSIGN_OR_RETURN(extent.index,
+                              ReadVariableSize(reader, box.index_size));
+      }
+      ABSL_ASSIGN_OR_RETURN(extent.offset,
+                            ReadVariableSize(reader, box.offset_size));
+      ABSL_ASSIGN_OR_RETURN(extent.length,
+                            ReadVariableSize(reader, box.length_size));
+      item.extents.push_back(extent);
+    }
+    box.items.push_back(item);
+  }
+  return box;
+}
+
+absl::Status SerializeIloc(const IlocBox& box, const BmffBoxHeader& header,
+                           riegeli::Writer& writer) {
+  uint8_t offset_and_length_size = (box.offset_size << 4) | box.length_size;
+  if (!riegeli::WriteBigEndian<uint8_t>(offset_and_length_size, writer)) {
+    return writer.status();
+  }
+
+  uint8_t base_offset_and_index_size =
+      (box.base_offset_size << 4) | box.index_size;
+  if (!riegeli::WriteBigEndian<uint8_t>(base_offset_and_index_size, writer)) {
+    return writer.status();
+  }
+
+  if (header.version < 2) {
+    if (box.items.size() > 0xffff) {
+      return absl::InvalidArgumentError("Too many items for version < 2");
+    }
+    if (!riegeli::WriteBigEndian<uint16_t>(
+            static_cast<uint16_t>(box.items.size()), writer)) {
+      return writer.status();
+    }
+  } else if (header.version == 2) {
+    if (!riegeli::WriteBigEndian<uint32_t>(
+            static_cast<uint32_t>(box.items.size()), writer)) {
+      return writer.status();
+    }
+  }
+
+  for (const auto& item : box.items) {
+    if (header.version < 2) {
+      if (item.item_id > 0xffff) {
+        return absl::InvalidArgumentError("Item ID too large for version < 2");
+      }
+      if (!riegeli::WriteBigEndian<uint16_t>(
+              static_cast<uint16_t>(item.item_id), writer)) {
+        return writer.status();
+      }
+    } else if (header.version == 2) {
+      if (!riegeli::WriteBigEndian<uint32_t>(item.item_id, writer)) {
+        return writer.status();
+      }
+    }
+
+    if (header.version == 1 || header.version == 2) {
+      if (!riegeli::WriteBigEndian<uint16_t>(item.construction_method,
+                                             writer)) {
+        return writer.status();
+      }
+    }
+
+    if (!riegeli::WriteBigEndian<uint16_t>(item.data_reference_index, writer)) {
+      return writer.status();
+    }
+
+    ABSL_RETURN_IF_ERROR(
+        WriteVariableSize(item.base_offset, box.base_offset_size, writer));
+
+    if (item.extents.size() > 0xffff) {
+      return absl::InvalidArgumentError("Too many extents");
+    }
+    if (!riegeli::WriteBigEndian<uint16_t>(
+            static_cast<uint16_t>(item.extents.size()), writer)) {
+      return writer.status();
+    }
+
+    for (const auto& extent : item.extents) {
+      if ((header.version == 1 || header.version == 2) && box.index_size > 0) {
+        ABSL_RETURN_IF_ERROR(
+            WriteVariableSize(extent.index, box.index_size, writer));
+      }
+      ABSL_RETURN_IF_ERROR(
+          WriteVariableSize(extent.offset, box.offset_size, writer));
+      ABSL_RETURN_IF_ERROR(
+          WriteVariableSize(extent.length, box.length_size, writer));
+    }
+  }
+  return absl::OkStatus();
+}
+
+void AdjustOffsets(IlocBox& box, int64_t adjustment) {
+  for (auto& item : box.items) {
+    if (item.data_reference_index == 0) {
+      uint16_t method = item.construction_method & 0xf;
+      if (method == 0) {
+        if (box.base_offset_size > 0) {
+          // The `extent.offset` is a relative offset. We must add it to the
+          // base_offset to find the absolute position of the data.
+          item.base_offset += adjustment;
+        } else if (box.offset_size > 0) {
+          // The `extent.offset` is an absolute offset. We must add the
+          // adjustment to it directly.
+          for (auto& extent : item.extents) {
+            extent.offset += adjustment;
+          }
+        }
+      }
+    }
+  }
+}
+
+absl::StatusOr<StcoBox> ParseStco(riegeli::Reader& reader) {
+  StcoBox box;
+  uint32_t entry_count;
+  if (!riegeli::ReadBigEndian<uint32_t>(reader, entry_count)) {
+    return absl::DataLossError("Failed to read entry count");
+  }
+  box.offsets.reserve(entry_count);
+  for (uint32_t i = 0; i < entry_count; ++i) {
+    uint32_t offset;
+    if (!riegeli::ReadBigEndian<uint32_t>(reader, offset)) {
+      return absl::DataLossError("Failed to read offset");
+    }
+    box.offsets.push_back(offset);
+  }
+  return box;
+}
+
+absl::Status SerializeStco(const StcoBox& box, riegeli::Writer& writer) {
+  if (box.offsets.size() > 0xffffffff) {
+    return absl::InvalidArgumentError("Too many offsets");
+  }
+  if (!riegeli::WriteBigEndian<uint32_t>(
+          static_cast<uint32_t>(box.offsets.size()), writer)) {
+    return writer.status();
+  }
+  for (uint32_t offset : box.offsets) {
+    if (!riegeli::WriteBigEndian<uint32_t>(offset, writer)) {
+      return writer.status();
+    }
+  }
+  return absl::OkStatus();
+}
+
+void AdjustOffsets(StcoBox& box, int64_t adjustment) {
+  for (auto& offset : box.offsets) {
+    offset += adjustment;
+  }
+}
+
+absl::StatusOr<Co64Box> ParseCo64(riegeli::Reader& reader) {
+  Co64Box box;
+  uint32_t entry_count;
+  if (!riegeli::ReadBigEndian<uint32_t>(reader, entry_count)) {
+    return absl::DataLossError("Failed to read entry count");
+  }
+  box.offsets.reserve(entry_count);
+  for (uint32_t i = 0; i < entry_count; ++i) {
+    uint64_t offset;
+    if (!riegeli::ReadBigEndian<uint64_t>(reader, offset)) {
+      return absl::DataLossError("Failed to read offset");
+    }
+    box.offsets.push_back(offset);
+  }
+  return box;
+}
+
+absl::Status SerializeCo64(const Co64Box& box, riegeli::Writer& writer) {
+  if (box.offsets.size() > 0xffffffff) {
+    return absl::InvalidArgumentError("Too many offsets");
+  }
+  if (!riegeli::WriteBigEndian<uint32_t>(
+          static_cast<uint32_t>(box.offsets.size()), writer)) {
+    return writer.status();
+  }
+  for (uint64_t offset : box.offsets) {
+    if (!riegeli::WriteBigEndian<uint64_t>(offset, writer)) {
+      return writer.status();
+    }
+  }
+  return absl::OkStatus();
+}
+
+void AdjustOffsets(Co64Box& box, int64_t adjustment) {
+  for (auto& offset : box.offsets) {
+    offset += adjustment;
+  }
+}
+
+absl::StatusOr<SaioBox> ParseSaio(riegeli::Reader& reader,
+                                  const BmffBoxHeader& header) {
+  SaioBox box;
+  if ((header.flags[2] & 1) == 1) {
+    if (!riegeli::ReadBigEndian<uint32_t>(reader, box.aux_info_type)) {
+      return absl::DataLossError("Failed to read aux_info_type");
+    }
+    if (!riegeli::ReadBigEndian<uint32_t>(reader,
+                                          box.aux_info_type_parameter)) {
+      return absl::DataLossError("Failed to read aux_info_type_parameter");
+    }
+  }
+
+  uint32_t entry_count;
+  if (!riegeli::ReadBigEndian<uint32_t>(reader, entry_count)) {
+    return absl::DataLossError("Failed to read entry count");
+  }
+
+  box.offsets.reserve(entry_count);
+  for (uint32_t i = 0; i < entry_count; ++i) {
+    if (header.version == 0) {
+      uint32_t offset;
+      if (!riegeli::ReadBigEndian<uint32_t>(reader, offset)) {
+        return absl::DataLossError("Failed to read offset");
+      }
+      box.offsets.push_back(offset);
+    } else {
+      uint64_t offset;
+      if (!riegeli::ReadBigEndian<uint64_t>(reader, offset)) {
+        return absl::DataLossError("Failed to read offset");
+      }
+      box.offsets.push_back(offset);
+    }
+  }
+  return box;
+}
+
+absl::Status SerializeSaio(const SaioBox& box, const BmffBoxHeader& header,
+                           riegeli::Writer& writer) {
+  if ((header.flags[2] & 1) == 1) {
+    if (!riegeli::WriteBigEndian<uint32_t>(box.aux_info_type, writer)) {
+      return writer.status();
+    }
+    if (!riegeli::WriteBigEndian<uint32_t>(box.aux_info_type_parameter,
+                                           writer)) {
+      return writer.status();
+    }
+  }
+
+  if (box.offsets.size() > 0xffffffff) {
+    return absl::InvalidArgumentError("Too many offsets");
+  }
+  if (!riegeli::WriteBigEndian<uint32_t>(
+          static_cast<uint32_t>(box.offsets.size()), writer)) {
+    return writer.status();
+  }
+
+  for (uint64_t offset : box.offsets) {
+    if (header.version == 0) {
+      if (offset > 0xffffffff) {
+        return absl::InvalidArgumentError("Offset too large for version 0");
+      }
+      if (!riegeli::WriteBigEndian<uint32_t>(static_cast<uint32_t>(offset),
+                                             writer)) {
+        return writer.status();
+      }
+    } else {
+      if (!riegeli::WriteBigEndian<uint64_t>(offset, writer)) {
+        return writer.status();
+      }
+    }
+  }
+  return absl::OkStatus();
+}
+
+void AdjustOffsets(SaioBox& box, int64_t adjustment) {
+  for (auto& offset : box.offsets) {
+    offset += adjustment;
+  }
+}
+
+}  // namespace credentio
diff --git a/formats/bmff/offset_adjustment.h b/formats/bmff/offset_adjustment.h
new file mode 100644
index 0000000..ae3ada8
--- /dev/null
+++ b/formats/bmff/offset_adjustment.h
@@ -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.
+//
+
+#ifndef THIRD_PARTY_CREDENTIO_FORMATS_BMFF_OFFSET_ADJUSTMENT_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_BMFF_OFFSET_ADJUSTMENT_H_
+
+#include <cstdint>
+#include <vector>
+
+#include "absl/status/status.h"
+#include "absl/status/statusor.h"
+#include "formats/bmff/box_header.h"
+#include "riegeli/bytes/reader.h"
+#include "riegeli/bytes/writer.h"
+
+namespace credentio {
+
+struct IlocExtent {
+  uint64_t index = 0;
+  uint64_t offset = 0;
+  uint64_t length = 0;
+};
+
+struct IlocItem {
+  uint32_t item_id = 0;
+  uint16_t construction_method = 0;  // Present if version 1 or 2
+  uint16_t data_reference_index = 0;
+  uint64_t base_offset = 0;
+  std::vector<IlocExtent> extents;
+};
+
+struct IlocBox {
+  uint8_t offset_size = 0;
+  uint8_t length_size = 0;
+  uint8_t base_offset_size = 0;
+  uint8_t index_size = 0;
+  std::vector<IlocItem> items;
+};
+
+struct StcoBox {
+  std::vector<uint32_t> offsets;
+};
+
+struct Co64Box {
+  std::vector<uint64_t> offsets;
+};
+
+struct SaioBox {
+  uint32_t aux_info_type = 0;
+  uint32_t aux_info_type_parameter = 0;
+  std::vector<uint64_t> offsets;
+};
+
+// Parses the ILOC box payload from the reader.
+// The reader should be positioned at the start of the box payload (after the
+// header).
+absl::StatusOr<IlocBox> ParseIloc(riegeli::Reader& reader,
+                                  const BmffBoxHeader& header);
+
+// Serializes the ILOC box payload to the writer.
+absl::Status SerializeIloc(const IlocBox& box, const BmffBoxHeader& header,
+                           riegeli::Writer& writer);
+
+// Adjusts the offsets in the ILOC box.
+// Only extents with data_reference_index == 0 are adjusted.
+void AdjustOffsets(IlocBox& box, int64_t adjustment);
+
+// Parses the STCO box payload from the reader.
+absl::StatusOr<StcoBox> ParseStco(riegeli::Reader& reader);
+
+// Serializes the STCO box payload to the writer.
+absl::Status SerializeStco(const StcoBox& box, riegeli::Writer& writer);
+
+// Adjusts the offsets in the STCO box.
+void AdjustOffsets(StcoBox& box, int64_t adjustment);
+
+// Parses the CO64 box payload from the reader.
+absl::StatusOr<Co64Box> ParseCo64(riegeli::Reader& reader);
+
+// Serializes the CO64 box payload to the writer.
+absl::Status SerializeCo64(const Co64Box& box, riegeli::Writer& writer);
+
+// Adjusts the offsets in the CO64 box.
+void AdjustOffsets(Co64Box& box, int64_t adjustment);
+
+// Parses the SAIO box payload from the reader.
+absl::StatusOr<SaioBox> ParseSaio(riegeli::Reader& reader,
+                                  const BmffBoxHeader& header);
+
+// Serializes the SAIO box payload to the writer.
+absl::Status SerializeSaio(const SaioBox& box, const BmffBoxHeader& header,
+                           riegeli::Writer& writer);
+
+// Adjusts the offsets in the SAIO box.
+void AdjustOffsets(SaioBox& box, int64_t adjustment);
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_BMFF_OFFSET_ADJUSTMENT_H_
diff --git a/formats/bmff/offset_adjustment_test.cc b/formats/bmff/offset_adjustment_test.cc
new file mode 100644
index 0000000..eb512bc
--- /dev/null
+++ b/formats/bmff/offset_adjustment_test.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 "formats/bmff/offset_adjustment.h"
+
+#include <string>
+#include <vector>
+
+#include "absl/status/status.h"
+#include "absl/status/status_matchers.h"
+#include "absl/strings/str_cat.h"
+#include "formats/bmff/box_header.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::StatusIs;
+using ::credentio_testing::Uint16Str;
+using ::credentio_testing::Uint32Str;
+using ::credentio_testing::Uint64Str;
+using ::credentio_testing::Uint8Str;
+using ::testing::ElementsAre;
+using ::testing::HasSubstr;
+
+TEST(OffsetAdjustmentTest, Stco) {
+  std::string data = absl::StrCat(Uint32Str(2),    // entry_count
+                                  Uint32Str(100),  // offset 1
+                                  Uint32Str(200)   // offset 2
+  );
+
+  riegeli::StringReader reader(data);
+  auto box_or = ParseStco(reader);
+  ABSL_ASSERT_OK(box_or);
+  StcoBox box = *box_or;
+  EXPECT_THAT(box.offsets, ElementsAre(100, 200));
+
+  AdjustOffsets(box, 50);
+  EXPECT_THAT(box.offsets, ElementsAre(150, 250));
+
+  std::string serialized;
+  riegeli::StringWriter writer(&serialized);
+  ABSL_ASSERT_OK(SerializeStco(box, writer));
+  writer.Close();
+
+  std::string expected_data = absl::StrCat(Uint32Str(2),    // entry_count
+                                           Uint32Str(150),  // offset 1
+                                           Uint32Str(250)   // offset 2
+  );
+  EXPECT_EQ(serialized, expected_data);
+}
+
+TEST(OffsetAdjustmentTest, Co64) {
+  std::string data = absl::StrCat(Uint32Str(2),    // entry_count
+                                  Uint64Str(100),  // offset 1
+                                  Uint64Str(200)   // offset 2
+  );
+
+  riegeli::StringReader reader(data);
+  auto box_or = ParseCo64(reader);
+  ABSL_ASSERT_OK(box_or);
+  Co64Box box = *box_or;
+  EXPECT_THAT(box.offsets, ElementsAre(100, 200));
+
+  AdjustOffsets(box, 50);
+  EXPECT_THAT(box.offsets, ElementsAre(150, 250));
+
+  std::string serialized;
+  riegeli::StringWriter writer(&serialized);
+  ABSL_ASSERT_OK(SerializeCo64(box, writer));
+  writer.Close();
+
+  std::string expected_data = absl::StrCat(Uint32Str(2),    // entry_count
+                                           Uint64Str(150),  // offset 1
+                                           Uint64Str(250)   // offset 2
+  );
+  EXPECT_EQ(serialized, expected_data);
+}
+
+TEST(OffsetAdjustmentTest, SaioVersion0NoAuxInfo) {
+  BmffBoxHeader header;
+  header.version = 0;
+  header.flags = std::string("\x00\x00\x00", 3);
+
+  std::string data = absl::StrCat(Uint32Str(2),    // entry_count
+                                  Uint32Str(100),  // offset 1
+                                  Uint32Str(200)   // offset 2
+  );
+
+  riegeli::StringReader reader(data);
+  auto box_or = ParseSaio(reader, header);
+  ABSL_ASSERT_OK(box_or);
+  SaioBox box = *box_or;
+  EXPECT_THAT(box.offsets, ElementsAre(100, 200));
+
+  AdjustOffsets(box, 50);
+  EXPECT_THAT(box.offsets, ElementsAre(150, 250));
+
+  std::string serialized;
+  riegeli::StringWriter writer(&serialized);
+  ABSL_ASSERT_OK(SerializeSaio(box, header, writer));
+  writer.Close();
+
+  std::string expected_data = absl::StrCat(Uint32Str(2),    // entry_count
+                                           Uint32Str(150),  // offset 1
+                                           Uint32Str(250)   // offset 2
+  );
+  EXPECT_EQ(serialized, expected_data);
+}
+
+TEST(OffsetAdjustmentTest, SaioVersion1WithAuxInfo) {
+  BmffBoxHeader header;
+  header.version = 1;
+  header.flags = std::string("\x00\x00\x01", 3);  // flags & 1
+
+  std::string data =
+      absl::StrCat(Uint32Str(0x12345678),  // aux_info_type
+                   Uint32Str(0xabcdef01),  // aux_info_type_parameter
+                   Uint32Str(2),           // entry_count
+                   Uint64Str(100),         // offset 1
+                   Uint64Str(200)          // offset 2
+      );
+
+  riegeli::StringReader reader(data);
+  auto box_or = ParseSaio(reader, header);
+  ABSL_ASSERT_OK(box_or);
+  SaioBox box = *box_or;
+  EXPECT_EQ(box.aux_info_type, 0x12345678);
+  EXPECT_EQ(box.aux_info_type_parameter, 0xabcdef01);
+  EXPECT_THAT(box.offsets, ElementsAre(100, 200));
+
+  AdjustOffsets(box, 50);
+  EXPECT_THAT(box.offsets, ElementsAre(150, 250));
+
+  std::string serialized;
+  riegeli::StringWriter writer(&serialized);
+  ABSL_ASSERT_OK(SerializeSaio(box, header, writer));
+  writer.Close();
+
+  std::string expected_data =
+      absl::StrCat(Uint32Str(0x12345678),  // aux_info_type
+                   Uint32Str(0xabcdef01),  // aux_info_type_parameter
+                   Uint32Str(2),           // entry_count
+                   Uint64Str(150),         // offset 1
+                   Uint64Str(250)          // offset 2
+      );
+  EXPECT_EQ(serialized, expected_data);
+}
+
+TEST(OffsetAdjustmentTest, IlocVersion0) {
+  BmffBoxHeader header;
+  header.version = 0;
+
+  // offset_size = 4, length_size = 4 -> 0x44
+  // base_offset_size = 4, index_size = 0 -> 0x40
+  std::string data =
+      absl::StrCat(Uint8Str(0x44),  // offset_size, length_size
+                   Uint8Str(0x40),  // base_offset_size, index_size
+                   Uint16Str(1),    // item_count
+                   // Item 1
+                   Uint16Str(1),     // item_id
+                   Uint16Str(0),     // data_reference_index
+                   Uint32Str(1000),  // base_offset
+                   Uint16Str(2),     // extent_count
+                   // Extent 1
+                   Uint32Str(100),  // offset
+                   Uint32Str(20),   // length
+                   // Extent 2
+                   Uint32Str(200),  // offset
+                   Uint32Str(30)    // length
+      );
+
+  riegeli::StringReader reader(data);
+  auto box_or = ParseIloc(reader, header);
+  ABSL_ASSERT_OK(box_or);
+  IlocBox box = *box_or;
+  EXPECT_EQ(box.offset_size, 4);
+  EXPECT_EQ(box.length_size, 4);
+  EXPECT_EQ(box.base_offset_size, 4);
+  EXPECT_EQ(box.index_size, 0);
+  ASSERT_EQ(box.items.size(), 1);
+  EXPECT_EQ(box.items[0].item_id, 1);
+  EXPECT_EQ(box.items[0].data_reference_index, 0);
+  EXPECT_EQ(box.items[0].base_offset, 1000);
+  ASSERT_EQ(box.items[0].extents.size(), 2);
+  EXPECT_EQ(box.items[0].extents[0].offset, 100);
+  EXPECT_EQ(box.items[0].extents[0].length, 20);
+  EXPECT_EQ(box.items[0].extents[1].offset, 200);
+  EXPECT_EQ(box.items[0].extents[1].length, 30);
+
+  AdjustOffsets(box, 50);
+  EXPECT_EQ(box.items[0].base_offset, 1050);
+  EXPECT_EQ(box.items[0].extents[0].offset, 100);
+  EXPECT_EQ(box.items[0].extents[1].offset, 200);
+
+  std::string serialized;
+  riegeli::StringWriter writer(&serialized);
+  ABSL_ASSERT_OK(SerializeIloc(box, header, writer));
+  writer.Close();
+
+  std::string expected_data =
+      absl::StrCat(Uint8Str(0x44),  // offset_size, length_size
+                   Uint8Str(0x40),  // base_offset_size, index_size
+                   Uint16Str(1),    // item_count
+                   // Item 1
+                   Uint16Str(1),     // item_id
+                   Uint16Str(0),     // data_reference_index
+                   Uint32Str(1050),  // base_offset
+                   Uint16Str(2),     // extent_count
+                   // Extent 1
+                   Uint32Str(100),  // offset
+                   Uint32Str(20),   // length
+                   // Extent 2
+                   Uint32Str(200),  // offset
+                   Uint32Str(30)    // length
+      );
+  EXPECT_EQ(serialized, expected_data);
+}
+
+TEST(OffsetAdjustmentTest, IlocVersion1WithIndex) {
+  BmffBoxHeader header;
+  header.version = 1;
+
+  // offset_size = 4, length_size = 4 -> 0x44
+  // base_offset_size = 4, index_size = 4 -> 0x44
+  std::string data =
+      absl::StrCat(Uint8Str(0x44),  // offset_size, length_size
+                   Uint8Str(0x44),  // base_offset_size, index_size
+                   Uint16Str(1),    // item_count
+                   // Item 1
+                   Uint16Str(1),       // item_id
+                   Uint16Str(0x1230),  // reserved_and_construction_method
+                   Uint16Str(0),       // data_reference_index
+                   Uint32Str(1000),    // base_offset
+                   Uint16Str(1),       // extent_count
+                   // Extent 1
+                   Uint32Str(5),    // index
+                   Uint32Str(100),  // offset
+                   Uint32Str(20)    // length
+      );
+
+  riegeli::StringReader reader(data);
+  auto box_or = ParseIloc(reader, header);
+  ABSL_ASSERT_OK(box_or);
+  IlocBox box = *box_or;
+  EXPECT_EQ(box.offset_size, 4);
+  EXPECT_EQ(box.length_size, 4);
+  EXPECT_EQ(box.base_offset_size, 4);
+  EXPECT_EQ(box.index_size, 4);
+  ASSERT_EQ(box.items.size(), 1);
+  EXPECT_EQ(box.items[0].item_id, 1);
+  EXPECT_EQ(box.items[0].construction_method, 0x1230);
+  EXPECT_EQ(box.items[0].data_reference_index, 0);
+  EXPECT_EQ(box.items[0].base_offset, 1000);
+  ASSERT_EQ(box.items[0].extents.size(), 1);
+  EXPECT_EQ(box.items[0].extents[0].index, 5);
+  EXPECT_EQ(box.items[0].extents[0].offset, 100);
+  EXPECT_EQ(box.items[0].extents[0].length, 20);
+
+  AdjustOffsets(box, 50);
+  EXPECT_EQ(box.items[0].base_offset, 1050);
+  EXPECT_EQ(box.items[0].extents[0].offset, 100);
+
+  std::string serialized;
+  riegeli::StringWriter writer(&serialized);
+  ABSL_ASSERT_OK(SerializeIloc(box, header, writer));
+  writer.Close();
+
+  std::string expected_data =
+      absl::StrCat(Uint8Str(0x44),  // offset_size, length_size
+                   Uint8Str(0x44),  // base_offset_size, index_size
+                   Uint16Str(1),    // item_count
+                   // Item 1
+                   Uint16Str(1),       // item_id
+                   Uint16Str(0x1230),  // reserved_and_construction_method
+                   Uint16Str(0),       // data_reference_index
+                   Uint32Str(1050),    // base_offset
+                   Uint16Str(1),       // extent_count
+                   // Extent 1
+                   Uint32Str(5),    // index
+                   Uint32Str(100),  // offset
+                   Uint32Str(20)    // length
+      );
+  EXPECT_EQ(serialized, expected_data);
+}
+
+TEST(OffsetAdjustmentTest, IlocVersion2LargeSizes) {
+  BmffBoxHeader header;
+  header.version = 2;
+
+  // offset_size = 8, length_size = 8 -> 0x88
+  // base_offset_size = 8, index_size = 8 -> 0x88
+  std::string data =
+      absl::StrCat(Uint8Str(0x88),  // offset_size, length_size
+                   Uint8Str(0x88),  // base_offset_size, index_size
+                   Uint32Str(1),    // item_count (32-bit in v2)
+                   // Item 1
+                   Uint32Str(1),       // item_id (32-bit in v2)
+                   Uint16Str(0x1230),  // reserved_and_construction_method
+                   Uint16Str(0),       // data_reference_index
+                   Uint64Str(1000),    // base_offset
+                   Uint16Str(1),       // extent_count
+                   // Extent 1
+                   Uint64Str(5),    // index
+                   Uint64Str(100),  // offset
+                   Uint64Str(20)    // length
+      );
+
+  riegeli::StringReader reader(data);
+  auto box_or = ParseIloc(reader, header);
+  ABSL_ASSERT_OK(box_or);
+  IlocBox box = *box_or;
+  EXPECT_EQ(box.offset_size, 8);
+  EXPECT_EQ(box.length_size, 8);
+  EXPECT_EQ(box.base_offset_size, 8);
+  EXPECT_EQ(box.index_size, 8);
+  ASSERT_EQ(box.items.size(), 1);
+  EXPECT_EQ(box.items[0].item_id, 1);
+  EXPECT_EQ(box.items[0].construction_method, 0x1230);
+  EXPECT_EQ(box.items[0].data_reference_index, 0);
+  EXPECT_EQ(box.items[0].base_offset, 1000);
+  ASSERT_EQ(box.items[0].extents.size(), 1);
+  EXPECT_EQ(box.items[0].extents[0].index, 5);
+  EXPECT_EQ(box.items[0].extents[0].offset, 100);
+  EXPECT_EQ(box.items[0].extents[0].length, 20);
+
+  AdjustOffsets(box, 50);
+  EXPECT_EQ(box.items[0].base_offset, 1050);
+  EXPECT_EQ(box.items[0].extents[0].offset, 100);
+
+  std::string serialized;
+  riegeli::StringWriter writer(&serialized);
+  ABSL_ASSERT_OK(SerializeIloc(box, header, writer));
+  writer.Close();
+
+  std::string expected_data =
+      absl::StrCat(Uint8Str(0x88),  // offset_size, length_size
+                   Uint8Str(0x88),  // base_offset_size, index_size
+                   Uint32Str(1),    // item_count (32-bit in v2)
+                   // Item 1
+                   Uint32Str(1),       // item_id (32-bit in v2)
+                   Uint16Str(0x1230),  // reserved_and_construction_method
+                   Uint16Str(0),       // data_reference_index
+                   Uint64Str(1050),    // base_offset
+                   Uint16Str(1),       // extent_count
+                   // Extent 1
+                   Uint64Str(5),    // index
+                   Uint64Str(100),  // offset
+                   Uint64Str(20)    // length
+      );
+  EXPECT_EQ(serialized, expected_data);
+}
+
+TEST(OffsetAdjustmentTest, IlocNonZeroDataReferenceIndexNotAdjusted) {
+  BmffBoxHeader header;
+  header.version = 0;
+
+  std::string data =
+      absl::StrCat(Uint8Str(0x44),  // offset_size, length_size
+                   Uint8Str(0x40),  // base_offset_size, index_size
+                   Uint16Str(1),    // item_count
+                   // Item 1
+                   Uint16Str(1),     // item_id
+                   Uint16Str(1),     // data_reference_index (Non-zero)
+                   Uint32Str(1000),  // base_offset
+                   Uint16Str(1),     // extent_count
+                   // Extent 1
+                   Uint32Str(100),  // offset
+                   Uint32Str(20)    // length
+      );
+
+  riegeli::StringReader reader(data);
+  auto box_or = ParseIloc(reader, header);
+  ABSL_ASSERT_OK(box_or);
+  IlocBox box = *box_or;
+  AdjustOffsets(box, 50);
+  // Offset should NOT be adjusted because data_reference_index != 0
+  EXPECT_EQ(box.items[0].extents[0].offset, 100);
+}
+
+TEST(OffsetAdjustmentTest, IlocZeroBaseOffsetSize) {
+  BmffBoxHeader header;
+  header.version = 0;
+
+  // offset_size = 4, length_size = 4 -> 0x44
+  // base_offset_size = 0, index_size = 0 -> 0x00
+  std::string data =
+      absl::StrCat(Uint8Str(0x44),  // offset_size, length_size
+                   Uint8Str(0x00),  // base_offset_size, index_size
+                   Uint16Str(1),    // item_count
+                   // Item 1
+                   Uint16Str(1),  // item_id
+                   Uint16Str(0),  // data_reference_index
+                   Uint16Str(1),  // extent_count
+                   // Extent 1
+                   Uint32Str(100),  // offset
+                   Uint32Str(20)    // length
+      );
+
+  riegeli::StringReader reader(data);
+  auto box_or = ParseIloc(reader, header);
+  ABSL_ASSERT_OK(box_or);
+  IlocBox box = *box_or;
+  AdjustOffsets(box, 50);
+  // Extent offset should be adjusted because base_offset_size == 0
+  EXPECT_EQ(box.items[0].extents[0].offset, 150);
+}
+
+TEST(OffsetAdjustmentTest, IlocZeroOffsetSize) {
+  BmffBoxHeader header;
+  header.version = 0;
+
+  // offset_size = 0, length_size = 4 -> 0x04
+  // base_offset_size = 4, index_size = 0 -> 0x40
+  std::string data =
+      absl::StrCat(Uint8Str(0x04),  // offset_size, length_size
+                   Uint8Str(0x40),  // base_offset_size, index_size
+                   Uint16Str(1),    // item_count
+                   // Item 1
+                   Uint16Str(1),     // item_id
+                   Uint16Str(0),     // data_reference_index
+                   Uint32Str(1000),  // base_offset
+                   Uint16Str(1),     // extent_count
+                   // Extent 1
+                   Uint32Str(20)  // length (No offset)
+      );
+
+  riegeli::StringReader reader(data);
+  auto box_or = ParseIloc(reader, header);
+  ABSL_ASSERT_OK(box_or);
+  IlocBox box = *box_or;
+  AdjustOffsets(box, 50);
+  // Base offset should be adjusted
+  EXPECT_EQ(box.items[0].base_offset, 1050);
+}
+
+TEST(OffsetAdjustmentTest, IlocNonZeroConstructionMethodNotAdjusted) {
+  BmffBoxHeader header;
+  header.version = 1;
+
+  // offset_size = 4, length_size = 4 -> 0x44
+  // base_offset_size = 4, index_size = 0 -> 0x40
+  std::string data =
+      absl::StrCat(Uint8Str(0x44),  // offset_size, length_size
+                   Uint8Str(0x40),  // base_offset_size, index_size
+                   Uint16Str(1),    // item_count
+                   // Item 1
+                   Uint16Str(1),       // item_id
+                   Uint16Str(0x0001),  // reserved (0), method (1)
+                   Uint16Str(0),       // data_reference_index
+                   Uint32Str(1000),    // base_offset
+                   Uint16Str(1),       // extent_count
+                   // Extent 1
+                   Uint32Str(100),  // offset
+                   Uint32Str(20)    // length
+      );
+
+  riegeli::StringReader reader(data);
+  auto box_or = ParseIloc(reader, header);
+  ABSL_ASSERT_OK(box_or);
+  IlocBox box = *box_or;
+  AdjustOffsets(box, 50);
+  // Offset should NOT be adjusted because construction_method != 0
+  EXPECT_EQ(box.items[0].base_offset, 1000);
+  EXPECT_EQ(box.items[0].extents[0].offset, 100);
+}
+
+TEST(OffsetAdjustmentTest, IlocSerializeInvalidSize) {
+  BmffBoxHeader header;
+  header.version = 0;
+
+  IlocBox box;
+  box.offset_size = 4;
+  box.length_size = 4;
+  box.base_offset_size = 1;  // Invalid size
+  box.index_size = 0;
+  box.items.resize(1);
+  box.items[0].item_id = 1;
+  box.items[0].data_reference_index = 0;
+  box.items[0].base_offset = 1000;
+  box.items[0].extents.resize(1);
+  box.items[0].extents[0].offset = 100;
+  box.items[0].extents[0].length = 20;
+
+  std::string serialized;
+  riegeli::StringWriter writer(&serialized);
+  EXPECT_THAT(SerializeIloc(box, header, writer),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("invalid byte_size: 1")));
+  writer.Close();
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/formats/bmff/writer.cc b/formats/bmff/writer.cc
new file mode 100644
index 0000000..3581109
--- /dev/null
+++ b/formats/bmff/writer.cc
@@ -0,0 +1,521 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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/writer.h"
+
+#include <algorithm>
+#include <cstddef>
+#include <cstdint>
+#include <memory>
+#include <optional>
+#include <string>
+#include <utility>
+#include <vector>
+
+#include "absl/base/nullability.h"
+#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/str_cat.h"
+#include "absl/strings/string_view.h"
+#include "absl/synchronization/notification.h"
+#include "async/async_context.h"
+#include "crypto/algorithms.h"
+#include "formats/bmff/box_header.h"
+#include "formats/bmff/embedder.h"
+#include "formats/bmff/hasher.h"
+#include "formats/byte_range.h"
+#include "proto/bmff_based_hash_assertion.pb.h"
+#include "proto/generator.pb.h"
+#include "riegeli/bytes/reader.h"
+#include "riegeli/bytes/string_writer.h"
+#include "riegeli/bytes/writer.h"
+#include "utils/byte_instruction.h"
+
+namespace credentio {
+namespace {
+
+constexpr int64_t kCreateFreeBoxThreshold = 30;
+
+// Scans through the source, modifying the source's values to adjust offsets to
+// account for the manifest size as needed and hashing the applicable bytes.
+// The method will return the offset where the manifest store should be
+// inserted, the length of the manifest store, and an ordered vector of byte
+// ranges of atoms which require offset adjustment before being written to the
+// destination.
+absl::StatusOr<std::vector<ByteInstruction>> InspectAndHash(
+    riegeli::Reader& source, uint64_t manifest_store_size,
+    const ByteRange& insertion_point, bool can_replace_manifest,
+    BmffBasedHashAssertion& assertion) {
+  if (!source.Seek(0) || source.pos() != 0) {
+    return absl::InvalidArgumentError("cannot seek to start of input");
+  }
+  BmffEmbedder embedder;
+  ABSL_ASSIGN_OR_RETURN(
+      std::vector<ByteInstruction> instructions,
+      embedder.InspectAndHash(source, manifest_store_size, insertion_point,
+                              can_replace_manifest, assertion));
+  return std::move(instructions);
+}
+
+// Wraps the manifest store in an atom.
+absl::StatusOr<std::string> WrapManifestStoreInAtom(
+    absl::string_view manifest_store) {
+  std::string atom;
+  riegeli::StringWriter writer(&atom);
+  ABSL_RETURN_IF_ERROR(WriteC2paBmff(manifest_store, writer));
+  if (!writer.Close()) {
+    return absl::InternalError("failed to close string writer");
+  }
+  return std::move(atom);
+}
+
+absl::Status GenerateManifestStoreInstructions(
+    const ByteRange& insertion_point, absl::string_view manifest_store,
+    std::vector<ByteInstruction>& byte_instructions,
+    bool client_supplied_hard_binding = false) {
+  // Wrap the manifest store in an atom.
+  ABSL_ASSIGN_OR_RETURN(std::string manifest_atom,
+                        WrapManifestStoreInAtom(manifest_store));
+
+  if (client_supplied_hard_binding &&
+      insertion_point.length < manifest_atom.size()) {
+    return absl::ResourceExhaustedError(absl::StrCat(
+        "not enough space to embed C2PA manifest with client-supplied hard "
+        "binding (insertion_point.length=",
+        insertion_point.length, ", manifest_atom.size=", manifest_atom.size(),
+        "); skipping embedding to avoid byte shifting"));
+  }
+
+  if (insertion_point.length == 0) {
+    // No overwritable bytes, only insert.
+    byte_instructions.push_back(
+        {.operation = ByteInstruction::Operation::kInsert,
+         .offset = insertion_point.offset,
+         .bytes = std::move(manifest_atom)});
+    return absl::OkStatus();
+  }
+
+  if (insertion_point.length == manifest_atom.size()) {
+    // The atom fits exactly, just overwrite.
+    byte_instructions.push_back(
+        {.operation = ByteInstruction::Operation::kReplace,
+         .offset = insertion_point.offset,
+         .bytes = std::move(manifest_atom)});
+    return absl::OkStatus();
+  }
+
+  if (insertion_point.length < manifest_atom.size()) {
+    // Not enough space, overwrite the initial data, insert the
+    // remaining.
+    byte_instructions.push_back(
+        {.operation = ByteInstruction::Operation::kReplace,
+         .offset = insertion_point.offset,
+         .bytes = manifest_atom.substr(0, insertion_point.length)});
+    byte_instructions.push_back(
+        {.operation = ByteInstruction::Operation::kInsert,
+         .offset = insertion_point.offset + insertion_point.length,
+         .bytes = manifest_atom.substr(insertion_point.length)});
+    return absl::OkStatus();
+  }
+
+  // More than enough space
+  int64_t manifest_size = manifest_atom.size();
+  int64_t padding_size = insertion_point.length - manifest_size;
+
+  if (padding_size < kCreateFreeBoxThreshold) {
+    // Minor amount of remaining space, just pad with \0.
+    ABSL_ASSIGN_OR_RETURN(
+        manifest_atom, WrapManifestStoreInAtom(absl::StrCat(
+                           manifest_store, std::string(padding_size, '\0'))));
+    byte_instructions.push_back(
+        {.operation = ByteInstruction::Operation::kReplace,
+         .offset = insertion_point.offset,
+         .bytes = std::move(manifest_atom)});
+    return absl::OkStatus();
+  }
+
+  // Significant amount of remaining space, create a free box.
+  std::string free_box;
+  riegeli::StringWriter<> free_box_writer(&free_box);
+  ABSL_RETURN_IF_ERROR(CreateFreeBox(padding_size, free_box_writer));
+  if (!free_box_writer.Close()) {
+    return absl::InternalError("failed to close string writer");
+  }
+
+  byte_instructions.push_back(
+      {.operation = ByteInstruction::Operation::kReplace,
+       .offset = insertion_point.offset,
+       .bytes = std::move(manifest_atom)});
+  byte_instructions.push_back(
+      {.operation = ByteInstruction::Operation::kReplace,
+       .offset = insertion_point.offset + manifest_size,
+       .bytes = std::move(free_box)});
+
+  return absl::OkStatus();
+}
+
+absl::StatusOr<bool> CanReplaceManifest(
+    const GenerateManifestStoreRequest& request) {
+  if (!request.has_actions()) {
+    return absl::InvalidArgumentError("request must include `actions`");
+  }
+  if (request.actions().inception_action_case() ==
+      Actions::INCEPTION_ACTION_NOT_SET) {
+    return absl::InvalidArgumentError(
+        "request must include an inception action (created or opened)");
+  }
+  return request.actions().has_opened();
+}
+
+}  // namespace
+
+absl::Status BmffWriter::Write(
+    GenerateManifestStoreRequest request, riegeli::Reader* absl_nonnull source,
+    riegeli::Writer* absl_nonnull destination,
+    OutputMetadata* absl_nullable output_metadata) const {
+  absl::Notification done;
+  absl::Status status;
+  Write(std::move(request), source, destination, output_metadata,
+        AsyncContext(), [&](absl::Status s) {
+          status = std::move(s);
+          done.Notify();
+        });
+  done.WaitForNotification();
+  return status;
+}
+
+void BmffWriter::Write(GenerateManifestStoreRequest request,
+                       riegeli::Reader* absl_nonnull source,
+                       riegeli::Writer* absl_nonnull destination,
+                       OutputMetadata* absl_nullable output_metadata,
+                       AsyncContext context,
+                       absl::AnyInvocable<void(absl::Status) &&> cb) const {
+  if (!source->SupportsRandomAccess()) {
+    AsyncResolver<void>(std::move(context), std::move(cb))
+        .Return(absl::InvalidArgumentError(
+            "source does not support random access"));
+    return;
+  }
+
+  auto byte_instructions = std::make_shared<std::vector<ByteInstruction>>();
+
+  WriteInternal(
+      std::move(request), source, output_metadata, byte_instructions, context,
+      [resolver = AsyncResolver<void>(context, std::move(cb)), source,
+       byte_instructions, destination](absl::Status status) mutable {
+        if (!status.ok()) {
+          resolver.Return(std::move(status));
+          return;
+        }
+        status = ApplyByteInstructions(source, *byte_instructions, destination);
+        resolver.Return(std::move(status));
+      });
+}
+
+absl::Status BmffWriter::Write(
+    GenerateManifestStoreRequest request, riegeli::Writer* absl_nonnull asset,
+    OutputMetadata* absl_nullable output_metadata) const {
+  absl::Notification done;
+  absl::Status status;
+  Write(std::move(request), asset, output_metadata, AsyncContext(),
+        [&](absl::Status s) {
+          status = std::move(s);
+          done.Notify();
+        });
+  done.WaitForNotification();
+  return status;
+}
+
+void BmffWriter::Write(GenerateManifestStoreRequest request,
+                       riegeli::Writer* absl_nonnull asset,
+                       OutputMetadata* absl_nullable output_metadata,
+                       AsyncContext context,
+                       absl::AnyInvocable<void(absl::Status) &&> cb) const {
+  if (!asset->SupportsRandomAccess()) {
+    AsyncResolver<void>(std::move(context), std::move(cb))
+        .Return(
+            absl::InvalidArgumentError("asset does not support random access"));
+    return;
+  }
+  if (!asset->SupportsReadMode()) {
+    AsyncResolver<void>(std::move(context), std::move(cb))
+        .Return(absl::InvalidArgumentError("asset does not support read mode"));
+    return;
+  }
+  if (request.options().has_max_bytes_to_shift() &&
+      request.options().max_bytes_to_shift() < 0) {
+    AsyncResolver<void>(std::move(context), std::move(cb))
+        .Return(absl::InvalidArgumentError(
+            "max_bytes_to_shift must be non-negative"));
+    return;
+  }
+
+  auto options = request.options();
+  riegeli::Reader* source = asset->ReadMode(0);
+  auto byte_instructions = std::make_shared<std::vector<ByteInstruction>>();
+
+  WriteInternal(
+      std::move(request), source, output_metadata, byte_instructions, context,
+      [resolver = AsyncResolver<void>(context, std::move(cb)), asset,
+       options = std::move(options),
+       byte_instructions](absl::Status status) mutable {
+        if (!status.ok()) {
+          resolver.Return(std::move(status));
+          return;
+        }
+
+        std::optional<size_t> insert_idx;
+        auto first_insert_it = std::find_if(
+            byte_instructions->begin(), byte_instructions->end(),
+            [](const ByteInstruction& instr) {
+              return instr.operation == ByteInstruction::Operation::kInsert;
+            });
+        if (first_insert_it != byte_instructions->end()) {
+          insert_idx = first_insert_it - byte_instructions->begin();
+        }
+
+        auto file_size = asset->Size();
+        uint64_t bytes_shifted = 0;
+        if (file_size.has_value() && insert_idx.has_value() &&
+            *file_size > (*byte_instructions)[*insert_idx].offset) {
+          bytes_shifted = *file_size - (*byte_instructions)[*insert_idx].offset;
+        }
+
+        if (options.has_max_bytes_to_shift() &&
+            bytes_shifted >
+                static_cast<uint64_t>(options.max_bytes_to_shift())) {
+          uint64_t overwritable_bytes = 0;
+          if (*insert_idx > 0) {
+            const auto& prev_instruction =
+                (*byte_instructions)[*insert_idx - 1];
+            if (prev_instruction.operation ==
+                    ByteInstruction::Operation::kReplace &&
+                prev_instruction.offset + prev_instruction.bytes.size() ==
+                    (*byte_instructions)[*insert_idx].offset) {
+              overwritable_bytes = prev_instruction.bytes.size();
+            }
+          }
+          uint64_t manifest_atom_size =
+              overwritable_bytes +
+              (*byte_instructions)[*insert_idx].bytes.size();
+          resolver.Return(absl::InvalidArgumentError(absl::StrCat(
+              "embedding requires shifting ", bytes_shifted,
+              " bytes, which exceeds the limit of ",
+              options.max_bytes_to_shift(),
+              " bytes (pre-allocated manifest size: ", overwritable_bytes,
+              " bytes, final manifest size: ", manifest_atom_size, " bytes)")));
+          return;
+        }
+
+        if (!asset->Seek(0)) {
+          resolver.Return(
+              absl::InternalError("failed to seek to start of asset"));
+          return;
+        }
+        status = ApplyByteInstructions(*byte_instructions, asset);
+        resolver.Return(std::move(status));
+      });
+}
+
+void BmffWriter::WriteInternal(
+    GenerateManifestStoreRequest request, riegeli::Reader* absl_nonnull source,
+    OutputMetadata* absl_nullable output_metadata,
+    std::shared_ptr<std::vector<ByteInstruction>> byte_instructions,
+    AsyncContext context, absl::AnyInvocable<void(absl::Status) &&> cb) const {
+  AsyncResolver<void> resolver(context, std::move(cb));
+  if (source->pos() != 0) {
+    resolver.Return(absl::InvalidArgumentError(
+        "source must be positioned at the start of the file"));
+    return;
+  }
+  auto can_replace_manifest = CanReplaceManifest(request);
+  if (!can_replace_manifest.ok()) {
+    resolver.Return(
+        absl::Status(can_replace_manifest.status().code(),
+                     absl::StrCat(can_replace_manifest.status().message(),
+                                  "; verifying request has valid actions")));
+    return;
+  }
+
+  if (request.has_hard_binding() &&
+      !request.hard_binding().has_bmff_based_hash()) {
+    resolver.Return(absl::InvalidArgumentError(
+        "request `hard_binding` must be a `bmff_based_hash` assertion"));
+    return;
+  }
+
+  auto insertion_point = LocateManifestInsertionPoint(*source);
+  if (!insertion_point.ok()) {
+    resolver.Return(
+        absl::Status(insertion_point.status().code(),
+                     absl::StrCat(insertion_point.status().message(),
+                                  "; locating manifest insertion point")));
+    return;
+  }
+
+  if (request.has_hard_binding()) {
+    generator_.GenerateManifestStore(
+        std::move(request), context,
+        [output_metadata, byte_instructions, insertion_point = *insertion_point,
+         resolver = std::move(resolver)](
+            absl::StatusOr<GenerateManifestStoreResponse> response) mutable {
+          if (!response.ok()) {
+            resolver.Return(response.status());
+            return;
+          }
+
+          constexpr int64_t kMaxManifestStoreSizeBytes =
+              100 * 1024 * 1024;  // 100 MiB
+          if (response->manifest_store().empty() ||
+              response->manifest_store().size() > kMaxManifestStoreSizeBytes) {
+            resolver.Return(absl::InvalidArgumentError(
+                absl::StrCat("invalid manifest store size: ",
+                             response->manifest_store().size())));
+            return;
+          }
+
+          // Add the byte instructions for the manifest store atom to the
+          // result.
+          auto gen_status = GenerateManifestStoreInstructions(
+              insertion_point, response->manifest_store(), *byte_instructions,
+              /*client_supplied_hard_binding=*/true);
+          if (!gen_status.ok()) {
+            resolver.Return(std::move(gen_status));
+            return;
+          }
+
+          // Sort all byte instructions by offset.
+          std::sort(byte_instructions->begin(), byte_instructions->end(),
+                    [](const ByteInstruction& a, const ByteInstruction& b) {
+                      return a.offset < b.offset;
+                    });
+
+          if (output_metadata != nullptr) {
+            output_metadata->timestamp_included = response->is_timestamped();
+          }
+          resolver.Return(absl::OkStatus());
+        });
+    return;
+  }
+
+  // Step 1: Estimate the manifest size
+  *request.mutable_hard_binding()->mutable_bmff_based_hash() =
+      GetBmffHashTemplate(HashAlgorithm::kSha256);
+
+  if (options_.exclusion_processor != nullptr) {
+    auto status = options_.exclusion_processor(*source, request);
+    if (!status.ok()) {
+      resolver.Return(std::move(status));
+      return;
+    }
+  }
+
+  generator_.GenerateManifestStoreTemplate(
+      request, context,
+      [this, source, request, can_replace_manifest = *can_replace_manifest,
+       output_metadata, byte_instructions, insertion_point = *insertion_point,
+       context, resolver = std::move(resolver)](
+          absl::StatusOr<ManifestStoreTemplate> template_response) mutable {
+        if (!template_response.ok()) {
+          resolver.Return(template_response.status());
+          return;
+        }
+
+        // Step 2: Construct the final generated manifest store request.
+        constexpr int64_t kMaxManifestStoreSizeBytes =
+            100 * 1024 * 1024;  // 100 MiB
+        if (template_response->manifest_store_size() <= 0 ||
+            template_response->manifest_store_size() >
+                kMaxManifestStoreSizeBytes) {
+          resolver.Return(absl::InvalidArgumentError(
+              absl::StrCat("invalid manifest store template size: ",
+                           template_response->manifest_store_size())));
+          return;
+        }
+
+        FinalizeManifestStoreRequest finalize_request;
+        *finalize_request.mutable_manifest_store_template() =
+            std::move(*template_response);
+        *finalize_request.mutable_hard_binding()->mutable_bmff_based_hash() =
+            request.hard_binding().bmff_based_hash();
+
+        // Step 3: Determine where the manifest should be inserted, where atoms
+        // which need adjustment are located, and compute the hash of the
+        // applicable bytes.
+        auto inspect_res = InspectAndHash(
+            *source,
+            finalize_request.manifest_store_template().manifest_store_size(),
+            insertion_point, can_replace_manifest,
+            *finalize_request.mutable_hard_binding()
+                 ->mutable_bmff_based_hash());
+        if (!inspect_res.ok()) {
+          resolver.Return(absl::Status(
+              inspect_res.status().code(),
+              absl::StrCat(inspect_res.status().message(),
+                           "; embedding manifest store template")));
+          return;
+        }
+        *byte_instructions = std::move(*inspect_res);
+
+        // Step 4: Generate the finalized manifest store.
+        const size_t expected_manifest_store_size =
+            finalize_request.manifest_store_template().manifest_store_size();
+
+        generator_.FinalizeManifestStore(
+            std::move(finalize_request), context,
+            [expected_manifest_store_size, insertion_point, output_metadata,
+             byte_instructions, resolver = std::move(resolver)](
+                absl::StatusOr<FinalizeManifestStoreResponse>
+                    response) mutable {
+              if (!response.ok()) {
+                resolver.Return(response.status());
+                return;
+              }
+
+              if (response->manifest_store().size() !=
+                  expected_manifest_store_size) {
+                resolver.Return(absl::InternalError(
+                    "manifest store size does not match template"));
+                return;
+              }
+
+              // Add the byte instructions for the manifest store atom to the
+              // result.
+              auto gen_status = GenerateManifestStoreInstructions(
+                  insertion_point, response->manifest_store(),
+                  *byte_instructions);
+              if (!gen_status.ok()) {
+                resolver.Return(std::move(gen_status));
+                return;
+              }
+
+              // Sort all byte instructions by offset.
+              std::sort(byte_instructions->begin(), byte_instructions->end(),
+                        [](const ByteInstruction& a, const ByteInstruction& b) {
+                          return a.offset < b.offset;
+                        });
+
+              if (output_metadata != nullptr) {
+                output_metadata->timestamp_included =
+                    response->is_timestamped();
+              }
+              resolver.Return(absl::OkStatus());
+            });
+      });
+}
+
+}  // namespace credentio
diff --git a/formats/bmff/writer.h b/formats/bmff/writer.h
new file mode 100644
index 0000000..3c4d89b
--- /dev/null
+++ b/formats/bmff/writer.h
@@ -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.
+//
+
+#ifndef THIRD_PARTY_CREDENTIO_FORMATS_BMFF_WRITER_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_BMFF_WRITER_H_
+
+#include <memory>
+#include <utility>
+#include <vector>
+
+#include "absl/base/nullability.h"
+#include "absl/functional/any_invocable.h"
+#include "absl/log/die_if_null.h"
+#include "absl/status/status.h"
+#include "async/async_context.h"
+#include "generator/generator.h"
+#include "proto/generator.pb.h"
+#include "riegeli/bytes/reader.h"
+#include "riegeli/bytes/writer.h"
+#include "utils/byte_instruction.h"
+
+namespace credentio {
+
+// Writer of C2PA manifest stores into BMFF assets.
+class BmffWriter {
+ public:
+  // Information about the C2PA manifest store embedded in the output asset.
+  struct OutputMetadata {
+    // Whether the C2PA active manifest embedded in the output asset has a
+    // timestamp.
+    bool timestamp_included = false;
+  };
+
+  using ExclusionProcessor = absl::AnyInvocable<absl::Status(
+      riegeli::Reader&, GenerateManifestStoreRequest&) const>;
+
+  struct Options {
+    ExclusionProcessor exclusion_processor = nullptr;
+  };
+
+  // Creates a BMFF writer.
+  //
+  // The generator must outlive the writer.
+  explicit BmffWriter(const ManifestStoreGenerator* absl_nonnull generator)
+      : BmffWriter(generator, Options{}) {}
+
+  BmffWriter(const ManifestStoreGenerator* absl_nonnull generator,
+             Options options)
+      : generator_(*ABSL_DIE_IF_NULL(generator)),
+        options_(std::move(options)) {}
+
+  // Generates a C2PA manifest store and embeds it into a copy of the given
+  // asset. The riegeli::Reader and riegeli::Writer cannot be pointed to the
+  // same underlying data.
+  void Write(GenerateManifestStoreRequest request,
+             riegeli::Reader* absl_nonnull source,
+             riegeli::Writer* absl_nonnull destination,
+             OutputMetadata* absl_nullable output_metadata,
+             AsyncContext context,
+             absl::AnyInvocable<void(absl::Status) &&> cb) const;
+  // Blocking version.
+  absl::Status Write(
+      GenerateManifestStoreRequest request,
+      riegeli::Reader* absl_nonnull source,
+      riegeli::Writer* absl_nonnull destination,
+      OutputMetadata* absl_nullable output_metadata = nullptr) const;
+
+  // Generates a C2PA manifest store and embeds it in the given asset just
+  // as the above methods, but does so in-place.
+  //
+  // The supplied writer must support random access and read mode. Read mode is
+  // not enabled by default on the riegeli::StringWriter or riegeli::FileWriter,
+  // it can be enabled by passing an Option to their constructors:
+  //  - StringWriter(&data, StringWriterBase::Options().set_append(true));
+  //  - FileWriter(filepath, FileWriterBase::Options().set_existing(true));
+  void Write(GenerateManifestStoreRequest request,
+             riegeli::Writer* absl_nonnull asset,
+             OutputMetadata* absl_nullable output_metadata,
+             AsyncContext context,
+             absl::AnyInvocable<void(absl::Status) &&> cb) const;
+  // Blocking version.
+  absl::Status Write(
+      GenerateManifestStoreRequest request, riegeli::Writer* absl_nonnull asset,
+      OutputMetadata* absl_nullable output_metadata = nullptr) const;
+
+ private:
+  void WriteInternal(
+      GenerateManifestStoreRequest request,
+      riegeli::Reader* absl_nonnull source,
+      OutputMetadata* absl_nullable output_metadata,
+      std::shared_ptr<std::vector<ByteInstruction>> byte_instructions,
+      AsyncContext context, absl::AnyInvocable<void(absl::Status) &&> cb) const;
+
+  const ManifestStoreGenerator& generator_;
+  Options options_;
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_BMFF_WRITER_H_
diff --git a/formats/gif/BUILD b/formats/gif/BUILD
index 19badc8..f6abcb2 100644
--- a/formats/gif/BUILD
+++ b/formats/gif/BUILD
@@ -69,6 +69,91 @@
 )
 
 cc_library(
+    name = "block",
+    srcs = ["block.cc"],
+    hdrs = ["block.h"],
+    deps = [
+        ":constants",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/strings:string_view",
+        "@riegeli//riegeli/bytes:writer",
+        "@riegeli//riegeli/endian:endian_writing",
+    ],
+)
+
+cc_test(
+    name = "block_test",
+    srcs = ["block_test.cc"],
+    deps = [
+        ":block",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@googletest//:gtest_main",
+        "@riegeli//riegeli/bytes:string_writer",
+    ],
+)
+
+cc_library(
+    name = "embedder",
+    srcs = ["embedder.cc"],
+    hdrs = ["embedder.h"],
+    deps = [
+        ":block",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/strings:string_view",
+        "@riegeli//riegeli/bytes:copy_all",
+        "@riegeli//riegeli/bytes:reader",
+        "@riegeli//riegeli/bytes:writer",
+    ],
+)
+
+cc_test(
+    name = "embedder_test",
+    srcs = ["embedder_test.cc"],
+    deps = [
+        ":embedder",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@googletest//:gtest_main",
+        "@riegeli//riegeli/bytes:string_reader",
+        "@riegeli//riegeli/bytes:string_writer",
+    ],
+)
+
+cc_library(
+    name = "hasher",
+    srcs = ["hasher.cc"],
+    hdrs = ["hasher.h"],
+    deps = [
+        ":constants",
+        ":reader",
+        "//bindings:input_hasher",
+        "//crypto:algorithms",
+        "//proto:data_hash_assertion_cc_proto",
+        "@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 = "hasher_test",
+    srcs = ["hasher_test.cc"],
+    deps = [
+        ":constants",
+        ":hasher",
+        ":test_builder",
+        "//crypto:algorithms",
+        "//testing:proto_test_utils",
+        "@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"],
@@ -147,3 +232,29 @@
         "@abseil-cpp//absl/status:statusor",
     ],
 )
+
+cc_library(
+    name = "writer",
+    srcs = ["writer.cc"],
+    hdrs = ["writer.h"],
+    deps = [
+        ":block",
+        ":embedder",
+        ":hasher",
+        "//async:async_context",
+        "//crypto:algorithms",
+        "//generator",
+        "//proto:data_hash_assertion_cc_proto",
+        "//proto:generator_cc_proto",
+        "@abseil-cpp//absl/base:nullability",
+        "@abseil-cpp//absl/functional:any_invocable",
+        "@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:string_view",
+        "@abseil-cpp//absl/synchronization",
+        "@riegeli//riegeli/bytes:reader",
+        "@riegeli//riegeli/bytes:writer",
+    ],
+)
diff --git a/formats/gif/block.cc b/formats/gif/block.cc
new file mode 100644
index 0000000..7cb06d2
--- /dev/null
+++ b/formats/gif/block.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/gif/block.h"
+
+#include <algorithm>
+#include <cstdint>
+
+#include "absl/status/status.h"
+#include "absl/strings/string_view.h"
+#include "formats/gif/constants.h"
+#include "riegeli/bytes/writer.h"
+#include "riegeli/endian/endian_writing.h"
+
+namespace credentio {
+
+namespace {
+
+absl::Status WriteDataAsBlocks(absl::string_view payload,
+                               riegeli::Writer& output) {
+  for (int64_t start = 0; start < payload.size(); start += 255) {
+    int64_t block_size = std::min<int64_t>(255, payload.size() - start);
+
+    if (!output.WriteByte(block_size)) {
+      return output.status();
+    }
+    if (!output.Write(payload.substr(start, block_size))) {
+      return output.status();
+    }
+  }
+
+  if (!output.WriteByte(0)) {
+    return output.status();
+  }
+
+  return absl::OkStatus();
+}
+
+}  // namespace
+
+// Given the size of a jumbf payload in bytes, it will return the total number
+// of bytes required to store the payload within a GIF block where the data is
+// segmented into blocks of at most 255 bytes.
+uint64_t CalculateTotalGifC2paBlockSize(uint64_t jumbf_payload_size) {
+  // The C2PA block is:
+  // - 1 byte for the extension introducer (0x21)
+  // - 1 byte for the extension type (0xFF)
+  // - 1 byte for the informational data (always 0x0B)
+  // - 11 bytes for the c2pa informational data
+  // - ceil(jumbf_payload_size / 255) + jumbf_payload_size bytes for the data
+  // - 1 byte for the termination block (0x00)
+  return 15 + jumbf_payload_size + (jumbf_payload_size + 254) / 255;
+}
+
+// Writes the given payload as a GIF block with n blocks of at most 255 bytes
+// each.
+absl::Status WriteGifC2paBlock(absl::string_view payload,
+                               riegeli::Writer& output) {
+  if (!output.WriteByte(kGifExtensionIntroducer)) {
+    return output.status();
+  }
+  if (!output.WriteByte(kGifExtensionApplication)) {
+    return output.status();
+  }
+  if (!output.WriteByte(kGifC2paExtensionInfoSize)) {
+    return output.status();
+  }
+  if (!output.Write(kGifC2paIdentifier)) {
+    return output.status();
+  }
+  if (!riegeli::WriteBigEndian<uint16_t>(kGifC2paAuthenticationCode, output)) {
+    return output.status();
+  }
+  if (!output.WriteByte(0)) {
+    return output.status();
+  }
+  return WriteDataAsBlocks(payload, output);
+}
+
+}  // namespace credentio
diff --git a/formats/gif/block.h b/formats/gif/block.h
new file mode 100644
index 0000000..1d6ce1b
--- /dev/null
+++ b/formats/gif/block.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_GIF_BLOCK_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_GIF_BLOCK_H_
+
+#include <cstdint>
+
+#include "absl/status/status.h"
+#include "absl/strings/string_view.h"
+#include "riegeli/bytes/writer.h"
+
+namespace credentio {
+
+// Given the size of a jumbf payload in bytes, it will return the total number
+// of bytes required to store the payload within a GIF block where the data is
+// segmented into blocks of at most 255 bytes.
+uint64_t CalculateTotalGifC2paBlockSize(uint64_t jumbf_payload_size);
+
+// Writes the given payload as a GIF block with n blocks of at most 255 bytes
+// each.
+absl::Status WriteGifC2paBlock(absl::string_view payload,
+                               riegeli::Writer& output);
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_GIF_BLOCK_H_
diff --git a/formats/gif/block_test.cc b/formats/gif/block_test.cc
new file mode 100644
index 0000000..c5e3df6
--- /dev/null
+++ b/formats/gif/block_test.cc
@@ -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.
+//
+
+#include "formats/gif/block.h"
+
+#include <cstdint>
+#include <string>
+#include <vector>
+
+#include "absl/status/status_matchers.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "riegeli/bytes/string_writer.h"
+
+namespace credentio {
+namespace {
+
+TEST(BlockTest, Write10BytePayload) {
+  std::string manifest_store = "0123456789";
+
+  std::vector<uint8_t> expected_output = {
+      0x21, 0xFF, 0x0B, 'C', '2', 'P', 'A', '_', 'G', 'I', 'F', 0x01, 0x00,
+      0x00, 0x0a, '0',  '1', '2', '3', '4', '5', '6', '7', '8', '9',  0x00,
+  };
+  std::string expected(expected_output.begin(), expected_output.end());
+
+  std::string output_s;
+  riegeli::StringWriter<> output(&output_s);
+  ABSL_ASSERT_OK(WriteGifC2paBlock(manifest_store, output));
+  ASSERT_TRUE(output.Close());
+
+  EXPECT_EQ(output_s, expected);
+  EXPECT_EQ(output_s.size(), 26);
+}
+
+TEST(BlockTest, Write300BytePayload) {
+  // Manifest store is 300 bytes long.
+  std::string manifest_store(300, 'a');
+
+  // Construct the expected output.
+  std::vector<uint8_t> expected_output = {
+      0x21, 0xFF, 0x0B, 'C',  '2',  'P',  'A',  '_',
+      'G',  'I',  'F',  0x01, 0x00, 0x00, 0xFF,
+  };
+  expected_output.reserve(expected_output.size() + 302);
+  for (uint32_t i = 0; i < 255; ++i) {
+    expected_output.push_back('a');
+  }
+  expected_output.push_back(45);
+  for (uint32_t i = 0; i < 45; ++i) {
+    expected_output.push_back('a');
+  }
+  expected_output.push_back(0x00);
+  std::string expected(expected_output.begin(), expected_output.end());
+
+  std::string output_s;
+  riegeli::StringWriter<> output(&output_s);
+  ABSL_ASSERT_OK(WriteGifC2paBlock(manifest_store, output));
+  ASSERT_TRUE(output.Close());
+
+  EXPECT_EQ(output_s, expected);
+  EXPECT_EQ(output_s.size(), 317);
+}
+
+TEST(BlockTest, CalculateSizeOneSubBlock) {
+  EXPECT_EQ(CalculateTotalGifC2paBlockSize(10), 26);
+}
+
+TEST(BlockTest, CalculateSizeMultipleSubBlocks) {
+  EXPECT_EQ(CalculateTotalGifC2paBlockSize(1000), 1019);
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/formats/gif/embedder.cc b/formats/gif/embedder.cc
new file mode 100644
index 0000000..fcabfa3
--- /dev/null
+++ b/formats/gif/embedder.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 "formats/gif/embedder.h"
+
+#include <cstdint>
+#include <optional>
+
+#include "absl/status/status.h"
+#include "absl/status/status_macros.h"
+#include "absl/strings/string_view.h"
+#include "formats/gif/block.h"
+#include "riegeli/bytes/copy_all.h"
+#include "riegeli/bytes/reader.h"
+#include "riegeli/bytes/writer.h"
+
+namespace credentio {
+
+absl::Status GifEmbedder::CopyAndEmbed(
+    riegeli::Reader& input, absl::string_view manifest_store,
+    int64_t insert_at_offset, std::optional<int64_t> existing_c2pa_length,
+    riegeli::Writer& output) const {
+  if (!input.Copy(insert_at_offset, output)) {
+    return input.StatusOrAnnotate(
+        absl::InternalError("failed to copy input up to insert point"));
+  }
+  // Embed the manifest store.
+  ABSL_RETURN_IF_ERROR(WriteGifC2paBlock(manifest_store, output));
+  input.Skip(existing_c2pa_length.value_or(0));
+
+  return riegeli::CopyAll(input, output);
+}
+
+}  // namespace credentio
diff --git a/formats/gif/embedder.h b/formats/gif/embedder.h
new file mode 100644
index 0000000..3d1b3ea
--- /dev/null
+++ b/formats/gif/embedder.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_GIF_EMBEDDER_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_GIF_EMBEDDER_H_
+
+#include <cstdint>
+#include <optional>
+
+#include "absl/status/status.h"
+#include "absl/strings/string_view.h"
+#include "riegeli/bytes/reader.h"
+#include "riegeli/bytes/writer.h"
+
+namespace credentio {
+// A utility class to embed a C2PA manifest store as an Application Extension
+// block into a GIF file.
+class GifEmbedder {
+ public:
+  // Copies the data from the input to the output and embeds the manifest store.
+  // NOTE: Input and output must be disjoint, i.e., in-place embedding is not
+  // supported.
+  absl::Status CopyAndEmbed(riegeli::Reader& input,
+                            absl::string_view manifest_store,
+                            int64_t insert_at_offset,
+                            std::optional<int64_t> existing_c2pa_length,
+                            riegeli::Writer& output) const;
+};
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_GIF_EMBEDDER_H_
diff --git a/formats/gif/embedder_test.cc b/formats/gif/embedder_test.cc
new file mode 100644
index 0000000..b8739f1
--- /dev/null
+++ b/formats/gif/embedder_test.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 "formats/gif/embedder.h"
+
+#include <cstdint>
+#include <optional>
+#include <string>
+#include <vector>
+
+#include "absl/status/status_matchers.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "riegeli/bytes/string_reader.h"
+#include "riegeli/bytes/string_writer.h"
+
+namespace credentio {
+namespace {
+
+TEST(EmbedderTest, Write10BytePayload) {
+  std::string image = "insert_@underscore";
+  riegeli::StringReader<> input(image);
+
+  std::string manifest_store = "0123456789";
+
+  std::vector<uint8_t> expected_output = {
+      'i', 'n', 's', 'e', 'r', 't', 0x21, 0xFF, 0x0B, 'C',  '2',
+      'P', 'A', '_', 'G', 'I', 'F', 0x01, 0x00, 0x00, 0x0a, '0',
+      '1', '2', '3', '4', '5', '6', '7',  '8',  '9',  0x00, '_',
+      '@', 'u', 'n', 'd', 'e', 'r', 's',  'c',  'o',  'r',  'e',
+  };
+  std::string expected(expected_output.begin(), expected_output.end());
+
+  std::string output_s;
+  riegeli::StringWriter<> output(&output_s);
+
+  GifEmbedder embedder;
+  ABSL_ASSERT_OK(
+      embedder.CopyAndEmbed(input, manifest_store, 6, std::nullopt, output));
+  ASSERT_TRUE(output.Close());
+
+  EXPECT_EQ(output_s, expected);
+}
+
+TEST(EmbedderTest, ReplaceExistingPayload) {
+  std::string image = "before_OLD_C2PA_after";
+  riegeli::StringReader<> input(image);
+
+  std::string manifest_store = "0123456789";
+
+  std::vector<uint8_t> expected_output = {
+      'b', 'e', 'f', 'o', 'r', 'e',  '_',  0x21, 0xFF, 0x0B, 'C', '2', 'P',
+      'A', '_', 'G', 'I', 'F', 0x01, 0x00, 0x00, 0x0a, '0',  '1', '2', '3',
+      '4', '5', '6', '7', '8', '9',  0x00, 'a',  'f',  't',  'e', 'r',
+  };
+  std::string expected(expected_output.begin(), expected_output.end());
+
+  std::string output_s;
+  riegeli::StringWriter<> output(&output_s);
+
+  GifEmbedder embedder;
+  ABSL_ASSERT_OK(embedder.CopyAndEmbed(input, manifest_store, 7, 9, output));
+  ASSERT_TRUE(output.Close());
+
+  EXPECT_EQ(output_s, expected);
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/formats/gif/hasher.cc b/formats/gif/hasher.cc
new file mode 100644
index 0000000..5cc2132
--- /dev/null
+++ b/formats/gif/hasher.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 "formats/gif/hasher.h"
+
+#include <memory>
+
+#include "absl/status/status.h"
+#include "absl/status/status_macros.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/str_cat.h"
+#include "bindings/input_hasher.h"
+#include "crypto/algorithms.h"
+#include "formats/gif/constants.h"
+#include "formats/gif/reader.h"
+#include "proto/data_hash_assertion.pb.h"
+#include "riegeli/bytes/reader.h"
+
+namespace credentio {
+
+absl::StatusOr<DigestGifResult> DigestGif(riegeli::Reader& input,
+                                          HashAlgorithm alg) {
+  DigestGifResult result = {.insertion_point = {.offset = 0}};
+
+  DataHashAssertion* assertion = &result.data_hash;
+  assertion->set_alg(absl::StrCat(alg));
+
+  DataRange* manifest_exclusion = assertion->add_exclusions();
+  manifest_exclusion->set_start(0);
+  manifest_exclusion->set_length(0);
+
+  bool found_logical_descriptor = false;
+  bool found_image_descriptor = false;
+
+  std::unique_ptr<InputHasher> hasher;
+  ABSL_ASSIGN_OR_RETURN(hasher, InputHasher::Create(alg));
+
+  ABSL_RETURN_IF_ERROR(IterateOverGifBlocks(
+      input,
+      [&input, &hasher, &manifest_exclusion, &found_image_descriptor,
+       &found_logical_descriptor,
+       &result](const GifBlock& block) -> absl::StatusOr<bool> {
+        if (block.type == kGifImageDescriptorLabel) {
+          found_image_descriptor = true;
+
+          // We reached the Image Descriptor, C2PA must be before this point.
+          if (result.insertion_point.offset == 0) {
+            return absl::InvalidArgumentError(
+                "No location found to insert C2PA block");
+          }
+        } else if (block.type == kGifLogicalScreenDescriptorLabel) {
+          found_logical_descriptor = true;
+          // Insert the C2PA block after the logical descriptor.
+          result.insertion_point.offset = block.offset + block.length;
+          manifest_exclusion->set_start(result.insertion_point.offset);
+        } else if (block.type == "C2PA") {
+          if (result.existing_c2pa_length.has_value()) {
+            // This means that the asset has multiple C2PA blocks.
+            return absl::InvalidArgumentError(
+                "Unexpected additional C2PA block found");
+          }
+          if (found_image_descriptor) {
+            return absl::InvalidArgumentError(
+                "Unexpected C2PA block found after Image Descriptor");
+          }
+          if (!found_logical_descriptor) {
+            return absl::InvalidArgumentError(
+                "Unexpected C2PA block found before Logical Descriptor");
+          }
+
+          // Replace the existing C2PA block instead
+          result.insertion_point.offset = block.offset;
+          result.existing_c2pa_length = block.length;
+
+          manifest_exclusion->set_start(block.offset);
+          // Don't update the hash for the existing C2PA block.
+          return true;
+        }
+
+        ABSL_RETURN_IF_ERROR(hasher->Update(input, block.offset, block.length));
+        return true;
+      }));
+
+  if (result.insertion_point.offset == 0 || !found_logical_descriptor) {
+    return absl::InvalidArgumentError(
+        "No Logical Screen Descriptor found in GIF");
+  }
+
+  // Set the hash
+  assertion->set_hash(hasher->Digest());
+  return result;
+}
+
+}  // namespace credentio
diff --git a/formats/gif/hasher.h b/formats/gif/hasher.h
new file mode 100644
index 0000000..dbb63b0
--- /dev/null
+++ b/formats/gif/hasher.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_HASHER_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_GIF_HASHER_H_
+
+#include <cstdint>
+#include <optional>
+
+#include "absl/status/statusor.h"
+#include "crypto/algorithms.h"
+#include "proto/data_hash_assertion.pb.h"
+#include "riegeli/bytes/reader.h"
+
+namespace credentio {
+
+// Insertion point for a C2PA manifest store into a GIF image. The offset is
+// relative to the start of the GIF image.
+struct GifInsertionPoint {
+  int64_t offset;
+
+  bool operator==(const GifInsertionPoint& other) const = default;
+};
+
+struct DigestGifResult {
+  DataHashAssertion data_hash;
+  GifInsertionPoint insertion_point;
+  std::optional<int64_t> existing_c2pa_length;
+};
+
+// Digests the given GIF image and returns a DigestGifResult that can be
+// used to generate a C2PA manifest store. The manifest store will be placed
+// before the first Graphic Control Extension or Image Descriptor.
+absl::StatusOr<DigestGifResult> DigestGif(riegeli::Reader& input,
+                                          HashAlgorithm alg);
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_GIF_HASHER_H_
diff --git a/formats/gif/hasher_test.cc b/formats/gif/hasher_test.cc
new file mode 100644
index 0000000..7654991
--- /dev/null
+++ b/formats/gif/hasher_test.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 "formats/gif/hasher.h"
+
+#include <optional>
+#include <string>
+
+#include "absl/status/status.h"
+#include "absl/status/status_matchers.h"
+#include "crypto/algorithms.h"
+#include "formats/gif/constants.h"
+#include "formats/gif/test_builder.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "riegeli/bytes/string_reader.h"
+#include "testing/proto_test_utils.h"
+
+namespace credentio {
+namespace {
+
+using ::absl_testing::StatusIs;
+using ::credentio_testing::EqualsProto;
+using ::credentio_testing::ParseTextProtoOrDie;
+using ::testing::HasSubstr;
+
+TEST(HasherTest, RecordsExistingC2paLength) {
+  TestGifBuilder builder;
+  builder.AddGifHeader();
+  builder.AddLogicalDescriptor(/*has_color_table=*/false);
+  builder.AddBlock(kGifExtensionIntroducer, kGifExtensionApplication,
+                   std::string(kGifC2paIdentifier) + "___", "manifest_store");
+  builder.AddBlock(kGifTrailerIntroducer);
+  std::string asset = builder.GetAsset();
+
+  riegeli::StringReader<> input(asset);
+
+  auto result_or = DigestGif(input, HashAlgorithm::kSha256);
+  ABSL_ASSERT_OK(result_or);
+  EXPECT_TRUE(result_or->existing_c2pa_length.has_value());
+  EXPECT_GT(*result_or->existing_c2pa_length, 0);
+}
+
+TEST(HasherTest, FailsWhenMultipleC2paBlocksExist) {
+  TestGifBuilder builder;
+  builder.AddGifHeader();
+  builder.AddLogicalDescriptor(/*has_color_table=*/false);
+  builder.AddBlock(kGifExtensionIntroducer, kGifExtensionApplication,
+                   std::string(kGifC2paIdentifier) + "___", "manifest_store_1");
+  builder.AddBlock(kGifExtensionIntroducer, kGifExtensionApplication,
+                   std::string(kGifC2paIdentifier) + "___", "manifest_store_2");
+  builder.AddBlock(kGifTrailerIntroducer);
+  std::string asset = builder.GetAsset();
+
+  riegeli::StringReader<> input(asset);
+
+  EXPECT_THAT(DigestGif(input, HashAlgorithm::kSha256),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("Unexpected additional C2PA block found")));
+}
+
+TEST(HasherTest, FailsWhenC2paBlockAfterImageDescriptor) {
+  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");
+  builder.AddBlock(kGifTrailerIntroducer);
+  std::string asset = builder.GetAsset();
+
+  riegeli::StringReader<> input(asset);
+
+  EXPECT_THAT(
+      DigestGif(input, HashAlgorithm::kSha256),
+      StatusIs(
+          absl::StatusCode::kInvalidArgument,
+          HasSubstr("Unexpected C2PA block found after Image Descriptor")));
+}
+
+TEST(HasherTest, FailsWhenNoLogicalDescriptor) {
+  TestGifBuilder builder;
+  builder.AddGifHeader();
+  std::string asset = builder.GetAsset();
+
+  riegeli::StringReader<> input(asset);
+
+  EXPECT_THAT(DigestGif(input, HashAlgorithm::kSha256),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("No Logical Screen Descriptor found in GIF")));
+}
+
+TEST(HasherTest, SuccessfullyHashesGif) {
+  TestGifBuilder builder;
+  builder.AddGifHeader();
+  builder.AddLogicalDescriptor(/*has_color_table=*/false);
+  builder.AddBlock(kGifExtensionIntroducer, kGifExtensionApplication,
+                   "other_app_info", "misc_data");
+  builder.AddBlock(kGifTrailerIntroducer);
+  std::string asset = builder.GetAsset();
+
+  riegeli::StringReader<> input(asset);
+
+  auto result_or = DigestGif(input, HashAlgorithm::kSha256);
+  ABSL_ASSERT_OK(result_or);
+  EXPECT_EQ(result_or->insertion_point, GifInsertionPoint{.offset = 13});
+  EXPECT_THAT(
+      result_or->data_hash,
+      EqualsProto(ParseTextProtoOrDie<DataHashAssertion>(R"pb(
+        alg: "sha256"
+        hash: "\334(\"\336\341 WHK\0219$\202p-\246\373\246t;\310Zd5x\345R\341a_\354s"
+        exclusions { start: 13 length: 0 }
+      )pb")));
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/formats/gif/reader.cc b/formats/gif/reader.cc
index 37889ff..59cd441 100644
--- a/formats/gif/reader.cc
+++ b/formats/gif/reader.cc
@@ -124,6 +124,16 @@
   return absl::OkStatus();
 }
 
+absl::Status PopulateImageData(riegeli::Reader& input, GifBlock& block) {
+  block.type = std::string(kGifImageDataLabel);
+  block.offset = input.pos();
+  if (!input.Skip(1)) {
+    return input.StatusOrAnnotate(
+        absl::DataLossError("Failed to skip LZW minimum code size"));
+  }
+  return PopulateBlock(input, block);
+}
+
 absl::Status PopulateApplicationExtension(riegeli::Reader& input,
                                           GifBlock& block) {
   int64_t next_size_offset = input.pos();
@@ -234,7 +244,21 @@
         break;
       }
       case kGifImageDescriptorIntroducer: {
+        // Process the image descriptor.
         ABSL_RETURN_IF_ERROR(PopulateImageDescriptor(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"));
+        }
+
+        // Process the image data directly following the image descriptor.
+        ABSL_RETURN_IF_ERROR(PopulateImageData(input, block));
         break;
       }
       case kGifTrailerIntroducer: {
@@ -243,8 +267,8 @@
         break;
       }
       default:
-        block.type = std::string(kGifImageDataLabel);
-        ABSL_RETURN_IF_ERROR(PopulateBlock(input, block));
+        return absl::UnimplementedError(
+            absl::StrFormat("Introducer not supported: %#04x", introducer));
     }
 
     ABSL_ASSIGN_OR_RETURN(should_continue, processor(block));
diff --git a/formats/gif/reader_test.cc b/formats/gif/reader_test.cc
index e4ce30d..2c4d7a3 100644
--- a/formats/gif/reader_test.cc
+++ b/formats/gif/reader_test.cc
@@ -199,23 +199,6 @@
                                    }));
 }
 
-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();
@@ -226,9 +209,9 @@
   riegeli::StringReader<> input(contents);
 
   EXPECT_THAT(GetLastBlock(input), IsOkAndHolds(GifBlock{
-                                       .offset = 13,
-                                       .length = 10,
-                                       .type = "2C",
+                                       .offset = 23,
+                                       .length = 13,
+                                       .type = "TBID",
                                    }));
 }
 
@@ -242,9 +225,9 @@
   riegeli::StringReader<> input(contents);
 
   EXPECT_THAT(GetLastBlock(input), IsOkAndHolds(GifBlock{
-                                       .offset = 13,
-                                       .length = 22,
-                                       .type = "2C",
+                                       .offset = 35,
+                                       .length = 13,
+                                       .type = "TBID",
                                    }));
 }
 
diff --git a/formats/gif/test_builder.cc b/formats/gif/test_builder.cc
index 351e147..79369ea 100644
--- a/formats/gif/test_builder.cc
+++ b/formats/gif/test_builder.cc
@@ -76,6 +76,9 @@
     };
     gif_data_ += std::string(color_table.begin(), color_table.end());
   }
+
+  gif_data_ += 2;  // LZW minimum code size
+  gif_data_ += EncodeDataToBlocks("image_data", /*add_termination_block=*/true);
 }
 
 void TestGifBuilder::AddBlock(uint8_t introducer, std::optional<uint8_t> type,
diff --git a/formats/gif/writer.cc b/formats/gif/writer.cc
new file mode 100644
index 0000000..10d811e
--- /dev/null
+++ b/formats/gif/writer.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 "formats/gif/writer.h"
+
+#include <cstdint>
+#include <optional>
+#include <utility>
+
+#include "absl/base/nullability.h"
+#include "absl/functional/any_invocable.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/string_view.h"
+#include "absl/synchronization/notification.h"
+#include "async/async_context.h"
+#include "crypto/algorithms.h"
+#include "formats/gif/block.h"
+#include "formats/gif/embedder.h"
+#include "formats/gif/hasher.h"
+#include "generator/generator.h"
+#include "proto/data_hash_assertion.pb.h"
+#include "proto/generator.pb.h"
+#include "riegeli/bytes/reader.h"
+#include "riegeli/bytes/writer.h"
+
+namespace credentio {
+namespace {
+
+void AdjustDataHashExclusions(DataHashAssertion* absl_nonnull assertion,
+                              int64_t c2pa_offset, int64_t c2pa_size) {
+  for (auto& exclusion : *assertion->mutable_exclusions()) {
+    if (exclusion.start() < c2pa_offset) {
+      // Before the C2PA segment, so no adjustment needed.
+      continue;
+    }
+    if (exclusion.start() == c2pa_offset) {
+      // This is the C2PA segment, just need to fill in the length.
+      exclusion.set_length(c2pa_size);
+      continue;
+    }
+    // This is after the C2PA segment, so we need to add the manifest size to
+    // the offset.
+    exclusion.set_start(exclusion.start() + c2pa_size);
+  }
+}
+
+absl::Status CopyAndEmbed(riegeli::Reader& input,
+                          absl::string_view manifest_store,
+                          int64_t insert_at_offset,
+                          std::optional<int64_t> existing_c2pa_length,
+                          riegeli::Writer& destination) {
+  if (!input.Seek(0)) {
+    return input.StatusOrAnnotate(
+        absl::DataLossError("failed to seek to start of input"));
+  }
+  GifEmbedder embedder;
+  ABSL_RETURN_IF_ERROR(
+      embedder.CopyAndEmbed(input, manifest_store, insert_at_offset,
+                            existing_c2pa_length, destination));
+  return absl::OkStatus();
+}
+
+}  // namespace
+
+GifWriter::GifWriter(const ManifestStoreGenerator* absl_nonnull generator)
+    : generator_(*ABSL_DIE_IF_NULL(generator)) {}
+
+absl::Status GifWriter::Write(
+    GenerateManifestStoreRequest request, riegeli::Reader* absl_nonnull source,
+    riegeli::Writer* absl_nonnull destination,
+    OutputMetadata* absl_nullable output_metadata) const {
+  absl::Notification done;
+  absl::Status status;
+  Write(std::move(request), source, destination, output_metadata,
+        AsyncContext(), [&](absl::Status s) {
+          status = std::move(s);
+          done.Notify();
+        });
+  done.WaitForNotification();
+  return status;
+}
+
+void GifWriter::Write(GenerateManifestStoreRequest request,
+                      riegeli::Reader* absl_nonnull source,
+                      riegeli::Writer* absl_nonnull destination,
+                      OutputMetadata* absl_nullable output_metadata,
+                      AsyncContext context,
+                      absl::AnyInvocable<void(absl::Status) &&> cb) const {
+  AsyncResolver<void> resolver(context, std::move(cb));
+  if (request.has_hard_binding()) {
+    resolver.Return(absl::InvalidArgumentError(
+        "request must not include `hard_binding` (binding is computed by "
+        "GifWriter)"));
+    return;
+  }
+
+  auto digest_result = DigestGif(*source, HashAlgorithm::kSha256);
+  if (!digest_result.ok()) {
+    resolver.Return(digest_result.status());
+    return;
+  }
+
+  if (digest_result->existing_c2pa_length.has_value() &&
+      (!request.has_actions() || !request.actions().has_opened())) {
+    resolver.Return(absl::AlreadyExistsError(
+        "C2PA chunk already exists, but no `opened` action was provided"));
+    return;
+  }
+
+  request.mutable_hard_binding()->mutable_data_hash()->Swap(
+      &digest_result->data_hash);
+  auto insertion_point = std::move(digest_result->insertion_point);
+  auto existing_c2pa_length = digest_result->existing_c2pa_length;
+
+  generator_.GenerateManifestStoreTemplate(
+      request, context,
+      [generator = &generator_, source, request,
+       insertion_point = std::move(insertion_point), existing_c2pa_length,
+       destination, output_metadata, context, resolver = std::move(resolver)](
+          absl::StatusOr<ManifestStoreTemplate> template_response) mutable {
+        if (!template_response.ok()) {
+          resolver.Return(template_response.status());
+          return;
+        }
+
+        uint64_t embedded_size = CalculateTotalGifC2paBlockSize(
+            template_response->manifest_store_size());
+
+        FinalizeManifestStoreRequest finalize_request;
+        finalize_request.mutable_manifest_store_template()->Swap(
+            &*template_response);
+        finalize_request.mutable_hard_binding()->MergeFrom(
+            request.hard_binding());
+
+        AdjustDataHashExclusions(
+            finalize_request.mutable_hard_binding()->mutable_data_hash(),
+            insertion_point.offset, embedded_size);
+
+        generator->FinalizeManifestStore(
+            finalize_request, context,
+            [source, insertion_point, existing_c2pa_length, destination,
+             output_metadata, resolver = std::move(resolver)](
+                absl::StatusOr<FinalizeManifestStoreResponse>
+                    finalize_response) mutable {
+              if (!finalize_response.ok()) {
+                resolver.Return(finalize_response.status());
+                return;
+              }
+
+              auto copy_status = CopyAndEmbed(
+                  *source, finalize_response->manifest_store(),
+                  insertion_point.offset, existing_c2pa_length, *destination);
+              if (!copy_status.ok()) {
+                resolver.Return(std::move(copy_status));
+                return;
+              }
+              if (output_metadata != nullptr) {
+                output_metadata->timestamp_included =
+                    finalize_response->is_timestamped();
+              }
+              resolver.Return(absl::OkStatus());
+            });
+      });
+}
+
+}  // namespace credentio
diff --git a/formats/gif/writer.h b/formats/gif/writer.h
new file mode 100644
index 0000000..5e3fad9
--- /dev/null
+++ b/formats/gif/writer.h
@@ -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.
+//
+
+#ifndef THIRD_PARTY_CREDENTIO_FORMATS_GIF_WRITER_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_GIF_WRITER_H_
+
+#include "absl/base/nullability.h"
+#include "absl/functional/any_invocable.h"
+#include "absl/status/status.h"
+#include "async/async_context.h"
+#include "generator/generator.h"
+#include "proto/generator.pb.h"
+#include "riegeli/bytes/reader.h"
+#include "riegeli/bytes/writer.h"
+
+namespace credentio {
+
+// Writes C2PA manifest stores into GIF images.
+class GifWriter {
+ public:
+  // Information about the C2PA manifest store embedded in the output asset.
+  struct OutputMetadata {
+    // Whether the C2PA active manifest embedded in the output asset has a
+    // timestamp.
+    bool timestamp_included = false;
+  };
+
+  // Creates a GIF writer.
+  //
+  // The generator must outlive the writer.
+  explicit GifWriter(const ManifestStoreGenerator* absl_nonnull generator);
+
+  // Generates a C2PA manifest store and embeds it into a copy of the given
+  // asset. The riegeli::Reader and riegeli::Writer cannot be pointed to the
+  // same underlying data. The request must not include `hard_binding`.
+  // Fails if C2PA metadata already exists and no `opened` action is present.
+  void Write(GenerateManifestStoreRequest request,
+             riegeli::Reader* absl_nonnull source,
+             riegeli::Writer* absl_nonnull destination,
+             OutputMetadata* absl_nullable output_metadata,
+             AsyncContext context,
+             absl::AnyInvocable<void(absl::Status) &&> cb) const;
+  // Blocking version.
+  absl::Status Write(
+      GenerateManifestStoreRequest request,
+      riegeli::Reader* absl_nonnull source,
+      riegeli::Writer* absl_nonnull destination,
+      OutputMetadata* absl_nullable output_metadata = nullptr) const;
+
+ private:
+  const ManifestStoreGenerator& generator_;
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_GIF_WRITER_H_
diff --git a/formats/hasher_data.cc b/formats/hasher_data.cc
new file mode 100644
index 0000000..b38d193
--- /dev/null
+++ b/formats/hasher_data.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 "formats/hasher_data.h"
+
+#include <cstdint>
+#include <limits>
+#include <memory>
+#include <optional>
+
+#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 "bindings/input_hasher.h"
+#include "crypto/algorithms.h"
+#include "proto/data_hash_assertion.pb.h"
+#include "riegeli/bytes/reader.h"
+
+namespace credentio {
+
+namespace {
+
+absl::StatusOr<uint64_t> GetSize(riegeli::Reader& input) {
+  if (!input.SupportsSize()) {
+    return absl::InvalidArgumentError("input does not support size");
+  }
+  std::optional<uint64_t> input_size = input.Size();
+  if (!input_size.has_value()) {
+    return absl::InvalidArgumentError("input does not have a size");
+  }
+  return *input_size;
+}
+
+absl::StatusOr<DataHashAssertion> DigestDataHashInternal(
+    riegeli::Reader& input, HashAlgorithm alg, uint64_t manifest_store_offset,
+    uint64_t manifest_store_length) {
+  ABSL_ASSIGN_OR_RETURN(uint64_t input_size, GetSize(input));
+
+  if (manifest_store_offset >
+          static_cast<uint64_t>(std::numeric_limits<int64_t>::max()) ||
+      manifest_store_length >
+          static_cast<uint64_t>(std::numeric_limits<int64_t>::max())) {
+    return absl::InvalidArgumentError(absl::Substitute(
+        "Manifest store offset ($0) or length ($1) exceeds int64 max",
+        manifest_store_offset, manifest_store_length));
+  }
+
+  uint64_t manifest_store_end;
+  if (__builtin_add_overflow(manifest_store_offset, manifest_store_length,
+                             &manifest_store_end)) {
+    return absl::InvalidArgumentError(absl::Substitute(
+        "Manifest store end calculation overflowed: offset $0, length $1",
+        manifest_store_offset, manifest_store_length));
+  }
+
+  if (manifest_store_end > input_size) {
+    return absl::InvalidArgumentError(absl::Substitute(
+        "Manifest store range [$0, $1) exceeds input size $2",
+        manifest_store_offset, manifest_store_end, input_size));
+  }
+
+  ABSL_ASSIGN_OR_RETURN(std::unique_ptr<InputHasher> hasher,
+                        InputHasher::Create(alg));
+  ABSL_RETURN_IF_ERROR(hasher->Update(input, 0, manifest_store_offset));
+  ABSL_RETURN_IF_ERROR(hasher->Update(input, manifest_store_end,
+                                      input_size - manifest_store_end));
+  DataHashAssertion assertion;
+  assertion.set_alg(absl::StrCat(alg));
+  assertion.set_hash(hasher->Digest());
+  DataRange* exclusion = assertion.add_exclusions();
+  exclusion->set_start(static_cast<int64_t>(manifest_store_offset));
+  exclusion->set_length(static_cast<int64_t>(manifest_store_length));
+  return assertion;
+}
+
+}  // namespace
+
+absl::StatusOr<DataHashAssertion> DataHashTemplate(HashAlgorithm alg) {
+  DataHashAssertion assertion;
+  assertion.set_alg(absl::StrCat(alg));
+  ABSL_ASSIGN_OR_RETURN(std::unique_ptr<InputHasher> hasher,
+                        InputHasher::Create(alg));
+  assertion.set_hash(hasher->Digest());
+  DataRange* exclusion = assertion.add_exclusions();
+  exclusion->set_start(0);
+  exclusion->set_length(0);
+  return assertion;
+}
+
+absl::StatusOr<DataHashAssertion> DigestDataHash(
+    riegeli::Reader& input, HashAlgorithm alg, uint64_t manifest_store_offset,
+    uint64_t manifest_store_length) {
+  return DigestDataHashInternal(input, alg, manifest_store_offset,
+                                manifest_store_length);
+}
+
+}  // namespace credentio
diff --git a/formats/hasher_data.h b/formats/hasher_data.h
new file mode 100644
index 0000000..e0ce246
--- /dev/null
+++ b/formats/hasher_data.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_HASHER_DATA_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_HASHER_DATA_H_
+
+#include <cstdint>
+
+#include "absl/status/statusor.h"
+#include "crypto/algorithms.h"
+#include "proto/data_hash_assertion.pb.h"
+#include "riegeli/bytes/reader.h"
+
+namespace credentio {
+// Produces a "template" DataHashAssertion.
+// This assertion will have the same contents as an assertion produced by
+// DigestDataHash, except for the specific hash value (which will have the same
+// length) and numeric offset/length values.
+absl::StatusOr<DataHashAssertion> DataHashTemplate(HashAlgorithm alg);
+
+// Same as above, but for a Riegeli reader.
+absl::StatusOr<DataHashAssertion> DigestDataHash(
+    riegeli::Reader& input, HashAlgorithm alg, uint64_t manifest_store_offset,
+    uint64_t manifest_store_length);
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_HASHER_DATA_H_
diff --git a/formats/id3/BUILD b/formats/id3/BUILD
index 90a715f..48f5359 100644
--- a/formats/id3/BUILD
+++ b/formats/id3/BUILD
@@ -156,3 +156,86 @@
         "@abseil-cpp//absl/status:statusor",
     ],
 )
+
+cc_library(
+    name = "embedder",
+    srcs = ["embedder.cc"],
+    hdrs = ["embedder.h"],
+    deps = [
+        ":constants",
+        ":id3",
+        ":reader",
+        "@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",
+        "@riegeli//riegeli/bytes:reader",
+        "@riegeli//riegeli/bytes:writer",
+    ],
+)
+
+cc_test(
+    name = "embedder_test",
+    srcs = ["embedder_test.cc"],
+    deps = [
+        ":embedder",
+        ":extractor",
+        "@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_library(
+    name = "writer",
+    srcs = ["writer.cc"],
+    hdrs = ["writer.h"],
+    deps = [
+        ":embedder",
+        "//crypto:algorithms",
+        "//formats:hasher_data",
+        "//generator",
+        "//proto:generator_cc_proto",
+        "@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:cord",
+        "@abseil-cpp//absl/strings:string_view",
+        "@riegeli//riegeli/bytes:cord_reader",
+        "@riegeli//riegeli/bytes:cord_writer",
+        "@riegeli//riegeli/bytes:reader",
+        "@riegeli//riegeli/bytes:writer",
+    ],
+)
+
+cc_test(
+    name = "writer_test",
+    srcs = ["writer_test.cc"],
+    deps = [
+        ":extractor",
+        ":writer",
+        "//async:async_context",
+        "//formats:byte_range",
+        "//generator",
+        "//proto:generator_cc_proto",
+        "//proto:manifest_cc_proto",
+        "//proto:validation_status_cc_proto",
+        "//testing:nonprod_generator",
+        "//testing:nonprod_validator",
+        "//validator:asset_validator",
+        "@abseil-cpp//absl/functional:any_invocable",
+        "@abseil-cpp//absl/log:absl_check",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@googletest//:gtest_main",
+        "@riegeli//riegeli/bytes:string_reader",
+        "@riegeli//riegeli/bytes:string_writer",
+    ],
+)
diff --git a/formats/id3/embedder.cc b/formats/id3/embedder.cc
new file mode 100644
index 0000000..3f6f450
--- /dev/null
+++ b/formats/id3/embedder.cc
@@ -0,0 +1,193 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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/embedder.h"
+
+#include <cstdint>
+#include <utility>
+
+#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 "formats/id3/constants.h"
+#include "formats/id3/id3.h"
+#include "formats/id3/reader.h"
+#include "riegeli/bytes/reader.h"
+#include "riegeli/bytes/writer.h"
+
+namespace credentio {
+namespace {
+
+// The size of the ID3 header excluded from the ID3 tag size calculation.
+constexpr uint32_t kExcludedId3TagHeaderSize = 10;
+// The size of the ID3 extended header excluded from the ID3 extended header
+// size calculation.
+constexpr uint32_t kExcludedId3ExtendedHeaderSize = 4;
+
+constexpr absl::string_view kGeobFrameFileName = "c2pa";
+constexpr absl::string_view kGeobFrameContentDescription =
+    "c2pa manifest store";
+
+// Encodes the C2PA manifest store as an ID3 GEOB frame, returns the encoded
+// frame as an `absl::Cord`. ID3v2.4 frame sizes are encoded as synchsafe
+// integers, whereas ID3v2.3 frame sizes are 32-bit big-endian integers.
+absl::StatusOr<absl::Cord> EncodeManifestStoreAsId3Frame(
+    absl::string_view manifest_store, uint8_t major_version) {
+  absl::Cord geob_frame_data;
+  // Encode manifest store as the GEOB frame data.
+  geob_frame_data.Append(
+      absl::string_view("\x00", 1));           // Text encoding [ISO-8859-1]
+  geob_frame_data.Append(kGeobFrameMimeType);  // MIME type
+  geob_frame_data.Append(absl::string_view("\x00", 1));  // NULL terminator
+  geob_frame_data.Append(kGeobFrameFileName);            // File name
+  geob_frame_data.Append(absl::string_view("\x00", 1));  // NULL terminator
+  geob_frame_data.Append(kGeobFrameContentDescription);  // Content description
+  geob_frame_data.Append(absl::string_view("\x00", 1));  // NULL terminator
+  geob_frame_data.Append(manifest_store);  // Manifest store as encapsulated
+                                           // object
+
+  absl::Cord output;
+  // Write the GEOB frame header.
+  output.Append(absl::string_view("GEOB", 4));
+  uint32_t frame_size = geob_frame_data.size();
+  char size_bytes[4];
+  if (major_version == 4) {
+    if (frame_size > 0x0FFFFFFF) {
+      return absl::InvalidArgumentError(
+          "Frame size exceeds maximum allowable ID3v2.4 frame size");
+    }
+    // Encode the frame size as a synchsafe integer.
+    size_bytes[0] = (frame_size >> 21) & 0x7F;
+    size_bytes[1] = (frame_size >> 14) & 0x7F;
+    size_bytes[2] = (frame_size >> 7) & 0x7F;
+    size_bytes[3] = frame_size & 0x7F;
+  } else {  // ID3v2.3
+    size_bytes[0] = (frame_size >> 24) & 0xFF;
+    size_bytes[1] = (frame_size >> 16) & 0xFF;
+    size_bytes[2] = (frame_size >> 8) & 0xFF;
+    size_bytes[3] = frame_size & 0xFF;
+  }
+  output.Append(absl::string_view(size_bytes, 4));
+  output.Append(absl::string_view("\x00\x00", 2));  // Flags
+  // Append the GEOB frame data.
+  output.Append(std::move(geob_frame_data));
+
+  return output;
+}
+
+}  // namespace
+
+absl::StatusOr<int64_t> Id3Embedder::CopyAndEmbed(
+    riegeli::Reader& input, absl::string_view manifest_store,
+    riegeli::Writer& output) const {
+  if (!input.SupportsSize() || !input.Size().has_value()) {
+    return absl::InvalidArgumentError("input does not support size");
+  }
+  uint64_t asset_size = input.Size().value();
+
+  int64_t beginning_offset = input.pos();
+  auto status = IterateOverId3Frames(
+      input, [&](const Id3Frame& frame) -> absl::StatusOr<bool> {
+        if (frame.id == "GEOB") {
+          ABSL_ASSIGN_OR_RETURN(auto geob_frame,
+                                GeobFrame::Parse(input, frame.data_offset(),
+                                                 frame.data_length()));
+          if (geob_frame.mime_type == kGeobFrameMimeType ||
+              geob_frame.mime_type == kGeobFrameMimeTypeDeprecated) {
+            return absl::AlreadyExistsError(
+                "C2PA manifest store already exists.");
+          }
+        }
+        return true;
+      });
+
+  if (absl::IsNotFound(status.status())) {
+    // No ID3v2 tag found in the input audio file, so we prepend a simplest ID3
+    // tag with only one GEOB frame for the manifest store.
+    ABSL_ASSIGN_OR_RETURN(
+        absl::Cord manifest_store_frame_encoded,
+        EncodeManifestStoreAsId3Frame(manifest_store, /*major_version=*/3));
+    Id3Header id3_header = {
+        .use_unsynchronisation = false,
+        .has_extended_header = false,
+        .is_experimental = false,
+        .tag_size = static_cast<uint32_t>(manifest_store_frame_encoded.size()),
+    };
+
+    if (!output.Write(id3_header.ToCord())) {
+      return output.StatusOrAnnotate(
+          absl::InternalError("failed to write ID3 header"));
+    }
+    if (!output.Write(manifest_store_frame_encoded)) {
+      return output.StatusOrAnnotate(
+          absl::InternalError("failed to write manifest store frame"));
+    }
+    int64_t manifest_store_offset = output.pos() - manifest_store.size();
+
+    if (!input.Seek(beginning_offset)) {
+      return input.StatusOrAnnotate(
+          absl::InternalError("failed to seek to start of input"));
+    }
+    if (!input.Copy(asset_size - beginning_offset, output)) {
+      return input.StatusOrAnnotate(
+          absl::InternalError("failed to copy input"));
+    }
+    return manifest_store_offset;
+  }
+  if (!status.ok()) {
+    // Propagate other errors during reader creation.
+    return status.status();
+  }
+
+  Id3Header id3_header = status.value();
+  uint32_t id3_header_size =
+      kExcludedId3TagHeaderSize +
+      (id3_header.has_extended_header
+           ? kExcludedId3ExtendedHeaderSize + id3_header.extended_header_size
+           : 0);
+  ABSL_ASSIGN_OR_RETURN(
+      absl::Cord manifest_store_frame,
+      EncodeManifestStoreAsId3Frame(manifest_store, id3_header.major_version));
+  // Update the tag size to include the new manifest store frame.
+  if (static_cast<uint64_t>(id3_header.tag_size) + manifest_store_frame.size() >
+      0x0FFFFFFF) {
+    return absl::InvalidArgumentError(
+        "Tag size exceeds maximum allowable ID3 tag size");
+  }
+  id3_header.tag_size += static_cast<uint32_t>(manifest_store_frame.size());
+
+  if (!output.Write(id3_header.ToCord())) {
+    return output.StatusOrAnnotate(
+        absl::InternalError("failed to write ID3 header"));
+  }
+  if (!output.Write(manifest_store_frame)) {
+    return output.StatusOrAnnotate(
+        absl::InternalError("failed to write manifest store frame"));
+  }
+
+  int64_t manifest_store_offset = output.pos() - manifest_store.size();
+  // Copy the rest of the input audio file.
+  if (!input.Seek(beginning_offset + id3_header_size)) {
+    return input.StatusOrAnnotate(
+        absl::InternalError("failed to seek to start of input"));
+  }
+  if (!input.Copy(asset_size - id3_header_size, output)) {
+    return input.StatusOrAnnotate(absl::InternalError("failed to copy input"));
+  }
+  return manifest_store_offset;
+}
+}  // namespace credentio
diff --git a/formats/id3/embedder.h b/formats/id3/embedder.h
new file mode 100644
index 0000000..5b41d65
--- /dev/null
+++ b/formats/id3/embedder.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_ID3_EMBEDDER_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_ID3_EMBEDDER_H_
+
+#include <cstdint>
+
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "riegeli/bytes/reader.h"
+#include "riegeli/bytes/writer.h"
+
+namespace credentio {
+
+// A utility class for embedding a C2PA manifest store as a ID3v2 `GEOB` frame
+// in a non-C2PA ID3-compatible audio file.
+class Id3Embedder {
+ public:
+  explicit Id3Embedder() = default;
+
+  absl::StatusOr<int64_t> CopyAndEmbed(riegeli::Reader& input,
+                                       absl::string_view manifest_store,
+                                       riegeli::Writer& output) const;
+};
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_ID3_EMBEDDER_H_
diff --git a/formats/id3/embedder_test.cc b/formats/id3/embedder_test.cc
new file mode 100644
index 0000000..f445017
--- /dev/null
+++ b/formats/id3/embedder_test.cc
@@ -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.
+//
+
+#include "formats/id3/embedder.h"
+
+#include <string>
+
+#include "absl/status/status.h"
+#include "absl/status/status_matchers.h"
+#include "absl/strings/string_view.h"
+#include "formats/id3/extractor.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::IsOkAndHolds;
+using ::absl_testing::StatusIs;
+using ::testing::Eq;
+using ::testing::HasSubstr;
+
+// Simple MPEG-2 Layer 3 frame header followed by 4 bytes of dummy data.
+constexpr char kMp3FileWithNoId3Tag[] = {'\xFF', '\xF3', '\x40', '\xC0',
+                                         0x01,   0x02,   0x03,   0x04};
+
+constexpr char kMp3FileWithNoC2paFrame[] = {
+    'I', 'D', '3', '\x03', '\x00', '\x00', '\x00', '\x00', '\x00',
+    '\x0F',  // Tag size = 15
+    // A 15-byte COMM frame
+    'C', 'O', 'M', 'M', '\x00', '\x00', '\x00', '\x05', '\x00', '\x00', '\x00',
+    '\x00', '\x00', '\x00', '\x00'};
+
+constexpr char kMp3FileWithId3v24NoC2paFrame[] = {
+    'I', 'D', '3', '\x04', '\x00', '\x00', '\x00', '\x00', '\x00',
+    '\x0F',  // Tag size = 15
+    // A 15-byte COMM frame
+    'C', 'O', 'M', 'M', '\x00', '\x00', '\x00', '\x05', '\x00', '\x00', '\x00',
+    '\x00', '\x00', '\x00', '\x00'};
+
+constexpr char kMp3FileWithNoC2paFrameWithExtendedHeader[] = {
+    'I', 'D', '3',           // ID3 file identifier
+    0x03, 0x00,              // Version 2.3
+    0x40,                    // Flags (bit 6 indicates extended header)
+    0x00, 0x00, 0x00, 0x19,  // Tag size = 25
+    // Extended header
+    0x00, 0x00, 0x00, 0x06,  // Extended header size = 6
+    0x00, 0x00,              // Extended flags
+    0x00, 0x00, 0x00, 0x00,  // Size of padding
+    // A 15-byte COMM frame
+    'C', 'O', 'M', 'M', 0x00, 0x00, 0x00, 0x05, 0x00, 0x00, 0x00, 0x00, 0x00,
+    0x00, 0x00};
+
+constexpr char kMp3FileWithC2paManifestStore[] = {
+    '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]
+    '\xFF', '\xFE', 'f', 0x00, 'o', 0x00, 'o', 0x00, 0x00, 0x00,
+    // Content description [UTF-16LE]
+    '\xFF', '\xFE', 'b', 0x00, 'a', 0x00, 'r', 0x00, 0x00, 0x00,
+    // Encapsulated object
+    'm', 'a', 'n', 'i', 'f', 'e', 's', 't', ' ', 's', 't', 'o', 'r', 'e'};
+
+TEST(Id3EmbedderTest, CopyAndEmbedNoId3Tag) {
+  std::string mp3_file_contents =
+      std::string(kMp3FileWithNoId3Tag, sizeof(kMp3FileWithNoId3Tag));
+  absl::string_view kManifestStore = "C2PA manifest store bytes";
+  riegeli::StringReader<> input(mp3_file_contents);
+  std::string output_s;
+  riegeli::StringWriter<> output(&output_s);
+
+  Id3Embedder embedder;
+  auto manifest_store_offset =
+      embedder.CopyAndEmbed(input, kManifestStore, output);
+  ABSL_ASSERT_OK(manifest_store_offset.status());
+  ASSERT_TRUE(output.Close());
+
+  EXPECT_THAT(output_s.substr(*manifest_store_offset, kManifestStore.size()),
+              Eq(kManifestStore));
+
+  Id3Extractor extractor;
+  riegeli::StringReader<> output_reader(output_s);
+  EXPECT_THAT(extractor.ExtractManifestStore(output_reader),
+              IsOkAndHolds(kManifestStore));
+}
+
+TEST(Id3EmbedderTest, CopyAndEmbedNoC2paFrame) {
+  std::string mp3_file_contents =
+      std::string(kMp3FileWithNoC2paFrame, sizeof(kMp3FileWithNoC2paFrame));
+  absl::string_view kManifestStore = "C2PA manifest store bytes";
+  riegeli::StringReader input(mp3_file_contents);
+  std::string output_s;
+  riegeli::StringWriter<> output(&output_s);
+
+  Id3Embedder embedder;
+  auto manifest_store_offset =
+      embedder.CopyAndEmbed(input, kManifestStore, output);
+  ABSL_ASSERT_OK(manifest_store_offset.status());
+  ASSERT_TRUE(output.Close());
+
+  EXPECT_THAT(output_s.substr(*manifest_store_offset, kManifestStore.size()),
+              Eq(kManifestStore));
+
+  Id3Extractor extractor;
+  riegeli::StringReader<> output_reader(output_s);
+  EXPECT_THAT(extractor.ExtractManifestStore(output_reader),
+              IsOkAndHolds(kManifestStore));
+}
+
+TEST(Id3EmbedderTest, CopyAndEmbedNoC2paFrameWithExtendedHeader) {
+  std::string mp3_file_contents =
+      std::string(kMp3FileWithNoC2paFrameWithExtendedHeader,
+                  sizeof(kMp3FileWithNoC2paFrameWithExtendedHeader));
+  absl::string_view kManifestStore = "C2PA manifest store bytes";
+  riegeli::StringReader input(mp3_file_contents);
+  std::string output_s;
+  riegeli::StringWriter<> output(&output_s);
+
+  Id3Embedder embedder;
+  auto manifest_store_offset =
+      embedder.CopyAndEmbed(input, kManifestStore, output);
+  ABSL_ASSERT_OK(manifest_store_offset.status());
+  ASSERT_TRUE(output.Close());
+
+  EXPECT_THAT(output_s.substr(*manifest_store_offset, kManifestStore.size()),
+              Eq(kManifestStore));
+
+  Id3Extractor extractor;
+  riegeli::StringReader<> output_reader(output_s);
+  EXPECT_THAT(extractor.ExtractManifestStore(output_reader),
+              IsOkAndHolds(kManifestStore));
+}
+
+TEST(Id3EmbedderTest, CopyAndEmbedId3v24SynchsafeFrameSize) {
+  std::string mp3_file_contents = std::string(
+      kMp3FileWithId3v24NoC2paFrame, sizeof(kMp3FileWithId3v24NoC2paFrame));
+  std::string manifest_store(300, 'a');
+  riegeli::StringReader input(mp3_file_contents);
+  std::string output_s;
+  riegeli::StringWriter<> output(&output_s);
+
+  Id3Embedder embedder;
+  auto manifest_store_offset =
+      embedder.CopyAndEmbed(input, manifest_store, output);
+  ABSL_ASSERT_OK(manifest_store_offset.status());
+  ASSERT_TRUE(output.Close());
+
+  EXPECT_THAT(output_s.substr(*manifest_store_offset, manifest_store.size()),
+              Eq(manifest_store));
+
+  Id3Extractor extractor;
+  riegeli::StringReader<> output_reader(output_s);
+  EXPECT_THAT(extractor.ExtractManifestStore(output_reader),
+              IsOkAndHolds(manifest_store));
+}
+
+TEST(Id3EmbedderTest, CopyAndEmbedC2paAlreadyExists) {
+  std::string mp3_file_contents = std::string(
+      kMp3FileWithC2paManifestStore, sizeof(kMp3FileWithC2paManifestStore));
+  absl::string_view kManifestStore = "C2PA manifest store bytes";
+  riegeli::StringReader input(mp3_file_contents);
+  std::string output_s;
+  riegeli::StringWriter<> output(&output_s);
+
+  Id3Embedder embedder;
+  EXPECT_THAT(embedder.CopyAndEmbed(input, kManifestStore, output),
+              StatusIs(absl::StatusCode::kAlreadyExists,
+                       HasSubstr("C2PA manifest store already exists")));
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/formats/id3/extractor_test.cc b/formats/id3/extractor_test.cc
index 3331874..b8e302d 100644
--- a/formats/id3/extractor_test.cc
+++ b/formats/id3/extractor_test.cc
@@ -28,7 +28,7 @@
 namespace credentio {
 namespace {
 
-using ::absl_testing::IsOk;
+using ::absl_testing::IsOkAndHolds;
 using ::absl_testing::StatusIs;
 using ::testing::AllOf;
 using ::testing::Eq;
@@ -36,20 +36,6 @@
 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
@@ -132,7 +118,7 @@
   riegeli::StringReader<> input(mp3_file_contents);
   Id3Extractor extractor;
   auto result = extractor.ExtractManifestStore(input);
-  ASSERT_THAT(result, IsOk());
+  ABSL_ASSERT_OK(result);
   EXPECT_THAT(*result, HasSubstr("manifest store"));
 }
 
@@ -143,7 +129,7 @@
   riegeli::StringReader<> input(mp3_file_contents);
   Id3Extractor extractor;
   auto result = extractor.ExtractManifestStore(input);
-  ASSERT_THAT(result, IsOk());
+  ABSL_ASSERT_OK(result);
   EXPECT_THAT(*result, HasSubstr("manifest store"));
 }
 
@@ -166,13 +152,10 @@
                   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)))));
+  EXPECT_THAT(
+      extractor.ExtractManifestStoreLocation(input, {.requires_c2pa = true}),
+      IsOkAndHolds(Optional(AllOf(Field(&ByteRange::offset, Eq(58)),
+                                  Field(&ByteRange::length, Eq(14))))));
 }
 
 TEST(Id3ExtractorTest, ExtractBoxesUnimplemented) {
diff --git a/formats/id3/id3_test.cc b/formats/id3/id3_test.cc
index dacab97..d30e43f 100644
--- a/formats/id3/id3_test.cc
+++ b/formats/id3/id3_test.cc
@@ -27,7 +27,6 @@
 namespace credentio {
 namespace {
 
-using ::absl_testing::IsOk;
 using ::absl_testing::StatusIs;
 using ::testing::HasSubstr;
 
@@ -44,7 +43,7 @@
   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());
+  ABSL_ASSERT_OK(geob_or);
   GeobFrame geob = *geob_or;
 
   EXPECT_EQ(geob.mime_type, "text/plain");
@@ -66,7 +65,7 @@
   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());
+  ABSL_ASSERT_OK(geob_or);
   GeobFrame geob = *geob_or;
 
   EXPECT_EQ(geob.mime_type, "text/plain");
@@ -88,7 +87,7 @@
   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());
+  ABSL_ASSERT_OK(geob_or);
   GeobFrame geob = *geob_or;
 
   EXPECT_EQ(geob.mime_type, "text/plain");
@@ -108,7 +107,7 @@
   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());
+  ABSL_ASSERT_OK(geob_or);
   GeobFrame geob = *geob_or;
 
   EXPECT_EQ(geob.mime_type, "text/plain");
@@ -128,7 +127,7 @@
   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());
+  ABSL_ASSERT_OK(geob_or);
   GeobFrame geob = *geob_or;
 
   EXPECT_EQ(geob.mime_type, "text/plain");
@@ -264,7 +263,7 @@
   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());
+  ABSL_ASSERT_OK(geob_or);
   GeobFrame geob = *geob_or;
 
   EXPECT_EQ(geob.mime_type, "text/plain");
@@ -281,7 +280,7 @@
   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());
+  ABSL_ASSERT_OK(header_or);
   Id3Header header = *header_or;
 
   EXPECT_FALSE(header.use_unsynchronisation);
@@ -303,7 +302,7 @@
   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());
+  ABSL_ASSERT_OK(header_or);
   Id3Header header = *header_or;
 
   EXPECT_FALSE(header.use_unsynchronisation);
diff --git a/formats/id3/reader_test.cc b/formats/id3/reader_test.cc
index 1e98844..7ec8436 100644
--- a/formats/id3/reader_test.cc
+++ b/formats/id3/reader_test.cc
@@ -110,7 +110,7 @@
     frames.push_back(frame);
     return true;
   });
-  ASSERT_THAT(status, IsOk());
+  ABSL_ASSERT_OK(status);
   ASSERT_TRUE(status.value().has_extended_header);
 
   EXPECT_THAT(frames, testing::UnorderedElementsAre(
diff --git a/formats/id3/writer.cc b/formats/id3/writer.cc
new file mode 100644
index 0000000..2a0ea76
--- /dev/null
+++ b/formats/id3/writer.cc
@@ -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.
+//
+
+#include "formats/id3/writer.h"
+
+#include <cstdint>
+#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/cord.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/string_view.h"
+#include "crypto/algorithms.h"
+#include "formats/hasher_data.h"
+#include "formats/id3/embedder.h"
+#include "proto/generator.pb.h"
+#include "riegeli/bytes/cord_reader.h"
+#include "riegeli/bytes/cord_writer.h"
+#include "riegeli/bytes/writer.h"
+
+namespace credentio {
+namespace {
+
+// Copies the input to the output cord, with the given manifest store embedded,
+// and returns the offset of the manifest store in the result.
+absl::StatusOr<int64_t> CopyAndEmbedCord(riegeli::Reader& input,
+                                         absl::string_view manifest_store,
+                                         riegeli::Writer& output) {
+  if (!input.Seek(0)) {
+    return absl::InternalError("failed to seek to start of input");
+  }
+  Id3Embedder embedder;
+  ABSL_ASSIGN_OR_RETURN(int64_t manifest_store_offset,
+                        embedder.CopyAndEmbed(input, manifest_store, output));
+  return manifest_store_offset;
+}
+}  // namespace
+
+absl::Status Id3Writer::Write(GenerateManifestStoreRequest request,
+                              riegeli::Reader* absl_nonnull source,
+                              riegeli::Writer* absl_nonnull destination) {
+  if (request.has_hard_binding()) {
+    return absl::InvalidArgumentError(
+        "request must not include `hard_binding` (binding is computed by "
+        "Id3Writer)");
+  }
+
+  // The two-phase approach is required for ID3-compatible audio: The hash can't
+  // be computed correctly until the manifest store size is known, because the
+  // manifest size affects the top-level ID3 tag size.
+
+  // Phase 1: Compute preliminary hard binding, produce a manifest store
+  // template, and embed a zero-filled placeholder in the asset.
+  HashAlgorithm alg = HashAlgorithm::kSha256;
+  ABSL_ASSIGN_OR_RETURN(*request.mutable_hard_binding()->mutable_data_hash(),
+                        DataHashTemplate(alg));
+  ABSL_ASSIGN_OR_RETURN(ManifestStoreTemplate manifest_store_template,
+                        generator_.GenerateManifestStoreTemplate(request));
+  constexpr int64_t kMaxManifestStoreSizeBytes = 100 * 1024 * 1024;  // 100 MiB
+  if (manifest_store_template.manifest_store_size() <= 0 ||
+      manifest_store_template.manifest_store_size() >
+          kMaxManifestStoreSizeBytes) {
+    return absl::InvalidArgumentError(
+        absl::StrCat("invalid manifest store template size: ",
+                     manifest_store_template.manifest_store_size()));
+  }
+  absl::Cord phase1_result;
+  riegeli::CordWriter phase1_writer(&phase1_result);
+  ABSL_ASSIGN_OR_RETURN(
+      int64_t manifest_store_offset,
+      CopyAndEmbedCord(
+          *source,
+          std::string(manifest_store_template.manifest_store_size(), '\0'),
+          phase1_writer));
+  if (!phase1_writer.Close()) {
+    return absl::InternalError("failed to close phase 1 writer");
+  }
+
+  // Phase 2: Compute final hard binding based on the updated content, finalize
+  // the manifest store, and embed the final manifest store in the asset.
+  riegeli::CordReader phase2_reader(&phase1_result);
+  FinalizeManifestStoreRequest finalize_request;
+  *finalize_request.mutable_manifest_store_template() =
+      std::move(manifest_store_template);
+  ABSL_ASSIGN_OR_RETURN(
+      *finalize_request.mutable_hard_binding()->mutable_data_hash(),
+      DigestDataHash(
+          phase2_reader, HashAlgorithm::kSha256,
+          /*manifest_store_offset=*/manifest_store_offset,
+          /*manifest_store_length=*/
+          finalize_request.manifest_store_template().manifest_store_size()));
+  ABSL_ASSIGN_OR_RETURN(auto response,
+                        generator_.FinalizeManifestStore(finalize_request));
+  if (response.manifest_store().size() !=
+      finalize_request.manifest_store_template().manifest_store_size()) {
+    return absl::InternalError("manifest store size does not match template");
+  }
+  destination->Write(phase1_result.Subcord(0, manifest_store_offset));
+  destination->Write(response.manifest_store());
+  destination->Write(phase1_result.Subcord(
+      manifest_store_offset + response.manifest_store().size(),
+      phase1_result.size()));
+  return absl::OkStatus();
+}
+}  // namespace credentio
diff --git a/formats/id3/writer.h b/formats/id3/writer.h
new file mode 100644
index 0000000..562e709
--- /dev/null
+++ b/formats/id3/writer.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_ID3_WRITER_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_ID3_WRITER_H_
+
+#include "absl/base/nullability.h"
+#include "absl/log/die_if_null.h"
+#include "absl/status/status.h"
+#include "generator/generator.h"
+#include "proto/generator.pb.h"
+#include "riegeli/bytes/reader.h"
+#include "riegeli/bytes/writer.h"
+
+namespace credentio {
+
+// Writer of C2PA manifest stores into ID3-compatible audio assets.
+class Id3Writer {
+ public:
+  // Creates a ID3 writer.
+  //
+  // The generator must outlive the writer.
+  explicit Id3Writer(const ManifestStoreGenerator* absl_nonnull generator)
+      : generator_(*ABSL_DIE_IF_NULL(generator)) {}
+
+  // Generates a C2PA manifest store and embeds it into a copy of the given
+  // asset. The riegeli::Reader and riegeli::Writer cannot be pointed to the
+  // same underlying data. The request must not include `hard_binding`.
+  absl::Status Write(GenerateManifestStoreRequest request,
+                     riegeli::Reader* absl_nonnull source,
+                     riegeli::Writer* absl_nonnull destination);
+
+ private:
+  const ManifestStoreGenerator& generator_;
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_ID3_WRITER_H_
diff --git a/formats/id3/writer_test.cc b/formats/id3/writer_test.cc
new file mode 100644
index 0000000..36c3b47
--- /dev/null
+++ b/formats/id3/writer_test.cc
@@ -0,0 +1,192 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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/writer.h"
+
+#include <memory>
+#include <string>
+#include <utility>
+
+#include "absl/functional/any_invocable.h"
+#include "absl/log/absl_check.h"
+#include "absl/status/status.h"
+#include "absl/status/status_matchers.h"
+#include "async/async_context.h"
+#include "formats/byte_range.h"
+#include "formats/id3/extractor.h"
+#include "generator/generator.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "proto/generator.pb.h"
+#include "proto/manifest.pb.h"
+#include "proto/validation_status.pb.h"
+#include "riegeli/bytes/string_reader.h"
+#include "riegeli/bytes/string_writer.h"
+#include "testing/nonprod_generator.h"
+#include "testing/nonprod_validator.h"
+#include "validator/asset_validator.h"
+
+namespace credentio {
+namespace {
+
+using ::absl_testing::IsOkAndHolds;
+using ::absl_testing::StatusIs;
+using ::testing::AllOf;
+using ::testing::Field;
+using ::testing::Gt;
+using ::testing::HasSubstr;
+using ::testing::IsEmpty;
+using ::testing::Optional;
+
+constexpr char kMp3FileWithNoId3Tag[] = {'\xFF', '\xF3', '\x40', '\xC0',
+                                         0x01,   0x02,   0x03,   0x04};
+
+class Id3WriterTest : public ::testing::Test {
+ protected:
+  void SetUp() override {
+    auto generator = CreateNonProdManifestStoreGenerator({});
+    ABSL_CHECK_OK(generator);
+    generator_ = std::move(*generator);
+    writer_ = std::make_unique<Id3Writer>(generator_.get());
+    auto validator = CreateNonProdValidator({});
+    ABSL_CHECK_OK(validator);
+    validator_ = std::move(*validator);
+  }
+  std::unique_ptr<ManifestStoreGenerator> generator_;
+  std::unique_ptr<Id3Writer> writer_;
+  std::unique_ptr<AssetValidator> validator_;
+};
+
+TEST_F(Id3WriterTest, Write) {
+  std::string mp3_file_contents =
+      std::string(kMp3FileWithNoId3Tag, sizeof(kMp3FileWithNoId3Tag));
+  riegeli::StringReader source(mp3_file_contents);
+  ABSL_ASSERT_OK(source.status());
+  std::string result;
+  riegeli::StringWriter destination(&result);
+  ABSL_ASSERT_OK(destination.status());
+  GenerateManifestStoreRequest request;
+  request.mutable_actions()
+      ->mutable_created()
+      ->mutable_action_metadata()
+      ->set_description("test only");
+  ABSL_ASSERT_OK(writer_->Write(request, &source, &destination));
+  ASSERT_TRUE(source.Close());
+  ASSERT_TRUE(destination.Close());
+
+  riegeli::StringReader result_reader(result);
+  ABSL_ASSERT_OK(result_reader.status());
+
+  EXPECT_THAT(Id3Extractor().ExtractManifestStoreLocation(result_reader, {}),
+              IsOkAndHolds(Optional(AllOf(Field(&ByteRange::offset, Gt(0)),
+                                          Field(&ByteRange::length, Gt(0))))));
+
+  ASSERT_TRUE(result_reader.Seek(0));
+  auto validation_result = validator_->Validate(result_reader, "audio/mpeg");
+  ABSL_ASSERT_OK(validation_result);
+  EXPECT_THAT(
+      (*validation_result)->proto().active_manifest().validation().failures(),
+      IsEmpty());
+}
+
+TEST_F(Id3WriterTest, WriteTwiceFails) {
+  GenerateManifestStoreRequest request;
+  request.mutable_actions()
+      ->mutable_created()
+      ->mutable_action_metadata()
+      ->set_description("test only");
+
+  std::string mp3_file_contents =
+      std::string(kMp3FileWithNoId3Tag, sizeof(kMp3FileWithNoId3Tag));
+  riegeli::StringReader source1(mp3_file_contents);
+  ABSL_ASSERT_OK(source1.status());
+  std::string result1;
+  riegeli::StringWriter destination1(&result1);
+  ABSL_ASSERT_OK(destination1.status());
+  ABSL_ASSERT_OK(writer_->Write(request, &source1, &destination1));
+  ASSERT_TRUE(source1.Close());
+  ASSERT_TRUE(destination1.Close());
+
+  riegeli::StringReader source2(result1);
+  ABSL_ASSERT_OK(source2.status());
+  std::string result2;
+  riegeli::StringWriter destination2(&result2);
+  ABSL_ASSERT_OK(destination2.status());
+  EXPECT_THAT(
+      writer_->Write(request, &source2, &destination2),
+      StatusIs(absl::StatusCode::kAlreadyExists, HasSubstr("already exists")));
+}
+
+TEST_F(Id3WriterTest, WriteWithHardBindingFails) {
+  std::string mp3_file_contents =
+      std::string(kMp3FileWithNoId3Tag, sizeof(kMp3FileWithNoId3Tag));
+  riegeli::StringReader source(mp3_file_contents);
+  ABSL_ASSERT_OK(source.status());
+  std::string result;
+  riegeli::StringWriter destination(&result);
+  ABSL_ASSERT_OK(destination.status());
+  GenerateManifestStoreRequest request;
+  request.mutable_actions()
+      ->mutable_created()
+      ->mutable_action_metadata()
+      ->set_description("test only");
+  request.mutable_hard_binding();
+  EXPECT_THAT(writer_->Write(request, &source, &destination),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("must not include `hard_binding`")));
+}
+
+class BadTemplateSizeGenerator : public ManifestStoreGenerator {
+ public:
+  void GenerateManifestStore(
+      GenerateManifestStoreRequest request, AsyncContext context,
+      absl::AnyInvocable<void(absl::StatusOr<GenerateManifestStoreResponse>) &&>
+          cb) const override {}
+
+  void GenerateManifestStoreTemplate(
+      GenerateManifestStoreRequest request, AsyncContext context,
+      absl::AnyInvocable<void(absl::StatusOr<ManifestStoreTemplate>) &&> cb)
+      const override {
+    ManifestStoreTemplate t;
+    t.set_manifest_store_size(-1);
+    std::move(cb)(t);
+  }
+
+  void FinalizeManifestStore(
+      FinalizeManifestStoreRequest request, AsyncContext context,
+      absl::AnyInvocable<void(absl::StatusOr<FinalizeManifestStoreResponse>) &&>
+          cb) const override {}
+};
+
+TEST_F(Id3WriterTest, WriteWithBadTemplateSizeFails) {
+  BadTemplateSizeGenerator bad_generator;
+  Id3Writer writer(&bad_generator);
+  std::string mp3_file_contents =
+      std::string(kMp3FileWithNoId3Tag, sizeof(kMp3FileWithNoId3Tag));
+  riegeli::StringReader source(mp3_file_contents);
+  std::string result;
+  riegeli::StringWriter destination(&result);
+  GenerateManifestStoreRequest request;
+  request.mutable_actions()
+      ->mutable_created()
+      ->mutable_action_metadata()
+      ->set_description("test only");
+  EXPECT_THAT(writer.Write(request, &source, &destination),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("invalid manifest store template size")));
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/formats/jpeg/BUILD b/formats/jpeg/BUILD
index 982f3a0..3ab0008 100644
--- a/formats/jpeg/BUILD
+++ b/formats/jpeg/BUILD
@@ -19,6 +19,90 @@
 package(default_visibility = ["//visibility:public"])
 
 cc_library(
+    name = "app11_io",
+    srcs = ["app11_io.cc"],
+    hdrs = ["app11_io.h"],
+    deps = [
+        "@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:string_reader",
+        "@riegeli//riegeli/bytes:writer",
+        "@riegeli//riegeli/endian:endian_reading",
+        "@riegeli//riegeli/endian:endian_writing",
+    ],
+)
+
+cc_test(
+    name = "app11_io_test",
+    srcs = ["app11_io_test.cc"],
+    deps = [
+        ":app11_io",
+        ":extractor",
+        "//constants:labels",
+        "//formats:asset_box",
+        "//formats:byte_range",
+        "//formats/jpeg/testing:jumbf_creator",
+        "//jumbf:box_builder",
+        "//jumbf:constants",
+        "//jumbf:encode_params",
+        "@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:cord",
+        "@abseil-cpp//absl/strings:string_view",
+        "@googletest//:gtest_main",
+        "@riegeli//riegeli/base:object",
+        "@riegeli//riegeli/bytes:limiting_writer",
+        "@riegeli//riegeli/bytes:string_reader",
+        "@riegeli//riegeli/bytes:string_writer",
+    ],
+)
+
+cc_library(
+    name = "embedder",
+    srcs = ["embedder.cc"],
+    hdrs = ["embedder.h"],
+    deps = [
+        ":app11_io",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/strings:string_view",
+        "@riegeli//riegeli/bytes:copy_all",
+        "@riegeli//riegeli/bytes:reader",
+        "@riegeli//riegeli/bytes:writer",
+    ],
+)
+
+cc_test(
+    name = "embedder_test",
+    srcs = ["embedder_test.cc"],
+    deps = [
+        ":embedder",
+        ":extractor",
+        "//constants:labels",
+        "//jumbf:box_builder",
+        "//jumbf:constants",
+        "//jumbf:encode_params",
+        "@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:cord",
+        "@abseil-cpp//absl/strings:string_view",
+        "@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 = "reader",
     srcs = ["reader.cc"],
     hdrs = ["reader.h"],
@@ -193,3 +277,93 @@
         "@abseil-cpp//absl/status:statusor",
     ],
 )
+
+cc_library(
+    name = "hasher",
+    srcs = ["hasher.cc"],
+    hdrs = ["hasher.h"],
+    deps = [
+        ":assessor",
+        ":c2pa_segment_handler",
+        ":reader",
+        "//bindings:input_hasher",
+        "//crypto:algorithms",
+        "//formats:byte_range",
+        "//proto:data_hash_assertion_cc_proto",
+        "//proto:generator_cc_proto",
+        "//proto:multi_asset_hash_assertion_cc_proto",
+        "@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:str_format",
+        "@riegeli//riegeli/bytes:reader",
+    ],
+)
+
+cc_library(
+    name = "writer",
+    srcs = ["writer.cc"],
+    hdrs = ["writer.h"],
+    deps = [
+        ":app11_io",
+        ":hasher",
+        ":reader",
+        "//async:async_context",
+        "//crypto:algorithms",
+        "//formats:byte_range",
+        "//generator",
+        "//proto:data_hash_assertion_cc_proto",
+        "//proto:generator_cc_proto",
+        "//utils:byte_instruction",
+        "@abseil-cpp//absl/base:nullability",
+        "@abseil-cpp//absl/functional:any_invocable",
+        "@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/synchronization",
+        "@riegeli//riegeli/bytes:reader",
+        "@riegeli//riegeli/bytes:string_writer",
+        "@riegeli//riegeli/bytes:writer",
+    ],
+)
+
+cc_test(
+    name = "writer_test",
+    srcs = ["writer_test.cc"],
+    deps = [
+        ":extractor",
+        ":reader",
+        ":writer",
+        "//async:async_context",
+        "//formats:byte_range",
+        "//formats/jpeg/testing:jpeg_creator",
+        "//formats/jpeg/testing:jumbf_creator",
+        "//generator",
+        "//proto:generator_cc_proto",
+        "//proto:manifest_cc_proto",
+        "//proto:validation_status_cc_proto",
+        "//testing:fake_manifest_store_generator",
+        "//testing:nonprod_generator",
+        "//testing:nonprod_validator",
+        "//testing:proto_test_utils",
+        "//validator:asset_validator",
+        "//validator:result",
+        "@abseil-cpp//absl/log:absl_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",
+        "@abseil-cpp//absl/strings:string_view",
+        "@abseil-cpp//absl/synchronization",
+        "@googletest//:gtest_main",
+        "@riegeli//riegeli/bytes:reader",
+        "@riegeli//riegeli/bytes:string_reader",
+        "@riegeli//riegeli/bytes:string_writer",
+    ],
+)
diff --git a/formats/jpeg/app11_io.cc b/formats/jpeg/app11_io.cc
new file mode 100644
index 0000000..fa32054
--- /dev/null
+++ b/formats/jpeg/app11_io.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/app11_io.h"
+
+#include <cstddef>
+#include <cstdint>
+
+#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 "riegeli/bytes/reader.h"
+#include "riegeli/bytes/string_reader.h"
+#include "riegeli/bytes/writer.h"
+#include "riegeli/endian/endian_reading.h"
+#include "riegeli/endian/endian_writing.h"
+
+namespace credentio {
+namespace {
+
+constexpr uint16_t kMaxPayloadSegmentByteSize = 65000;
+
+struct JumbfHeader {
+  uint32_t lbox;
+  uint32_t tbox;
+  uint64_t xlbox;
+
+  uint64_t byte_size() const {
+    return sizeof(lbox) + sizeof(tbox) + (lbox == 1 ? sizeof(xlbox) : 0);
+  }
+  uint64_t box_size() const { return lbox == 1 ? xlbox : lbox; }
+};
+
+absl::StatusOr<JumbfHeader> ConsumePayloadJumbfHeader(riegeli::Reader& reader) {
+  JumbfHeader header;
+  if (!riegeli::ReadBigEndian<uint32_t>(reader, header.lbox)) {
+    return reader.StatusOrAnnotate(absl::DataLossError("Failed to read lbox"));
+  }
+  if (!riegeli::ReadBigEndian<uint32_t>(reader, header.tbox)) {
+    return reader.StatusOrAnnotate(absl::DataLossError("Failed to read tbox"));
+  }
+  if (header.lbox == 1) {
+    if (!riegeli::ReadBigEndian<uint64_t>(reader, header.xlbox)) {
+      return reader.StatusOrAnnotate(
+          absl::DataLossError("Failed to read xlbox"));
+    }
+  }
+  return header;
+}
+
+absl::Status WritePayloadChunk(const JumbfHeader& header,
+                               absl::string_view payload_chunk,
+                               uint16_t segment_id, uint32_t sequence_num,
+                               riegeli::Writer& output) {
+  if (!riegeli::WriteBigEndian<uint16_t>(0xFFEB, output)) {
+    return output.status();
+  }
+  uint64_t total_seg_length =
+      sizeof(uint16_t) /*seg_length*/ + sizeof(uint16_t) /*ext_type*/ +
+      sizeof(uint16_t) /*segment_id*/ + sizeof(uint32_t) /*sequence_num*/ +
+      header.byte_size() + payload_chunk.size();
+  if (total_seg_length > UINT16_MAX) {
+    return absl::OutOfRangeError("maximum segment size too large");
+  }
+  uint16_t seg_length = static_cast<uint16_t>(total_seg_length);
+  if (!riegeli::WriteBigEndian<uint16_t>(seg_length, output)) {
+    return output.status();
+  }
+  // JPEG extension type.
+  if (!riegeli::WriteBigEndian<uint16_t>(0x4A50, output)) {
+    return output.status();
+  }
+  if (!riegeli::WriteBigEndian<uint16_t>(segment_id, output)) {
+    return output.status();
+  }
+  if (!riegeli::WriteBigEndian<uint32_t>(sequence_num, output)) {
+    return output.status();
+  }
+  if (!riegeli::WriteBigEndian<uint32_t>(header.lbox, output)) {
+    return output.status();
+  }
+  if (!riegeli::WriteBigEndian<uint32_t>(header.tbox, output)) {
+    return output.status();
+  }
+  if (header.lbox == 1) {
+    if (!riegeli::WriteBigEndian<uint64_t>(header.xlbox, output)) {
+      return output.status();
+    }
+  }
+  if (!output.Write(payload_chunk)) {
+    return output.status();
+  }
+  return absl::OkStatus();
+}
+}  // namespace
+
+uint64_t CalculateTotalJpegApp11SegmentsSize(uint64_t jumbf_payload_size) {
+  uint64_t jpeg_header_size = sizeof(uint16_t) +  // marker
+                              sizeof(uint16_t) +  // size
+                              sizeof(uint16_t) +  // ext_type
+                              sizeof(uint16_t) +  // segment_id
+                              sizeof(uint32_t);   // sequence_num
+  uint64_t jumbf_header_size =
+      JumbfHeader{.lbox = static_cast<uint32_t>(
+                      jumbf_payload_size > UINT32_MAX ? 1 : 0)}
+          .byte_size();
+
+  uint64_t payload_size_without_header = jumbf_payload_size - jumbf_header_size;
+  uint64_t number_of_chunks =
+      (payload_size_without_header / kMaxPayloadSegmentByteSize) +
+      (payload_size_without_header % kMaxPayloadSegmentByteSize != 0);
+
+  return ((jpeg_header_size + jumbf_header_size) * number_of_chunks) +
+         payload_size_without_header;
+}
+
+absl::Status WriteJpegApp11Payload(uint16_t segment_id,
+                                   absl::string_view payload,
+                                   riegeli::Writer& output) {
+  riegeli::StringReader<> payload_header_reader(payload.substr(0, 16));
+  ABSL_ASSIGN_OR_RETURN(auto header,
+                        ConsumePayloadJumbfHeader(payload_header_reader));
+  if (header.box_size() != payload.size()) {
+    return absl::InvalidArgumentError(absl::Substitute(
+        "invalid jumbf box -- box size does not match payload size: $0 vs $1",
+        header.box_size(), payload.size()));
+  }
+  uint32_t seq_num = 1;  // Starts at 1 per ISO/IEC 19566-5.
+  size_t cursor = header.byte_size();
+  while (cursor < payload.size()) {
+    uint16_t chunk_size = kMaxPayloadSegmentByteSize;
+    ABSL_RETURN_IF_ERROR(WritePayloadChunk(header,
+                                           payload.substr(cursor, chunk_size),
+                                           segment_id, seq_num, output));
+    cursor += chunk_size;
+    if (++seq_num == 0) {
+      return absl::OutOfRangeError(absl::Substitute(
+          "too many segments required for payload of size $0", payload.size()));
+    }
+  }
+  return absl::OkStatus();
+}
+}  // namespace credentio
diff --git a/formats/jpeg/app11_io.h b/formats/jpeg/app11_io.h
new file mode 100644
index 0000000..0bd56ba
--- /dev/null
+++ b/formats/jpeg/app11_io.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_JPEG_APP11_IO_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_JPEG_APP11_IO_H_
+
+#include <cstdint>
+
+#include "absl/status/status.h"
+#include "absl/strings/string_view.h"
+#include "riegeli/bytes/writer.h"
+
+namespace credentio {
+
+// Given the size of a jumbf payload in bytes, it will return the total number
+// of bytes required to store the payload across n APP11 segments including all
+// JPEG markers and APP11 headers. The number of segments needed is based on a
+// maximum segment size defined by kMaxPayloadSegmentByteSize in the .cc file.
+uint64_t CalculateTotalJpegApp11SegmentsSize(uint64_t jumbf_payload_size);
+
+// Writes the given payload as continuous APP11 segments with the same
+// segment_id. The payload must be a valid JUMBF box.
+absl::Status WriteJpegApp11Payload(uint16_t segment_id,
+                                   absl::string_view payload,
+                                   riegeli::Writer& output);
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_JPEG_APP11_IO_H_
diff --git a/formats/jpeg/app11_io_test.cc b/formats/jpeg/app11_io_test.cc
new file mode 100644
index 0000000..8069de8
--- /dev/null
+++ b/formats/jpeg/app11_io_test.cc
@@ -0,0 +1,198 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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/app11_io.h"
+
+#include <cstddef>
+#include <cstdint>
+#include <string>
+#include <utility>
+
+#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/cord.h"
+#include "absl/strings/string_view.h"
+#include "constants/labels.h"
+#include "formats/asset_box.h"
+#include "formats/byte_range.h"
+#include "formats/jpeg/extractor.h"
+#include "formats/jpeg/testing/jumbf_creator.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "jumbf/box_builder.h"
+#include "jumbf/constants.h"
+#include "jumbf/encode_params.h"
+#include "riegeli/base/object.h"
+#include "riegeli/bytes/limiting_writer.h"
+#include "riegeli/bytes/string_reader.h"
+#include "riegeli/bytes/string_writer.h"
+
+namespace credentio {
+namespace {
+
+using ::absl_testing::IsOkAndHolds;
+using ::absl_testing::StatusIs;
+using ::testing::ElementsAre;
+using ::testing::HasSubstr;
+using ::testing::Optional;
+
+// Creates a jumbf SuperBox that looks superficially like a Manifest Store.
+absl::StatusOr<std::string> CreateFakeManifestStore(size_t content_size) {
+  jumbf::SuperBoxBuilder manifest_store_builder(
+      kManifestStoreUuid,
+      jumbf::DescriptionBoxOptions{.label = absl::Cord(kManifestStoreLabel)});
+  ABSL_RETURN_IF_ERROR(manifest_store_builder.AddContent(
+      jumbf::kBinaryDataBoxType, absl::Cord(std::string(content_size, 'x'))));
+  ABSL_ASSIGN_OR_RETURN(jumbf::BuiltSuperBox manifest_store_box,
+                        std::move(manifest_store_builder).Finalize());
+  return std::string(std::move(manifest_store_box).AsCord());
+}
+
+TEST(App11IOTest, WriteSmallPayload) {
+  auto manifest_store_or = CreateFakeManifestStore(/*content_size=*/100);
+  ABSL_ASSERT_OK(manifest_store_or);
+  const auto& manifest_store = *manifest_store_or;
+
+  std::string output_s;
+  riegeli::StringWriter<> output(&output_s);
+  ABSL_ASSERT_OK(
+      WriteJpegApp11Payload(/*segment_id=*/30, manifest_store, output));
+  ASSERT_TRUE(output.Close());
+
+  riegeli::StringReader<absl::string_view> input(output_s);
+  JpegExtractor extractor;
+
+  EXPECT_THAT(extractor.ExtractManifestStore(input),
+              IsOkAndHolds(manifest_store));
+
+  ASSERT_TRUE(input.Seek(0));
+
+  EXPECT_THAT(extractor.ExtractManifestStoreLocation(input, {}),
+              IsOkAndHolds(Optional(ByteRange{.offset = 0, .length = 158})));
+
+  ASSERT_TRUE(input.Seek(0));
+
+  EXPECT_THAT(
+      extractor.ExtractBoxes(input, {}),
+      IsOkAndHolds(ElementsAre(AssetBox{
+          .identifier = "C2PA", .byte_range = {.offset = 0, .length = 158}})));
+}
+
+TEST(App11IOTest, WriteLargePayload) {
+  auto manifest_store_or = CreateFakeManifestStore(/*content_size=*/250000);
+  ABSL_ASSERT_OK(manifest_store_or);
+  const auto& manifest_store = *manifest_store_or;
+
+  std::string output_s;
+  riegeli::StringWriter<> output(&output_s);
+  ABSL_ASSERT_OK(
+      WriteJpegApp11Payload(/*segment_id=*/30, manifest_store, output));
+  ASSERT_TRUE(output.Close());
+
+  riegeli::StringReader<absl::string_view> input(output_s);
+
+  JpegExtractor extractor;
+  EXPECT_THAT(extractor.ExtractManifestStore(input),
+              IsOkAndHolds(manifest_store));
+
+  ASSERT_TRUE(input.Seek(0));
+
+  EXPECT_THAT(extractor.ExtractManifestStoreLocation(input, {}),
+              IsOkAndHolds(Optional(ByteRange{.offset = 0, .length = 250118})));
+
+  ASSERT_TRUE(input.Seek(0));
+
+  EXPECT_THAT(extractor.ExtractBoxes(input, {}),
+              IsOkAndHolds(ElementsAre(
+                  AssetBox{.identifier = "C2PA",
+                           .byte_range = {.offset = 0, .length = 250118}})));
+}
+
+TEST(App11IOTest, WriteInvalidPayload) {
+  std::string output_s;
+  riegeli::StringWriter<> output(&output_s);
+  EXPECT_THAT(
+      WriteJpegApp11Payload(/*segment_id=*/30, std::string(6, '\0'), output),
+      StatusIs(absl::StatusCode::kDataLoss, HasSubstr("Failed to read tbox")));
+}
+
+TEST(App11IOTest, WriteInvalidJumbfPayload) {
+  std::string output_s;
+  riegeli::StringWriter<> output(&output_s);
+  EXPECT_THAT(
+      WriteJpegApp11Payload(/*segment_id=*/30, std::string(60, '\0'), output),
+      StatusIs(absl::StatusCode::kInvalidArgument,
+               HasSubstr("invalid jumbf box")));
+}
+
+TEST(App11IOTest, WritePayloadWithXlbox) {
+  std::string output_s;
+  riegeli::StringWriter<> output(&output_s);
+  ABSL_EXPECT_OK(WriteJpegApp11Payload(
+      /*segment_id=*/30, CreateJumbf({.force_xlbox = true}), output));
+  ASSERT_TRUE(output.Close());
+}
+
+TEST(App11IOTest, WritePayloadFails) {
+  auto manifest_store_or = CreateFakeManifestStore(/*content_size=*/100);
+  ABSL_ASSERT_OK(manifest_store_or);
+  const auto& manifest_store = *manifest_store_or;
+
+  std::string output_s;
+  riegeli::StringWriter<> string_writer(&output_s);
+  // Limit the writer to 1 byte, forcing a failure.
+  riegeli::LimitingWriter<> limited_writer(
+      &string_writer, riegeli::LimitingWriterBase::Options().set_max_length(1));
+  EXPECT_THAT(
+      WriteJpegApp11Payload(/*segment_id=*/30, manifest_store, limited_writer),
+      StatusIs(absl::StatusCode::kResourceExhausted));
+  EXPECT_FALSE(limited_writer.Close());
+}
+
+TEST(App11IOTest, WritePayloadFailsImmediately) {
+  auto manifest_store_or = CreateFakeManifestStore(/*content_size=*/100);
+  ABSL_ASSERT_OK(manifest_store_or);
+  const auto& manifest_store = *manifest_store_or;
+
+  riegeli::StringWriter<> failing_writer(riegeli::kClosed);
+  failing_writer.Fail(absl::InternalError("Test failure"));
+  EXPECT_THAT(
+      WriteJpegApp11Payload(/*segment_id=*/30, manifest_store, failing_writer),
+      StatusIs(absl::StatusCode::kInternal, HasSubstr("Test failure")));
+  EXPECT_FALSE(failing_writer.Close());
+}
+
+TEST(App11IOTest, CalculateSizeOneSegment) {
+  EXPECT_EQ(CalculateTotalJpegApp11SegmentsSize(1000), 1012);
+}
+
+TEST(App11IOTest, CalculateSizeExactSingleSegmentSize) {
+  EXPECT_EQ(CalculateTotalJpegApp11SegmentsSize(65008), 65020);
+}
+
+TEST(App11IOTest, CalculateSizeMultipleSegments) {
+  EXPECT_EQ(CalculateTotalJpegApp11SegmentsSize(65200), 65232);
+}
+
+TEST(App11IOTest, CalculateSizeOf64BitPayload) {
+  EXPECT_EQ(CalculateTotalJpegApp11SegmentsSize(
+                static_cast<uint64_t>(UINT32_MAX) + 1000),
+            4296818435);
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/formats/jpeg/box_header_test.cc b/formats/jpeg/box_header_test.cc
index 77ffdf9..9d35f8b 100644
--- a/formats/jpeg/box_header_test.cc
+++ b/formats/jpeg/box_header_test.cc
@@ -28,7 +28,6 @@
 namespace credentio {
 namespace {
 
-using ::absl_testing::IsOk;
 using ::absl_testing::StatusIs;
 using ::testing::Range;
 using ::testing::TestParamInfo;
@@ -58,7 +57,7 @@
   }
 
   auto header_or = ConsumeJpegBoxHeader(input);
-  ASSERT_THAT(header_or, IsOk());
+  ABSL_ASSERT_OK(header_or);
 
   JpegBoxHeader header = *header_or;
   EXPECT_EQ(header.offset, 0);
diff --git a/formats/jpeg/embedder.cc b/formats/jpeg/embedder.cc
new file mode 100644
index 0000000..b7f13af
--- /dev/null
+++ b/formats/jpeg/embedder.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/jpeg/embedder.h"
+
+#include <cstdint>
+
+#include "absl/status/status.h"
+#include "absl/status/status_macros.h"
+#include "absl/strings/string_view.h"
+#include "formats/jpeg/app11_io.h"
+#include "riegeli/bytes/copy_all.h"
+#include "riegeli/bytes/reader.h"
+#include "riegeli/bytes/writer.h"
+
+namespace credentio {
+
+absl::Status JpegEmbedder::CopyAndEmbed(riegeli::Reader& input,
+                                        absl::string_view manifest_store,
+                                        int64_t insert_at_offset,
+                                        riegeli::Writer& output) const {
+  int64_t cursor = input.pos();
+
+  // Copy the data up to the insert point.
+  if (!input.Copy(insert_at_offset - cursor, output)) {
+    return input.StatusOrAnnotate(absl::DataLossError("failed to copy input"));
+  }
+
+  // Embed the manifest store.
+  ABSL_RETURN_IF_ERROR(
+      WriteJpegApp11Payload(segment_id_, manifest_store, output));
+
+  // Copy the rest of the file.
+  return riegeli::CopyAll(input, output);
+}
+}  // namespace credentio
diff --git a/formats/jpeg/embedder.h b/formats/jpeg/embedder.h
new file mode 100644
index 0000000..4421286
--- /dev/null
+++ b/formats/jpeg/embedder.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_FORMATS_JPEG_EMBEDDER_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_JPEG_EMBEDDER_H_
+
+#include <cstdint>
+
+#include "absl/status/status.h"
+#include "absl/strings/string_view.h"
+#include "riegeli/bytes/reader.h"
+#include "riegeli/bytes/writer.h"
+
+namespace credentio {
+// A utility class to embed a C2PA manifest store as one or more APP11 segments
+// into a non-C2PA JPEG file.
+class JpegEmbedder {
+ public:
+  explicit JpegEmbedder(uint16_t segment_id) : segment_id_(segment_id) {}
+  // Copies the data from the input to the output, embeds the manifest store
+  // as one or more APP11 segments before the first segment other than SOI
+  // and APP0..APP10.
+  // NOTE: Input and output must be disjoint, i.e., in-place embedding is not
+  // supported.
+  absl::Status CopyAndEmbed(riegeli::Reader& input,
+                            absl::string_view manifest_store,
+                            int64_t insert_at_offset,
+                            riegeli::Writer& output) const;
+
+ private:
+  uint16_t segment_id_;
+};
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_JPEG_EMBEDDER_H_
diff --git a/formats/jpeg/embedder_test.cc b/formats/jpeg/embedder_test.cc
new file mode 100644
index 0000000..aae24a2
--- /dev/null
+++ b/formats/jpeg/embedder_test.cc
@@ -0,0 +1,160 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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/embedder.h"
+
+#include <cstddef>
+#include <cstdint>
+#include <string>
+#include <utility>
+
+#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/cord.h"
+#include "absl/strings/string_view.h"
+#include "absl/types/span.h"
+#include "constants/labels.h"
+#include "formats/jpeg/extractor.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "jumbf/box_builder.h"
+#include "jumbf/constants.h"
+#include "jumbf/encode_params.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::IsOkAndHolds;
+using ::testing::AllOf;
+using ::testing::HasSubstr;
+
+absl::StatusOr<std::string> CreateFakeManifestStore(size_t content_size = 100) {
+  jumbf::SuperBoxBuilder manifest_store_builder(
+      kManifestStoreUuid,
+      jumbf::DescriptionBoxOptions{.label = absl::Cord(kManifestStoreLabel)});
+  ABSL_RETURN_IF_ERROR(manifest_store_builder.AddContent(
+      jumbf::kBinaryDataBoxType, absl::Cord(std::string(content_size, 'x'))));
+  ABSL_ASSIGN_OR_RETURN(jumbf::BuiltSuperBox manifest_store_box,
+                        std::move(manifest_store_builder).Finalize());
+  return std::string(std::move(manifest_store_box).AsCord());
+}
+
+struct JpegSegment {
+  uint16_t marker;
+  std::string payload;
+};
+
+absl::StatusOr<std::string> CreateJpeg(absl::Span<const JpegSegment> segments) {
+  std::string buffer;
+  riegeli::StringWriter<> output(&buffer);
+  for (auto segment : segments) {
+    if (!riegeli::WriteBigEndian<uint16_t>(segment.marker, output)) {
+      return output.status();
+    }
+    if (!segment.payload.empty()) {
+      uint16_t seg_size = sizeof(uint16_t) + segment.payload.size();
+      if (!riegeli::WriteBigEndian<uint16_t>(seg_size, output)) {
+        return output.status();
+      }
+      if (!output.Write(segment.payload)) {
+        return output.status();
+      }
+    }
+  }
+  output.Close();
+  return buffer;
+}
+
+TEST(JpegEmbedderTest, CopyAndEmbedAfterSOI) {
+  auto input_s_or = CreateJpeg({{.marker = /*SOI*/ 0xffd8, .payload = ""}});
+  ABSL_ASSERT_OK(input_s_or);
+  const auto& input_s = *input_s_or;
+
+  riegeli::StringReader<absl::string_view> input(input_s);
+  std::string output_s;
+  riegeli::StringWriter output(&output_s);
+  auto manifest_store_or = CreateFakeManifestStore();
+  ABSL_ASSERT_OK(manifest_store_or);
+  const auto& manifest_store = *manifest_store_or;
+
+  JpegEmbedder embedder(/*segment_id=*/30);
+  ABSL_ASSERT_OK(embedder.CopyAndEmbed(input, manifest_store,
+                                       /*insert_at_offset=*/2, output));
+  output.Close();
+
+  riegeli::StringReader<absl::string_view> output_reader(output_s);
+  EXPECT_THAT(JpegExtractor().ExtractManifestStore(output_reader),
+              IsOkAndHolds(manifest_store));
+}
+
+TEST(JpegEmbedderTest, CopyAndEmbedBeforeSOS) {
+  auto input_s_or = CreateJpeg({{.marker = /*SOI*/ 0xffd8, .payload = ""},
+                                {.marker = /*SOS*/ 0xffda, .payload = "abcd"},
+                                {.marker = /*EOI*/ 0xffd9, .payload = ""}});
+  ABSL_ASSERT_OK(input_s_or);
+  const auto& input_s = *input_s_or;
+
+  riegeli::StringReader input(input_s);
+  std::string output_s;
+  riegeli::StringWriter output(&output_s);
+  auto manifest_store_or = CreateFakeManifestStore();
+  ABSL_ASSERT_OK(manifest_store_or);
+  const auto& manifest_store = *manifest_store_or;
+
+  JpegEmbedder embedder(/*segment_id=*/30);
+  ABSL_ASSERT_OK(embedder.CopyAndEmbed(input, manifest_store,
+                                       /*insert_at_offset=*/2, output));
+  output.Close();
+
+  riegeli::StringReader output_reader(output_s);
+  EXPECT_THAT(JpegExtractor().ExtractManifestStore(output_reader),
+              IsOkAndHolds(manifest_store));
+}
+
+TEST(JpegEmbedderTest, CopyAndEmbedInAppSegments) {
+  auto input_s_or =
+      CreateJpeg({{.marker = /*SOI*/ 0xffd8, .payload = ""},
+                  {.marker = /*APP0*/ 0xFFE0, .payload = "abcd"},
+                  {.marker = /*APP13*/ 0xFFED, .payload = "efgh"},
+                  {.marker = /*APP0*/ 0xFFE0, .payload = "ijkl"}});
+  ABSL_ASSERT_OK(input_s_or);
+  const auto& input_s = *input_s_or;
+
+  riegeli::StringReader input(input_s);
+  std::string output_s;
+  riegeli::StringWriter output(&output_s);
+  auto manifest_store_or = CreateFakeManifestStore();
+  ABSL_ASSERT_OK(manifest_store_or);
+  const auto& manifest_store = *manifest_store_or;
+
+  JpegEmbedder embedder(/*segment_id=*/30);
+  ABSL_ASSERT_OK(embedder.CopyAndEmbed(input, manifest_store,
+                                       /*insert_at_offset=*/10, output));
+  output.Close();
+  EXPECT_THAT(output_s,
+              AllOf(HasSubstr("abcd"), HasSubstr("efgh"), HasSubstr("ijkl")));
+
+  riegeli::StringReader output_reader(output_s);
+  EXPECT_THAT(JpegExtractor().ExtractManifestStore(output_reader),
+              IsOkAndHolds(manifest_store));
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/formats/jpeg/extractor_test.cc b/formats/jpeg/extractor_test.cc
index df119ff..dcca14d 100644
--- a/formats/jpeg/extractor_test.cc
+++ b/formats/jpeg/extractor_test.cc
@@ -42,24 +42,11 @@
 namespace credentio {
 namespace {
 
+using ::absl_testing::IsOkAndHolds;
 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;
@@ -147,117 +134,115 @@
 };
 
 TEST_P(ExtractorTest, Extract) {
-  ASSERT_OK_AND_ASSIGN(auto image, CreateImage(GetParam()));
-  riegeli::StringReader input(image);
+  absl::StatusOr<std::string> image = CreateImage(GetParam());
+  ABSL_ASSERT_OK(image);
+  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));
+  const absl::StatusOr<ExtractorResult>& expected = GetParam().expected;
+  if (expected.ok()) {
+    EXPECT_THAT(extractor_result, IsOkAndHolds(Eq(expected->manifest_store)));
   } else {
     EXPECT_THAT(extractor_result,
-                StatusIs(GetParam().expected.status().code(),
-                         HasSubstr(GetParam().expected.status().message())));
+                StatusIs(expected.status().code(),
+                         HasSubstr(expected.status().message())));
   }
 }
 
 TEST_P(ExtractorTest, ExtractWorksWhenPrefixPadded) {
-  ASSERT_OK_AND_ASSIGN(auto image, CreateImage(GetParam()));
-  std::string contents = "padding" + image;
+  absl::StatusOr<std::string> image = CreateImage(GetParam());
+  ABSL_ASSERT_OK(image);
+  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));
+  const absl::StatusOr<ExtractorResult>& expected = GetParam().expected;
+  if (expected.ok()) {
+    EXPECT_THAT(extractor_result, IsOkAndHolds(Eq(expected->manifest_store)));
   } else {
     EXPECT_THAT(extractor_result,
-                StatusIs(GetParam().expected.status().code(),
-                         HasSubstr(GetParam().expected.status().message())));
+                StatusIs(expected.status().code(),
+                         HasSubstr(expected.status().message())));
   }
 }
 
 TEST_P(ExtractorTest, ExtractManifestStoreLocation) {
-  ASSERT_OK_AND_ASSIGN(auto image, CreateImage(GetParam()));
-  riegeli::StringReader input(image);
+  absl::StatusOr<std::string> image = CreateImage(GetParam());
+  ABSL_ASSERT_OK(image);
+  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));
+  const absl::StatusOr<ExtractorResult>& expected = GetParam().expected;
+  if (expected.ok()) {
+    EXPECT_THAT(
+        result,
+        IsOkAndHolds(Eq(expected->asset_byte_info.manifest_store_location)));
   } else {
-    EXPECT_THAT(result,
-                StatusIs(GetParam().expected.status().code(),
-                         HasSubstr(GetParam().expected.status().message())));
+    EXPECT_THAT(result, StatusIs(expected.status().code(),
+                                 HasSubstr(expected.status().message())));
   }
 }
 
 TEST_P(ExtractorTest, ExtractBoxes) {
-  ASSERT_OK_AND_ASSIGN(auto image, CreateImage(GetParam()));
-  riegeli::StringReader input(image);
+  absl::StatusOr<std::string> image = CreateImage(GetParam());
+  ABSL_ASSERT_OK(image);
+  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));
+  const absl::StatusOr<ExtractorResult>& expected = GetParam().expected;
+  if (expected.ok()) {
+    EXPECT_THAT(result, IsOkAndHolds(Eq(expected->asset_byte_info.boxes)));
   } else {
-    EXPECT_THAT(result,
-                StatusIs(GetParam().expected.status().code(),
-                         HasSubstr(GetParam().expected.status().message())));
+    EXPECT_THAT(result, StatusIs(expected.status().code(),
+                                 HasSubstr(expected.status().message())));
   }
 }
 
 TEST_P(ExtractorTest, ExtractManifestStoreLocationWithSuffix) {
-  ASSERT_OK_AND_ASSIGN(auto image, CreateImage(GetParam()));
-  std::string contents = image + "suffix";
+  absl::StatusOr<std::string> image = CreateImage(GetParam());
+  ABSL_ASSERT_OK(image);
+  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));
+  const absl::StatusOr<ExtractorResult>& expected = GetParam().expected;
+  if (expected.ok()) {
+    EXPECT_THAT(
+        result,
+        IsOkAndHolds(Eq(expected->asset_byte_info.manifest_store_location)));
   } else {
-    EXPECT_THAT(result,
-                StatusIs(GetParam().expected.status().code(),
-                         HasSubstr(GetParam().expected.status().message())));
+    EXPECT_THAT(result, StatusIs(expected.status().code(),
+                                 HasSubstr(expected.status().message())));
   }
 }
 
 TEST_P(ExtractorTest, ExtractBoxesWithSuffix) {
-  ASSERT_OK_AND_ASSIGN(auto image, CreateImage(GetParam()));
-  std::string contents = image + "suffix";
+  absl::StatusOr<std::string> image = CreateImage(GetParam());
+  ABSL_ASSERT_OK(image);
+  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));
+  const absl::StatusOr<ExtractorResult>& expected = GetParam().expected;
+  if (expected.ok()) {
+    EXPECT_THAT(result, IsOkAndHolds(Eq(expected->asset_byte_info.boxes)));
   } else {
-    EXPECT_THAT(result,
-                StatusIs(GetParam().expected.status().code(),
-                         HasSubstr(GetParam().expected.status().message())));
+    EXPECT_THAT(result, StatusIs(expected.status().code(),
+                                 HasSubstr(expected.status().message())));
   }
 }
 
diff --git a/formats/jpeg/hasher.cc b/formats/jpeg/hasher.cc
new file mode 100644
index 0000000..cc88f60
--- /dev/null
+++ b/formats/jpeg/hasher.cc
@@ -0,0 +1,331 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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/hasher.h"
+
+#include <algorithm>
+#include <cstdint>
+#include <memory>
+#include <optional>
+#include <string>
+#include <utility>
+#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/match.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/string_view.h"
+#include "bindings/input_hasher.h"
+#include "crypto/algorithms.h"
+#include "formats/byte_range.h"
+#include "formats/jpeg/assessor.h"
+#include "formats/jpeg/c2pa_segment_handler.h"
+#include "formats/jpeg/reader.h"
+#include "proto/data_hash_assertion.pb.h"
+#include "proto/generator.pb.h"
+#include "proto/multi_asset_hash_assertion.pb.h"
+#include "riegeli/bytes/reader.h"
+
+namespace credentio {
+
+namespace {
+
+constexpr uint64_t kMaxChunkSize = 1024 * 1024;  // 1 MiB
+
+// Updates the given hashers with the indicated content, leaving the input
+// position at the end of the content.
+absl::Status UpdateTwoHashers(riegeli::Reader& input, int64_t offset,
+                              int64_t length, InputHasher& hasher1,
+                              InputHasher& hasher2) {
+  if (!input.Seek(offset)) {
+    return absl::InvalidArgumentError(
+        absl::StrCat("Failed to seek to offset ", offset));
+  }
+
+  int64_t chunk_size = kMaxChunkSize;
+  for (int64_t i = 0; i < length; i += chunk_size) {
+    int64_t l = std::min(length - i, chunk_size);
+
+    std::string data;
+    if (!input.Read(l, data)) {
+      return absl::InvalidArgumentError("Failed to read data");
+    }
+
+    hasher1.Update(data);
+    hasher2.Update(data);
+  }
+
+  return absl::OkStatus();
+}
+
+// Returns true if the current box hash range should end based on the next
+// segment header and whether a C2PA segment needs to be injected.
+bool InsertC2paBeforeSegment(absl::string_view next_segment_label) {
+  if (next_segment_label == "SOI") {
+    return false;
+  }
+  if (!absl::StartsWith(next_segment_label, "APP")) {
+    // Inject before any non-APP segments.
+    return true;
+  }
+  if (next_segment_label == "APP2") {
+    // Inject before the APP2 segment.
+    return true;
+  }
+  if (next_segment_label == "APP11") {
+    // Inject before any existing APP11 segments.
+    return true;
+  }
+  return false;
+}
+
+absl::StatusOr<uint16_t> GetUnusedSegmentId(
+    const absl::flat_hash_set<uint16_t>& used_segment_ids) {
+  if (used_segment_ids.size() == UINT16_MAX) {
+    return absl::InvalidArgumentError("No Available Segment ID");
+  }
+  uint16_t next_segment_id = 1;
+  while (used_segment_ids.contains(next_segment_id)) {
+    ++next_segment_id;
+  }
+  return next_segment_id;
+}
+
+// Computes data hash(es) for a JPEG asset (which may be a part of a multi-part
+// asset). If the starting input position is 0, then
+//  * a placeholder exclusion will be added for the C2PA segment
+//  * the overall file hash will be computed (result.hard_binding.data_hash)
+// In all cases, a part hash will be computed, covering the asset starting at
+// the current input position and ending at the next EOI marker.
+//
+// If `exclusion_processor` is provided, it is invoked for each segment to
+// determine byte ranges to exclude from the hashes.
+absl::Status HashJpeg(riegeli::Reader& input, HashAlgorithm alg,
+                      std::optional<JpegExclusionProcessor> exclusion_processor,
+                      InputHasher& asset_hasher, DigestJpegResult& result) {
+  int64_t asset_start_offset = input.pos();
+  bool is_first_asset = asset_start_offset == 0;
+
+  C2paSegmentHandler c2pa_segment_handler;
+  absl::flat_hash_set<uint16_t> used_segment_ids;
+
+  std::optional<JpegInsertionPoint> insertion_point;
+
+  ABSL_ASSIGN_OR_RETURN(auto part_hasher, InputHasher::Create(alg));
+
+  PartBinding* part_binding = result.hard_binding.add_part_bindings();
+  part_binding->mutable_location()->set_byte_offset(asset_start_offset);
+  part_binding->set_is_optional(!is_first_asset);
+  part_binding->mutable_data_hash()->set_alg(absl::StrCat(alg));
+
+  std::optional<JpegSegment> data_past_asset = std::nullopt;
+
+  ABSL_RETURN_IF_ERROR(IterateOverJpegSegments(
+      input, [&](const JpegSegment& segment) -> absl::StatusOr<bool> {
+        if (segment.marker_label == "c2pa.after") {
+          data_past_asset = std::move(segment);
+          return false;  // stop iteration
+        }
+
+        if (segment.app11_info.has_value()) {
+          used_segment_ids.insert(segment.app11_info->segment_id);
+        }
+
+        if (is_first_asset && !insertion_point.has_value() &&
+            InsertC2paBeforeSegment(segment.marker_label)) {
+          // Add an exclusion placeholder for the C2PA segment. Length will be
+          // filled in during encoding.
+          auto* asset_exclusion =
+              result.hard_binding.mutable_data_hash()->add_exclusions();
+          asset_exclusion->set_start(segment.offset);
+          asset_exclusion->set_length(0);
+
+          auto* part_exclusion =
+              part_binding->mutable_data_hash()->add_exclusions();
+          part_exclusion->set_start(segment.offset - asset_start_offset);
+          part_exclusion->set_length(0);
+
+          insertion_point = JpegInsertionPoint{
+              .segment_id = 1,  // to be updated with the next available ID
+              .offset = segment.offset,
+          };
+        }
+
+        std::vector<ByteRange> exclusions;
+        if (exclusion_processor.has_value()) {
+          ABSL_ASSIGN_OR_RETURN(exclusions,
+                                (*exclusion_processor)(input, segment));
+        }
+        int64_t offset = segment.offset;
+        for (ByteRange exclusion : exclusions) {
+          uint64_t segment_end = segment.offset + segment.length;
+          if (exclusion.offset < offset || exclusion.offset > segment_end ||
+              exclusion.length > segment_end - exclusion.offset) {
+            return absl::InvalidArgumentError(absl::StrCat(
+                "Invalid or overlapping exclusion range [", exclusion.offset,
+                ", ", exclusion.offset + exclusion.length, ") for segment at ",
+                segment.offset));
+          }
+
+          auto* asset_exclusion =
+              result.hard_binding.mutable_data_hash()->add_exclusions();
+          asset_exclusion->set_start(exclusion.offset);
+          asset_exclusion->set_length(exclusion.length);
+
+          auto* part_exclusion =
+              part_binding->mutable_data_hash()->add_exclusions();
+          part_exclusion->set_start(exclusion.offset - asset_start_offset);
+          part_exclusion->set_length(exclusion.length);
+
+          ABSL_RETURN_IF_ERROR(UpdateTwoHashers(input, offset,
+                                                exclusion.offset - offset,
+                                                asset_hasher, *part_hasher));
+          offset = exclusion.offset + exclusion.length;
+        }
+        if (offset < (segment.offset + segment.length)) {
+          ABSL_RETURN_IF_ERROR(UpdateTwoHashers(
+              input, offset, segment.offset + segment.length - offset,
+              asset_hasher, *part_hasher));
+        }
+
+        c2pa_segment_handler.RecordSegment(segment);
+        return true;
+      }));
+
+  if (is_first_asset) {
+    absl::Status c2pa_segment_status =
+        c2pa_segment_handler.GetC2paSegment(input).status();
+    if (c2pa_segment_status.ok() ||
+        absl::StrContains(c2pa_segment_status.message(), "multiple")) {
+      return absl::AlreadyExistsError("C2PA chunk already exists");
+    }
+
+    if (!insertion_point.has_value()) {
+      return absl::InvalidArgumentError(
+          "No valid C2PA insertion point found in JPEG");
+    }
+    ABSL_ASSIGN_OR_RETURN(uint16_t next_segment_id,
+                          GetUnusedSegmentId(used_segment_ids));
+    insertion_point->segment_id = next_segment_id;
+
+    result.insertion_point = std::move(*insertion_point);
+  }
+
+  if (data_past_asset.has_value()) {
+    if (!input.Seek(data_past_asset->offset)) {
+      return absl::InvalidArgumentError("Failed to seek to data past asset");
+    }
+  }
+
+  part_binding->mutable_location()->set_length(input.pos() -
+                                               asset_start_offset);
+  part_binding->mutable_data_hash()->set_hash(part_hasher->Digest());
+  return absl::OkStatus();
+}
+
+// Adds a part hash covering the remaining bytes of the input, marked as
+// optional.
+absl::Status HashRemainingBytes(riegeli::Reader& input, HashAlgorithm alg,
+                                InputHasher& asset_hasher,
+                                DigestJpegResult& result) {
+  if (!input.SupportsSize() || !input.Size().has_value()) {
+    return absl::InvalidArgumentError("Failed to get size of input");
+  }
+
+  int64_t asset_start_offset = input.pos();
+  int64_t asset_length = *input.Size() - asset_start_offset;
+
+  ABSL_ASSIGN_OR_RETURN(auto part_hasher, InputHasher::Create(alg));
+
+  ABSL_RETURN_IF_ERROR(UpdateTwoHashers(input, asset_start_offset, asset_length,
+                                        asset_hasher, *part_hasher));
+
+  auto* part_binding = result.hard_binding.add_part_bindings();
+  part_binding->mutable_location()->set_byte_offset(asset_start_offset);
+  part_binding->mutable_location()->set_length(asset_length);
+  part_binding->set_is_optional(true);
+  part_binding->mutable_data_hash()->set_alg(absl::StrCat(alg));
+  part_binding->mutable_data_hash()->set_hash(part_hasher->Digest());
+
+  return absl::OkStatus();
+}
+
+}  // namespace
+
+absl::StatusOr<DigestJpegResult> DigestJpeg(
+    riegeli::Reader& input, HashAlgorithm alg, bool generate_multi_asset_hash,
+    std::optional<JpegExclusionProcessor> exclusion_processor) {
+  ABSL_ASSIGN_OR_RETURN(auto correct_format, JpegAssessor().IsSupported(input));
+  if (!correct_format) {
+    return absl::InvalidArgumentError("File does not start with a valid JPEG");
+  }
+  if (!input.Seek(0)) {
+    return absl::InvalidArgumentError("Failed to seek to start of input");
+  }
+  if (!input.SupportsSize() || !input.Size().has_value()) {
+    return absl::InvalidArgumentError("Failed to get size of input");
+  }
+
+  DigestJpegResult result = {};
+  result.hard_binding.mutable_data_hash()->set_alg(absl::StrCat(alg));
+
+  uint64_t input_size = *input.Size();
+
+  ABSL_ASSIGN_OR_RETURN(auto asset_hasher, InputHasher::Create(alg));
+
+  int64_t pos;
+  do {
+    pos = input.pos();
+
+    ABSL_ASSIGN_OR_RETURN(bool is_jpeg, JpegAssessor().IsSupported(input));
+    if (is_jpeg) {
+      ABSL_RETURN_IF_ERROR(
+          HashJpeg(input, alg, exclusion_processor, *asset_hasher, result));
+    } else {
+      ABSL_RETURN_IF_ERROR(
+          HashRemainingBytes(input, alg, *asset_hasher, result));
+    }
+
+    if (!generate_multi_asset_hash) {
+      break;
+    }
+    int64_t newpos = input.pos();
+    if (pos == newpos && pos < input_size) {
+      // Avoid infinite loop in case of bugs.
+      return absl::InternalError("Hashing made no progress");
+    }
+    pos = newpos;
+  } while (pos < input_size);
+
+  if (!generate_multi_asset_hash && input.pos() < input_size) {
+    ABSL_RETURN_IF_ERROR(
+        asset_hasher->Update(input, input.pos(), input_size - input.pos()));
+  }
+
+  result.hard_binding.mutable_data_hash()->set_hash(asset_hasher->Digest());
+
+  if (!generate_multi_asset_hash ||
+      result.hard_binding.part_bindings_size() == 1) {
+    // Only a single asset was found, no need for multi-asset hash assertion.
+    result.hard_binding.clear_part_bindings();
+  }
+
+  return result;
+}
+
+}  // namespace credentio
diff --git a/formats/jpeg/hasher.h b/formats/jpeg/hasher.h
new file mode 100644
index 0000000..2da3eee
--- /dev/null
+++ b/formats/jpeg/hasher.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_JPEG_HASHER_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_JPEG_HASHER_H_
+
+#include <cstdint>
+#include <optional>
+#include <vector>
+
+#include "absl/functional/function_ref.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/str_format.h"
+#include "crypto/algorithms.h"
+#include "formats/byte_range.h"
+#include "formats/jpeg/reader.h"
+#include "proto/data_hash_assertion.pb.h"
+#include "proto/generator.pb.h"
+#include "proto/multi_asset_hash_assertion.pb.h"
+#include "riegeli/bytes/reader.h"
+
+namespace credentio {
+
+struct JpegInsertionPoint {
+  uint16_t segment_id = 0;
+  uint64_t offset = 0;
+
+  friend bool operator==(const JpegInsertionPoint&,
+                         const JpegInsertionPoint&) = default;
+
+  template <typename Sink>
+  friend void AbslStringify(Sink& sink, const JpegInsertionPoint& point) {
+    absl::Format(&sink, "JpegInsertionPoint{segment_id: %u, offset: %u}",
+                 point.segment_id, point.offset);
+  }
+};
+
+struct DigestJpegResult {
+  HardBinding hard_binding;
+  JpegInsertionPoint insertion_point;
+};
+
+// Determines byte ranges within a given JPEG segment to be excluded from
+// hashing. Returned ByteRange offsets must be absolute file offsets within
+// [segment.offset, segment.offset + segment.length).
+using JpegExclusionProcessor =
+    absl::FunctionRef<absl::StatusOr<std::vector<ByteRange>>(
+        riegeli::Reader& reader, const JpegSegment& segment)>;
+
+// Digests the given JPEG image and returns a DigestJpegResult that can be
+// used to generate a C2PA manifest store.
+//
+// The result will contain a Data Hash Assertion, the offset at
+// which to inject the C2PA data, and the segment ID to use. If the asset
+// contains additional data beyond the initial JPEG's EOI tag it will also
+// contain a MultiAssetHashAssertion and a vector of Data Hash Assertions for
+// each found part of the file.
+//
+// Setting `generate_multi_asset_hash` to true will cause a multi-asset hash
+// assertion to be included if the input contains data after EOI. Non-JPEG
+// parts (e.g., an Android Motion Photo video) will be marked as optional.
+//
+// If `exclusion_processor` is provided, it is invoked for segments to identify
+// byte ranges to exclude from hashing.
+absl::StatusOr<DigestJpegResult> DigestJpeg(
+    riegeli::Reader& input, HashAlgorithm alg, bool generate_multi_asset_hash,
+    std::optional<JpegExclusionProcessor> exclusion_processor = std::nullopt);
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_JPEG_HASHER_H_
diff --git a/formats/jpeg/testing/app_segment_creator_test.cc b/formats/jpeg/testing/app_segment_creator_test.cc
index 2250aee..854402f 100644
--- a/formats/jpeg/testing/app_segment_creator_test.cc
+++ b/formats/jpeg/testing/app_segment_creator_test.cc
@@ -26,7 +26,6 @@
 namespace credentio {
 namespace {
 
-using ::absl_testing::IsOk;
 using ::absl_testing::StatusIs;
 
 TEST(AppSegmentCreatorTest, CreateAppSegmentsSuccess) {
@@ -36,7 +35,7 @@
   params.max_size = 65535;
 
   auto segments_or = CreateAppSegments(params);
-  ASSERT_THAT(segments_or, IsOk());
+  ABSL_ASSERT_OK(segments_or);
   EXPECT_EQ(segments_or.value().size(), 1);
 }
 
diff --git a/formats/jpeg/writer.cc b/formats/jpeg/writer.cc
new file mode 100644
index 0000000..adb8a65
--- /dev/null
+++ b/formats/jpeg/writer.cc
@@ -0,0 +1,297 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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/writer.h"
+
+#include <cstdint>
+#include <memory>
+#include <optional>
+#include <string>
+#include <utility>
+#include <vector>
+
+#include "absl/base/nullability.h"
+#include "absl/functional/any_invocable.h"
+#include "absl/log/die_if_null.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/synchronization/notification.h"
+#include "async/async_context.h"
+#include "crypto/algorithms.h"
+#include "formats/jpeg/app11_io.h"
+#include "formats/jpeg/hasher.h"
+#include "formats/jpeg/reader.h"
+#include "generator/generator.h"
+#include "proto/data_hash_assertion.pb.h"
+#include "proto/generator.pb.h"
+#include "riegeli/bytes/reader.h"
+#include "riegeli/bytes/string_writer.h"
+#include "riegeli/bytes/writer.h"
+#include "utils/byte_instruction.h"
+
+namespace credentio {
+namespace {
+
+void AdjustDataHashExclusions(DataHashAssertion* absl_nonnull assertion,
+                              int64_t c2pa_offset, int64_t c2pa_size) {
+  for (auto& exclusion : *assertion->mutable_exclusions()) {
+    if (exclusion.start() < c2pa_offset) {
+      // Before the C2PA segment, so no adjustment needed.
+      continue;
+    }
+    if (exclusion.start() == c2pa_offset) {
+      // This is the C2PA segment, just need to fill in the length.
+      exclusion.set_length(c2pa_size);
+      continue;
+    }
+    // This is after the C2PA segment, so we need to add the manifest size to
+    // the offset.
+    exclusion.set_start(exclusion.start() + c2pa_size);
+  }
+}
+
+}  // namespace
+
+JpegWriter::JpegWriter(const ManifestStoreGenerator* absl_nonnull generator,
+                       Options options)
+    : generator_(*ABSL_DIE_IF_NULL(generator)), options_(std::move(options)) {}
+
+absl::Status JpegWriter::Write(
+    GenerateManifestStoreRequest request, riegeli::Reader* absl_nonnull source,
+    riegeli::Writer* absl_nonnull destination,
+    OutputMetadata* absl_nullable output_metadata) const {
+  absl::Notification done;
+  absl::Status status;
+  Write(std::move(request), source, destination, output_metadata,
+        AsyncContext(), [&](absl::Status s) {
+          status = std::move(s);
+          done.Notify();
+        });
+  done.WaitForNotification();
+  return status;
+}
+
+void JpegWriter::Write(GenerateManifestStoreRequest request,
+                       riegeli::Reader* absl_nonnull source,
+                       riegeli::Writer* absl_nonnull destination,
+                       OutputMetadata* absl_nullable output_metadata,
+                       AsyncContext context,
+                       absl::AnyInvocable<void(absl::Status) &&> cb) const {
+  auto byte_instructions = std::make_shared<std::vector<ByteInstruction>>();
+  WriteInternal(
+      std::move(request), source, output_metadata, byte_instructions, context,
+      [resolver = AsyncResolver<void>(context, std::move(cb)), source,
+       destination, byte_instructions](absl::Status status) mutable {
+        if (!status.ok()) {
+          resolver.Return(std::move(status));
+          return;
+        }
+        status = ApplyByteInstructions(source, *byte_instructions, destination);
+        resolver.Return(std::move(status));
+      });
+}
+
+absl::Status JpegWriter::Write(
+    GenerateManifestStoreRequest request, riegeli::Writer* absl_nonnull asset,
+    OutputMetadata* absl_nullable output_metadata) const {
+  absl::Notification done;
+  absl::Status status;
+  Write(std::move(request), asset, output_metadata, AsyncContext(),
+        [&](absl::Status s) {
+          status = std::move(s);
+          done.Notify();
+        });
+  done.WaitForNotification();
+  return status;
+}
+
+void JpegWriter::Write(GenerateManifestStoreRequest request,
+                       riegeli::Writer* absl_nonnull asset,
+                       OutputMetadata* absl_nullable output_metadata,
+                       AsyncContext context,
+                       absl::AnyInvocable<void(absl::Status) &&> cb) const {
+  AsyncResolver<void> resolver(context, std::move(cb));
+  if (!asset->SupportsRandomAccess()) {
+    resolver.Return(
+        absl::InvalidArgumentError("asset does not support random access"));
+    return;
+  }
+  if (!asset->SupportsReadMode()) {
+    resolver.Return(
+        absl::InvalidArgumentError("asset does not support read mode"));
+    return;
+  }
+
+  riegeli::Reader* source = asset->ReadMode(0);
+  if (source == nullptr) {
+    resolver.Return(asset->ok()
+                        ? absl::InternalError("failed to create source reader")
+                        : asset->status());
+    return;
+  }
+  if (!source->ok()) {
+    resolver.Return(source->status());
+    return;
+  }
+
+  auto byte_instructions = std::make_shared<std::vector<ByteInstruction>>();
+  WriteInternal(std::move(request), source, output_metadata, byte_instructions,
+                context,
+                [resolver = std::move(resolver), asset,
+                 byte_instructions](absl::Status status) mutable {
+                  if (!status.ok()) {
+                    resolver.Return(std::move(status));
+                    return;
+                  }
+                  status = ApplyByteInstructions(*byte_instructions, asset);
+                  resolver.Return(std::move(status));
+                });
+}
+
+void JpegWriter::WriteInternal(
+    GenerateManifestStoreRequest request, riegeli::Reader* absl_nonnull source,
+    OutputMetadata* absl_nullable output_metadata,
+    std::shared_ptr<std::vector<ByteInstruction>> byte_instructions,
+    AsyncContext context, absl::AnyInvocable<void(absl::Status) &&> cb) const {
+  AsyncResolver<void> resolver(context, std::move(cb));
+  if (request.has_hard_binding()) {
+    resolver.Return(absl::InvalidArgumentError(
+        "request must not include `hard_binding` (binding is computed by "
+        "JpegWriter)"));
+    return;
+  }
+
+  auto exclusion_fn = [this, &request](riegeli::Reader& reader,
+                                       const JpegSegment& segment) {
+    return options_.exclusion_processor(reader, segment, request);
+  };
+  std::optional<JpegExclusionProcessor> exclusion_processor = std::nullopt;
+  if (options_.exclusion_processor != nullptr) {
+    exclusion_processor.emplace(std::move(exclusion_fn));
+  }
+  auto digest_result = DigestJpeg(*source, HashAlgorithm::kSha256,
+                                  request.options().generate_multi_asset_hash(),
+                                  exclusion_processor);
+  if (!digest_result.ok()) {
+    resolver.Return(digest_result.status());
+    return;
+  }
+
+  request.mutable_hard_binding()->Swap(&digest_result->hard_binding);
+  auto insertion_point = std::move(digest_result->insertion_point);
+
+  generator_.GenerateManifestStoreTemplate(
+      request, context,
+      [this, request, insertion_point = std::move(insertion_point),
+       output_metadata, byte_instructions, context,
+       resolver = std::move(resolver)](
+          absl::StatusOr<ManifestStoreTemplate> template_response) mutable {
+        if (!template_response.ok()) {
+          resolver.Return(template_response.status());
+          return;
+        }
+
+        // Do things with Generate Template Response
+        constexpr int64_t kMaxManifestStoreSizeBytes =
+            100 * 1024 * 1024;  // 100 MiB
+        if (template_response->manifest_store_size() <= 0 ||
+            template_response->manifest_store_size() >
+                kMaxManifestStoreSizeBytes) {
+          resolver.Return(absl::InvalidArgumentError(
+              absl::StrCat("invalid manifest store template size: ",
+                           template_response->manifest_store_size())));
+          return;
+        }
+
+        uint64_t embedded_size = CalculateTotalJpegApp11SegmentsSize(
+            template_response->manifest_store_size());
+        if (embedded_size > UINT32_MAX) {
+          resolver.Return(
+              absl::InternalError("estimated manifest store size is too large "
+                                  "to fit in a uint32_t"));
+          return;
+        }
+
+        FinalizeManifestStoreRequest finalize_request;
+        finalize_request.mutable_manifest_store_template()->Swap(
+            &*template_response);
+        finalize_request.mutable_hard_binding()->MergeFrom(
+            request.hard_binding());
+
+        AdjustDataHashExclusions(
+            finalize_request.mutable_hard_binding()->mutable_data_hash(),
+            insertion_point.offset, embedded_size);
+
+        if (finalize_request.hard_binding().part_bindings_size() > 0) {
+          AdjustDataHashExclusions(finalize_request.mutable_hard_binding()
+                                       ->mutable_part_bindings(0)
+                                       ->mutable_data_hash(),
+                                   insertion_point.offset, embedded_size);
+
+          for (int i = 0;
+               i < finalize_request.hard_binding().part_bindings_size(); ++i) {
+            auto* location = finalize_request.mutable_hard_binding()
+                                 ->mutable_part_bindings(i)
+                                 ->mutable_location();
+            if (location->byte_offset() == 0) {
+              location->set_length(location->length() + embedded_size);
+            } else {
+              location->set_byte_offset(location->byte_offset() +
+                                        embedded_size);
+            }
+          }
+        }
+
+        generator_.FinalizeManifestStore(
+            finalize_request, context,
+            [insertion_point, output_metadata, byte_instructions,
+             resolver = std::move(resolver)](
+                absl::StatusOr<FinalizeManifestStoreResponse>
+                    finalize_response) mutable {
+              if (!finalize_response.ok()) {
+                resolver.Return(finalize_response.status());
+                return;
+              }
+
+              std::string app11_payload;
+              riegeli::StringWriter app11_writer(&app11_payload);
+              auto write_status = WriteJpegApp11Payload(
+                  insertion_point.segment_id,
+                  finalize_response->manifest_store(), app11_writer);
+              if (!write_status.ok()) {
+                resolver.Return(std::move(write_status));
+                return;
+              }
+              if (!app11_writer.Close()) {
+                resolver.Return(app11_writer.status());
+                return;
+              }
+
+              byte_instructions->push_back(ByteInstruction{
+                  .operation = ByteInstruction::Operation::kInsert,
+                  .offset = static_cast<uint64_t>(insertion_point.offset),
+                  .bytes = std::move(app11_payload)});
+
+              if (output_metadata != nullptr) {
+                output_metadata->timestamp_included =
+                    finalize_response->is_timestamped();
+              }
+              resolver.Return(absl::OkStatus());
+            });
+      });
+}
+}  // namespace credentio
diff --git a/formats/jpeg/writer.h b/formats/jpeg/writer.h
new file mode 100644
index 0000000..cfe73fd
--- /dev/null
+++ b/formats/jpeg/writer.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_JPEG_WRITER_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_JPEG_WRITER_H_
+
+#include <cstdint>
+#include <memory>
+#include <utility>
+#include <vector>
+
+#include "absl/base/nullability.h"
+#include "absl/functional/any_invocable.h"
+#include "absl/status/status.h"
+#include "absl/status/statusor.h"
+#include "async/async_context.h"
+#include "formats/byte_range.h"
+#include "formats/jpeg/reader.h"
+#include "generator/generator.h"
+#include "proto/generator.pb.h"
+#include "riegeli/bytes/reader.h"
+#include "riegeli/bytes/writer.h"
+#include "utils/byte_instruction.h"
+
+namespace credentio {
+
+// Writes C2PA manifest stores into JPEG images.
+//
+// The writer applies no exclusion policy of its own: unless an
+// `ExclusionProcessor` is supplied, every byte of the source, other than the
+// manifest store APP11 segment(s), is covered by the hard binding hash.
+class JpegWriter {
+ public:
+  // Information about the C2PA manifest store embedded in the output asset.
+  struct OutputMetadata {
+    // Whether the C2PA active manifest embedded in the output asset has a
+    // timestamp.
+    bool timestamp_included = false;
+  };
+
+  // A function that determines byte ranges within a given JPEG segment to be
+  // excluded from the hard binding hash.
+  using ExclusionProcessor =
+      absl::AnyInvocable<absl::StatusOr<std::vector<ByteRange>>(
+          riegeli::Reader&, const JpegSegment&,
+          const GenerateManifestStoreRequest&) const>;
+
+  struct Options {
+    // If null, no bytes, other than the manifest store APP11 segment(s), are
+    // excluded from the hard binding hash.
+    ExclusionProcessor exclusion_processor = nullptr;
+  };
+
+  explicit JpegWriter(const ManifestStoreGenerator* absl_nonnull generator)
+      : JpegWriter(generator, Options()) {}
+  JpegWriter(const ManifestStoreGenerator* absl_nonnull generator,
+             Options options);
+
+  // Generates a C2PA manifest store and embeds it into a copy of the given
+  // asset. The riegeli::Reader and riegeli::Writer cannot be pointed to the
+  // same underlying data. The request must not include `hard_binding`.
+  void Write(GenerateManifestStoreRequest request,
+             riegeli::Reader* absl_nonnull source,
+             riegeli::Writer* absl_nonnull destination,
+             OutputMetadata* absl_nullable output_metadata,
+             AsyncContext context,
+             absl::AnyInvocable<void(absl::Status) &&> cb) const;
+  // Blocking version.
+  absl::Status Write(
+      GenerateManifestStoreRequest request,
+      riegeli::Reader* absl_nonnull source,
+      riegeli::Writer* absl_nonnull destination,
+      OutputMetadata* absl_nullable output_metadata = nullptr) const;
+
+  // Generates a C2PA manifest store and embeds it into the given asset in
+  // place.
+  //
+  // The supplied writer must support random access and read mode.
+  void Write(GenerateManifestStoreRequest request,
+             riegeli::Writer* absl_nonnull asset,
+             OutputMetadata* absl_nullable output_metadata,
+             AsyncContext context,
+             absl::AnyInvocable<void(absl::Status) &&> cb) const;
+  // Blocking version.
+  absl::Status Write(
+      GenerateManifestStoreRequest request, riegeli::Writer* absl_nonnull asset,
+      OutputMetadata* absl_nullable output_metadata = nullptr) const;
+
+ private:
+  void WriteInternal(
+      GenerateManifestStoreRequest request,
+      riegeli::Reader* absl_nonnull source,
+      OutputMetadata* absl_nullable output_metadata,
+      std::shared_ptr<std::vector<ByteInstruction>> byte_instructions,
+      AsyncContext context, absl::AnyInvocable<void(absl::Status) &&> cb) const;
+
+  const ManifestStoreGenerator& generator_;
+  Options options_;
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_JPEG_WRITER_H_
diff --git a/formats/jpeg/writer_test.cc b/formats/jpeg/writer_test.cc
new file mode 100644
index 0000000..1f0b29a
--- /dev/null
+++ b/formats/jpeg/writer_test.cc
@@ -0,0 +1,559 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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/writer.h"
+
+#include <cstddef>
+#include <cstdint>
+#include <memory>
+#include <string>
+#include <utility>
+#include <vector>
+
+#include "absl/log/absl_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/str_cat.h"
+#include "absl/strings/string_view.h"
+#include "absl/synchronization/notification.h"
+#include "async/async_context.h"
+#include "formats/byte_range.h"
+#include "formats/jpeg/extractor.h"
+#include "formats/jpeg/reader.h"
+#include "formats/jpeg/testing/jpeg_creator.h"
+#include "formats/jpeg/testing/jumbf_creator.h"
+#include "generator/generator.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "proto/generator.pb.h"
+#include "proto/manifest.pb.h"
+#include "proto/validation_status.pb.h"
+#include "riegeli/bytes/reader.h"
+#include "riegeli/bytes/string_reader.h"
+#include "riegeli/bytes/string_writer.h"
+#include "testing/fake_manifest_store_generator.h"
+#include "testing/nonprod_generator.h"
+#include "testing/nonprod_validator.h"
+#include "testing/proto_test_utils.h"
+#include "validator/asset_validator.h"
+#include "validator/result.h"
+
+namespace credentio {
+namespace {
+
+using ::absl_testing::IsOkAndHolds;
+using ::absl_testing::StatusIs;
+using ::credentio_testing::ParseTextProtoOrDie;
+using ::credentio_testing::PartiallyEqualsProto;
+using ::testing::AllOf;
+using ::testing::Contains;
+using ::testing::Field;
+using ::testing::Gt;
+using ::testing::HasSubstr;
+using ::testing::IsEmpty;
+using ::testing::Not;
+using ::testing::Optional;
+using ::testing::SizeIs;
+using ::testing::StartsWith;
+
+constexpr char kExifData[] = "Exif\0\0fake exif data";
+constexpr absl::string_view kExifPayload(kExifData, sizeof(kExifData) - 1);
+
+// Returns a JPEG segment with the given marker and payload.
+std::string Segment(uint16_t marker, absl::string_view payload) {
+  const uint16_t length = static_cast<uint16_t>(payload.size() + 2);
+  return absl::StrCat(
+      std::string({static_cast<char>(marker >> 8), static_cast<char>(marker),
+                   static_cast<char>(length >> 8), static_cast<char>(length)}),
+      payload);
+}
+
+// Returns a minimal JPEG image: SOI, APP0 (JFIF), APP1 (Exif), DQT, EOI.
+std::string CreateJpegImage() {
+  return CreateJpeg(JpegParams{
+      .payload = {
+          Segment(0xffe0,
+                  absl::string_view("JFIF\0\x01\x02\0\0\x01\0\x01\0\0", 14)),
+          Segment(0xffe1, kExifPayload),
+          Segment(0xffdb, std::string(65, '\x01')),
+      }});
+}
+
+GenerateManifestStoreRequest CreateRequest() {
+  GenerateManifestStoreRequest request;
+  request.mutable_actions()
+      ->mutable_created()
+      ->mutable_action_metadata()
+      ->set_description("test only");
+  return request;
+}
+
+// Returns a fake manifest store: a JUMBF box with a payload of the given size.
+std::string CreateFakeManifestStore(size_t payload_size = 32) {
+  return CreateJumbf({.payload = std::string(payload_size, 'm')});
+}
+
+// Returns the byte offset of the Exif payload in `jpeg`.
+size_t ExifOffset(absl::string_view jpeg) {
+  size_t offset = jpeg.find(kExifPayload);
+  ABSL_CHECK_NE(offset, absl::string_view::npos);
+  return offset;
+}
+
+class JpegWriterTest : public ::testing::Test {
+ protected:
+  void SetUp() override {
+    auto generator = CreateNonProdManifestStoreGenerator();
+    ABSL_CHECK_OK(generator);
+    generator_ = *std::move(generator);
+    writer_ = std::make_unique<JpegWriter>(generator_.get());
+
+    auto validator = CreateNonProdValidator();
+    ABSL_CHECK_OK(validator);
+    validator_ = *std::move(validator);
+  }
+
+  // Validates `asset` and returns the active manifest validation statuses.
+  absl::StatusOr<ValidationStatusSet> Validate(absl::string_view asset) {
+    riegeli::StringReader reader(asset);
+    ABSL_ASSIGN_OR_RETURN(std::unique_ptr<ValidationResult> result,
+                          validator_->Validate(reader, "image/jpeg"));
+    return result->proto().active_manifest().validation();
+  }
+
+  // Expects `asset` to contain a manifest store and to be valid.
+  void ExpectValid(absl::string_view asset) {
+    riegeli::StringReader reader(asset);
+    EXPECT_THAT(
+        JpegExtractor().ExtractManifestStoreLocation(reader, {}),
+        IsOkAndHolds(Optional(AllOf(Field(&ByteRange::offset, Gt(0)),
+                                    Field(&ByteRange::length, Gt(0))))));
+
+    absl::StatusOr<ValidationStatusSet> validation = Validate(asset);
+    ABSL_ASSERT_OK(validation);
+    EXPECT_THAT(validation->failures(), IsEmpty());
+    EXPECT_THAT(validation->successes(),
+                Contains(PartiallyEqualsProto(
+                    ParseTextProtoOrDie<ValidationStatus>(R"pb(
+                      code: "assertion.dataHash.match"
+                    )pb"))));
+  }
+
+  // Writes a manifest store into a copy of `image` using `writer`.
+  static absl::StatusOr<std::string> WriteCopy(
+      const JpegWriter& writer, GenerateManifestStoreRequest request,
+      absl::string_view image,
+      JpegWriter::OutputMetadata* output_metadata = nullptr) {
+    riegeli::StringReader source(image);
+    std::string result;
+    riegeli::StringWriter destination(&result);
+    absl::Status status = writer.Write(std::move(request), &source,
+                                       &destination, output_metadata);
+    if (!status.ok()) return status;
+    if (!destination.Close()) return destination.status();
+    return result;
+  }
+
+  std::unique_ptr<ManifestStoreGenerator> generator_;
+  std::unique_ptr<JpegWriter> writer_;
+  std::unique_ptr<AssetValidator> validator_;
+};
+
+TEST_F(JpegWriterTest, Write) {
+  std::string image = CreateJpegImage();
+  JpegWriter::OutputMetadata output_metadata;
+  absl::StatusOr<std::string> result =
+      WriteCopy(*writer_, CreateRequest(), image, &output_metadata);
+  ABSL_ASSERT_OK(result);
+  EXPECT_TRUE(output_metadata.timestamp_included);
+  EXPECT_GT(result->size(), image.size());
+  ExpectValid(*result);
+
+  absl::StatusOr<ValidationStatusSet> validation = Validate(*result);
+  ABSL_ASSERT_OK(validation);
+  EXPECT_THAT(validation->informationals(),
+              Not(Contains(PartiallyEqualsProto(
+                  ParseTextProtoOrDie<ValidationStatus>(R"pb(
+                    code: "assertion.dataHash.additionalExclusionsPresent"
+                  )pb")))));
+}
+
+TEST_F(JpegWriterTest, WriteInsertsManifestStoreBeforeFirstNonAppSegment) {
+  FakeManifestStoreGenerator generator(CreateFakeManifestStore());
+  JpegWriter writer(&generator);
+  std::string image = CreateJpegImage();
+  size_t dqt_offset = image.find("\xff\xdb");
+  ASSERT_NE(dqt_offset, std::string::npos);
+
+  absl::StatusOr<std::string> result =
+      WriteCopy(writer, CreateRequest(), image);
+  ABSL_ASSERT_OK(result);
+
+  // Bytes before the insertion point are unchanged.
+  EXPECT_THAT(*result, StartsWith(image.substr(0, dqt_offset)));
+  // An APP11 segment containing the manifest store is inserted.
+  EXPECT_EQ(result->substr(dqt_offset, 2), "\xff\xeb");
+  EXPECT_THAT(*result, HasSubstr(generator.manifest_store()));
+  // Bytes after the insertion point are unchanged.
+  EXPECT_EQ(result->substr(result->size() - (image.size() - dqt_offset)),
+            image.substr(dqt_offset));
+}
+
+TEST_F(JpegWriterTest, WriteWithoutOutputMetadata) {
+  absl::StatusOr<std::string> result =
+      WriteCopy(*writer_, CreateRequest(), CreateJpegImage());
+  ABSL_ASSERT_OK(result);
+  ExpectValid(*result);
+}
+
+TEST_F(JpegWriterTest, WriteWithoutTimestamp) {
+  auto generator =
+      CreateNonProdManifestStoreGenerator({.disable_timestamping = true});
+  ABSL_ASSERT_OK(generator);
+  JpegWriter writer(generator->get());
+
+  JpegWriter::OutputMetadata output_metadata;
+  absl::StatusOr<std::string> result =
+      WriteCopy(writer, CreateRequest(), CreateJpegImage(), &output_metadata);
+  ABSL_ASSERT_OK(result);
+  EXPECT_FALSE(output_metadata.timestamp_included);
+  ExpectValid(*result);
+}
+
+TEST_F(JpegWriterTest, WriteInPlace) {
+  std::string image = CreateJpegImage();
+  const size_t original_size = image.size();
+  riegeli::StringWriter<> asset(
+      &image, riegeli::StringWriterBase::Options().set_append(true));
+  ABSL_ASSERT_OK(asset.status());
+
+  JpegWriter::OutputMetadata output_metadata;
+  ABSL_ASSERT_OK(writer_->Write(CreateRequest(), &asset, &output_metadata));
+  ASSERT_TRUE(asset.Close());
+  EXPECT_TRUE(output_metadata.timestamp_included);
+  EXPECT_GT(image.size(), original_size);
+  ExpectValid(image);
+}
+
+TEST_F(JpegWriterTest, WriteInPlaceWithoutOutputMetadata) {
+  std::string image = CreateJpegImage();
+  riegeli::StringWriter<> asset(
+      &image, riegeli::StringWriterBase::Options().set_append(true));
+  ABSL_ASSERT_OK(asset.status());
+
+  ABSL_ASSERT_OK(writer_->Write(CreateRequest(), &asset));
+  ASSERT_TRUE(asset.Close());
+  ExpectValid(image);
+}
+
+TEST_F(JpegWriterTest, WriteInPlaceFailsWhenRandomAccessNotSupported) {
+  class NonRandomAccessWriter : public riegeli::StringWriter<> {
+   public:
+    using StringWriter::StringWriter;
+    bool SupportsRandomAccess() override { return false; }
+  };
+  std::string image = CreateJpegImage();
+  NonRandomAccessWriter asset(&image);
+  EXPECT_THAT(writer_->Write(CreateRequest(), &asset),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("asset does not support random access")));
+}
+
+TEST_F(JpegWriterTest, WriteInPlaceFailsWhenReadModeNotSupported) {
+  class NonReadModeWriter : public riegeli::StringWriter<> {
+   public:
+    using StringWriter::StringWriter;
+    bool SupportsReadMode() override { return false; }
+  };
+  std::string image = CreateJpegImage();
+  NonReadModeWriter asset(
+      &image, riegeli::StringWriterBase::Options().set_append(true));
+  EXPECT_THAT(writer_->Write(CreateRequest(), &asset),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("asset does not support read mode")));
+}
+
+TEST_F(JpegWriterTest, WriteWithHardBindingFails) {
+  GenerateManifestStoreRequest request = CreateRequest();
+  request.mutable_hard_binding();
+  EXPECT_THAT(WriteCopy(*writer_, request, CreateJpegImage()),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("must not include `hard_binding`")));
+}
+
+TEST_F(JpegWriterTest, WriteInPlaceWithHardBindingFails) {
+  GenerateManifestStoreRequest request = CreateRequest();
+  request.mutable_hard_binding();
+  std::string image = CreateJpegImage();
+  const std::string original = image;
+  riegeli::StringWriter<> asset(
+      &image, riegeli::StringWriterBase::Options().set_append(true));
+  EXPECT_THAT(writer_->Write(request, &asset),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("must not include `hard_binding`")));
+  ASSERT_TRUE(asset.Close());
+  EXPECT_EQ(image, original);
+}
+
+TEST_F(JpegWriterTest, WriteWithMultiAssetHash) {
+  GenerateManifestStoreRequest request = CreateRequest();
+  request.mutable_options()->set_generate_multi_asset_hash(true);
+  absl::StatusOr<std::string> result =
+      WriteCopy(*writer_, request, CreateJpegImage());
+  ABSL_ASSERT_OK(result);
+  ExpectValid(*result);
+}
+
+TEST_F(JpegWriterTest, WriteFailsWhenNoInsertionPointFound) {
+  EXPECT_THAT(
+      WriteCopy(*writer_, CreateRequest(),
+                absl::string_view("\xff\xd8\xff\xe0\x00\x02", 6)),
+      StatusIs(absl::StatusCode::kInvalidArgument,
+               HasSubstr("No valid C2PA insertion point found in JPEG")));
+}
+
+TEST_F(JpegWriterTest, WriteFailsWhenManifestStoreAlreadyExists) {
+  absl::StatusOr<std::string> result =
+      WriteCopy(*writer_, CreateRequest(), CreateJpegImage());
+  ABSL_ASSERT_OK(result);
+
+  EXPECT_THAT(WriteCopy(*writer_, CreateRequest(), *result),
+              StatusIs(absl::StatusCode::kAlreadyExists,
+                       HasSubstr("C2PA chunk already exists")));
+}
+
+TEST_F(JpegWriterTest, ModifyingHashedBytesInvalidatesAsset) {
+  absl::StatusOr<std::string> result =
+      WriteCopy(*writer_, CreateRequest(), CreateJpegImage());
+  ABSL_ASSERT_OK(result);
+
+  (*result)[ExifOffset(*result) + kExifPayload.size() - 1] ^= 0xff;
+  absl::StatusOr<ValidationStatusSet> validation = Validate(*result);
+  ABSL_ASSERT_OK(validation);
+  EXPECT_THAT(
+      validation->failures(),
+      Contains(PartiallyEqualsProto(ParseTextProtoOrDie<ValidationStatus>(R"pb(
+        code: "assertion.dataHash.mismatch"
+      )pb"))));
+}
+
+TEST_F(JpegWriterTest, WriteAsync) {
+  std::string image = CreateJpegImage();
+  riegeli::StringReader source(image);
+  std::string result;
+  riegeli::StringWriter destination(&result);
+
+  absl::Notification done;
+  absl::Status write_status;
+  JpegWriter::OutputMetadata output_metadata;
+  writer_->Write(CreateRequest(), &source, &destination, &output_metadata,
+                 AsyncContext(), [&](absl::Status status) {
+                   write_status = std::move(status);
+                   done.Notify();
+                 });
+  done.WaitForNotification();
+  ABSL_ASSERT_OK(write_status);
+  ASSERT_TRUE(destination.Close());
+  EXPECT_TRUE(output_metadata.timestamp_included);
+  ExpectValid(result);
+}
+
+TEST_F(JpegWriterTest, WriteInPlaceAsync) {
+  std::string image = CreateJpegImage();
+  riegeli::StringWriter<> asset(
+      &image, riegeli::StringWriterBase::Options().set_append(true));
+
+  absl::Notification done;
+  absl::Status write_status;
+  writer_->Write(CreateRequest(), &asset, /*output_metadata=*/nullptr,
+                 AsyncContext(), [&](absl::Status status) {
+                   write_status = std::move(status);
+                   done.Notify();
+                 });
+  done.WaitForNotification();
+  ABSL_ASSERT_OK(write_status);
+  ASSERT_TRUE(asset.Close());
+  ExpectValid(image);
+}
+
+TEST_F(JpegWriterTest, WriteAsyncReportsErrorThroughCallback) {
+  GenerateManifestStoreRequest request = CreateRequest();
+  request.mutable_hard_binding();
+  std::string image = CreateJpegImage();
+  riegeli::StringReader source(image);
+  std::string result;
+  riegeli::StringWriter destination(&result);
+
+  absl::Notification done;
+  absl::Status write_status;
+  writer_->Write(request, &source, &destination, /*output_metadata=*/nullptr,
+                 AsyncContext(), [&](absl::Status status) {
+                   write_status = std::move(status);
+                   done.Notify();
+                 });
+  done.WaitForNotification();
+  EXPECT_THAT(write_status, StatusIs(absl::StatusCode::kInvalidArgument));
+}
+
+TEST_F(JpegWriterTest, ExclusionProcessorExcludesBytesFromHardBinding) {
+  std::vector<std::string> visited_segments;
+  JpegWriter writer(
+      generator_.get(),
+      {.exclusion_processor = [&visited_segments](
+                                  riegeli::Reader&, const JpegSegment& segment,
+                                  const GenerateManifestStoreRequest& request)
+           -> absl::StatusOr<std::vector<ByteRange>> {
+        EXPECT_EQ(request.actions().created().action_metadata().description(),
+                  "test only");
+        visited_segments.push_back(segment.marker_label);
+        if (segment.marker_label != "APP1") return std::vector<ByteRange>();
+        return std::vector<ByteRange>{{.offset = segment.payload_offset,
+                                       .length = segment.payload_length}};
+      }});
+
+  absl::StatusOr<std::string> result =
+      WriteCopy(writer, CreateRequest(), CreateJpegImage());
+  ABSL_ASSERT_OK(result);
+  EXPECT_THAT(visited_segments, Contains("APP0"));
+  EXPECT_THAT(visited_segments, Contains("APP1"));
+  EXPECT_THAT(visited_segments, Contains("DQT"));
+  ExpectValid(*result);
+
+  absl::StatusOr<ValidationStatusSet> validation = Validate(*result);
+  ABSL_ASSERT_OK(validation);
+  EXPECT_THAT(
+      validation->informationals(),
+      Contains(PartiallyEqualsProto(ParseTextProtoOrDie<ValidationStatus>(R"pb(
+        code: "assertion.dataHash.additionalExclusionsPresent"
+      )pb"))));
+
+  // Modifying excluded bytes does not invalidate the asset.
+  (*result)[ExifOffset(*result) + kExifPayload.size() - 1] ^= 0xff;
+  ExpectValid(*result);
+}
+
+TEST_F(JpegWriterTest, ExclusionProcessorErrorIsPropagated) {
+  JpegWriter writer(
+      generator_.get(),
+      {.exclusion_processor = [](riegeli::Reader&, const JpegSegment&,
+                                 const GenerateManifestStoreRequest&)
+           -> absl::StatusOr<std::vector<ByteRange>> {
+        return absl::DataLossError("exclusion failure");
+      }});
+
+  EXPECT_THAT(WriteCopy(writer, CreateRequest(), CreateJpegImage()),
+              StatusIs(absl::StatusCode::kDataLoss, "exclusion failure"));
+}
+
+TEST_F(JpegWriterTest, ExclusionProcessorRangeOutsideSegmentFails) {
+  JpegWriter writer(
+      generator_.get(),
+      {.exclusion_processor = [](riegeli::Reader&, const JpegSegment& segment,
+                                 const GenerateManifestStoreRequest&)
+           -> absl::StatusOr<std::vector<ByteRange>> {
+        return std::vector<ByteRange>{
+            {.offset = segment.offset, .length = segment.length + 1}};
+      }});
+
+  EXPECT_THAT(WriteCopy(writer, CreateRequest(), CreateJpegImage()),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("Invalid or overlapping exclusion range")));
+}
+
+TEST_F(JpegWriterTest, GeneratorErrorIsPropagated) {
+  FakeManifestStoreGenerator generator;
+  generator.set_status(absl::UnavailableError("generator failure"));
+  JpegWriter writer(&generator);
+
+  EXPECT_THAT(WriteCopy(writer, CreateRequest(), CreateJpegImage()),
+              StatusIs(absl::StatusCode::kUnavailable, "generator failure"));
+}
+
+TEST_F(JpegWriterTest, InPlaceGeneratorErrorLeavesAssetUnchanged) {
+  FakeManifestStoreGenerator generator;
+  generator.set_status(absl::UnavailableError("generator failure"));
+  JpegWriter writer(&generator);
+  std::string image = CreateJpegImage();
+  const std::string original = image;
+  riegeli::StringWriter<> asset(
+      &image, riegeli::StringWriterBase::Options().set_append(true));
+
+  EXPECT_THAT(writer.Write(CreateRequest(), &asset),
+              StatusIs(absl::StatusCode::kUnavailable, "generator failure"));
+  ASSERT_TRUE(asset.Close());
+  EXPECT_EQ(image, original);
+}
+
+TEST_F(JpegWriterTest, WriteFailsWithEmptyManifestStoreTemplate) {
+  FakeManifestStoreGenerator generator;
+  generator.mutable_manifest_store_template()->set_manifest_store_size(0);
+  JpegWriter writer(&generator);
+
+  EXPECT_THAT(WriteCopy(writer, CreateRequest(), CreateJpegImage()),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("invalid manifest store template size: 0")));
+}
+
+TEST_F(JpegWriterTest, WriteFailsWithTooLargeManifestStoreTemplate) {
+  FakeManifestStoreGenerator generator;
+  generator.mutable_manifest_store_template()->set_manifest_store_size(
+      100 * 1024 * 1024 + 1);
+  JpegWriter writer(&generator);
+
+  EXPECT_THAT(WriteCopy(writer, CreateRequest(), CreateJpegImage()),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("invalid manifest store template size")));
+}
+
+TEST_F(JpegWriterTest, OutputMetadataReflectsFinalizeResponse) {
+  FakeManifestStoreGenerator generator(CreateFakeManifestStore());
+  JpegWriter writer(&generator);
+
+  JpegWriter::OutputMetadata output_metadata;
+  generator.mutable_finalize_manifest_store_response()->set_is_timestamped(
+      true);
+  ABSL_ASSERT_OK(
+      WriteCopy(writer, CreateRequest(), CreateJpegImage(), &output_metadata));
+  EXPECT_TRUE(output_metadata.timestamp_included);
+
+  output_metadata.timestamp_included = true;
+  generator.mutable_finalize_manifest_store_response()->set_is_timestamped(
+      false);
+  ABSL_ASSERT_OK(
+      WriteCopy(writer, CreateRequest(), CreateJpegImage(), &output_metadata));
+  EXPECT_FALSE(output_metadata.timestamp_included);
+}
+
+TEST_F(JpegWriterTest, LargeManifestStoreIsSplitAcrossApp11Segments) {
+  // A manifest store larger than a single APP11 segment (64 KiB).
+  FakeManifestStoreGenerator generator(CreateFakeManifestStore(200 * 1024));
+  JpegWriter writer(&generator);
+
+  absl::StatusOr<std::string> result =
+      WriteCopy(writer, CreateRequest(), CreateJpegImage());
+  ABSL_ASSERT_OK(result);
+
+  std::vector<size_t> app11_offsets;
+  for (size_t pos = result->find("\xff\xeb"); pos != std::string::npos;
+       pos = result->find("\xff\xeb", pos + 1)) {
+    app11_offsets.push_back(pos);
+  }
+  EXPECT_THAT(app11_offsets, SizeIs(Gt(3)));
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/formats/pdf/BUILD b/formats/pdf/BUILD
index df588d5..63e44de 100644
--- a/formats/pdf/BUILD
+++ b/formats/pdf/BUILD
@@ -91,6 +91,7 @@
         "@abseil-cpp//absl/container:flat_hash_map",
         "@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",
@@ -113,6 +114,7 @@
     hdrs = ["extractor.h"],
     deps = [
         ":constants",
+        ":manifest_store_merger",
         ":objects",
         ":reader",
         "//constants:labels",
@@ -135,9 +137,19 @@
     srcs = ["extractor_test.cc"],
     deps = [
         ":extractor",
+        "//constants:labels",
         "//formats:byte_range",
+        "//jumbf:box",
+        "//jumbf:box_builder",
+        "//jumbf:constants",
+        "//jumbf:encode_params",
+        "//jumbf:parse",
+        "@abseil-cpp//absl/log:absl_check",
         "@abseil-cpp//absl/status",
         "@abseil-cpp//absl/status:status_matchers",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/strings:cord",
         "@abseil-cpp//absl/strings:string_view",
         "@googletest//:gtest_main",
         "@riegeli//riegeli/bytes:string_reader",
@@ -182,3 +194,196 @@
         "@abseil-cpp//absl/status:statusor",
     ],
 )
+
+cc_library(
+    name = "manifest_store_merger",
+    srcs = ["manifest_store_merger.cc"],
+    hdrs = ["manifest_store_merger.h"],
+    deps = [
+        "//constants:labels",
+        "//jumbf:box",
+        "//jumbf:box_builder",
+        "//jumbf:encode_params",
+        "//jumbf:parse",
+        "@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",
+        "@abseil-cpp//absl/types:span",
+    ],
+)
+
+cc_test(
+    name = "manifest_store_merger_test",
+    srcs = ["manifest_store_merger_test.cc"],
+    deps = [
+        ":manifest_store_merger",
+        "//constants:labels",
+        "//jumbf:box",
+        "//jumbf:box_builder",
+        "//jumbf:constants",
+        "//jumbf:encode_params",
+        "//jumbf:parse",
+        "//uuid",
+        "@abseil-cpp//absl/log:absl_check",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings:cord",
+        "@abseil-cpp//absl/strings:string_view",
+        "@googletest//:gtest_main",
+    ],
+)
+
+cc_library(
+    name = "number_validator",
+    srcs = ["number_validator.cc"],
+    hdrs = ["number_validator.h"],
+    deps = [
+        "@abseil-cpp//absl/strings:string_view",
+    ],
+)
+
+cc_test(
+    name = "number_validator_test",
+    srcs = ["number_validator_test.cc"],
+    deps = [
+        ":number_validator",
+        "@googletest//:gtest_main",
+    ],
+)
+
+cc_library(
+    name = "object_writer",
+    srcs = ["object_writer.cc"],
+    hdrs = ["object_writer.h"],
+    deps = [
+        ":number_validator",
+        ":objects",
+        "@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",
+        "@riegeli//riegeli/bytes:string_writer",
+        "@riegeli//riegeli/bytes:writer",
+    ],
+)
+
+cc_test(
+    name = "object_writer_test",
+    srcs = ["object_writer_test.cc"],
+    deps = [
+        ":object_writer",
+        ":objects",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@googletest//:gtest_main",
+        "@riegeli//riegeli/bytes:string_writer",
+        "@riegeli//riegeli/bytes:writer",
+    ],
+)
+
+cc_library(
+    name = "embedder",
+    srcs = ["embedder.cc"],
+    hdrs = ["embedder.h"],
+    deps = [
+        ":constants",
+        ":extractor",
+        ":object_writer",
+        ":objects",
+        ":reader",
+        "@abseil-cpp//absl/algorithm:container",
+        "@abseil-cpp//absl/container:btree",
+        "@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/strings:str_format",
+        "@abseil-cpp//absl/strings:string_view",
+        "@abseil-cpp//absl/time",
+        "@abseil-cpp//absl/types:span",
+        "@riegeli//riegeli/bytes:reader",
+        "@riegeli//riegeli/bytes:writer",
+    ],
+)
+
+cc_test(
+    name = "embedder_test",
+    srcs = ["embedder_test.cc"],
+    deps = [
+        ":constants",
+        ":embedder",
+        ":extractor",
+        ":objects",
+        ":reader",
+        "//crypto:algorithms",
+        "//proto:data_hash_assertion_cc_proto",
+        "//proto:generator_cc_proto",
+        "//testing:nonprod_generator",
+        "@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:cord",
+        "@abseil-cpp//absl/strings:string_view",
+        "@googletest//:gtest_main",
+        "@riegeli//riegeli/bytes:string_reader",
+        "@riegeli//riegeli/bytes:string_writer",
+    ],
+)
+
+cc_library(
+    name = "writer",
+    srcs = ["writer.cc"],
+    hdrs = ["writer.h"],
+    deps = [
+        ":embedder",
+        "//crypto:algorithms",
+        "//formats:hasher_data",
+        "//generator",
+        "//proto:generator_cc_proto",
+        "@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:cord",
+        "@abseil-cpp//absl/strings:string_view",
+        "@riegeli//riegeli/bytes:cord_reader",
+        "@riegeli//riegeli/bytes:cord_writer",
+        "@riegeli//riegeli/bytes:reader",
+        "@riegeli//riegeli/bytes:writer",
+    ],
+)
+
+cc_test(
+    name = "writer_test",
+    srcs = ["writer_test.cc"],
+    deps = [
+        ":extractor",
+        ":writer",
+        "//formats:byte_range",
+        "//generator",
+        "//proto:generator_cc_proto",
+        "//proto:manifest_cc_proto",
+        "//proto:validation_status_cc_proto",
+        "//testing:fake_manifest_store_generator",
+        "//testing:nonprod_generator",
+        "//testing:nonprod_validator",
+        "//validator:asset_validator",
+        "@abseil-cpp//absl/log:absl_check",
+        "@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",
+    ],
+)
diff --git a/formats/pdf/embedder.cc b/formats/pdf/embedder.cc
new file mode 100644
index 0000000..d7f704a
--- /dev/null
+++ b/formats/pdf/embedder.cc
@@ -0,0 +1,554 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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/embedder.h"
+
+#include <cstdint>
+#include <iterator>
+#include <optional>
+#include <string>
+#include <tuple>
+#include <utility>
+#include <variant>
+#include <vector>
+
+#include "absl/algorithm/container.h"
+#include "absl/container/btree_set.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_format.h"
+#include "absl/strings/string_view.h"
+#include "absl/time/clock.h"
+#include "absl/time/time.h"
+#include "absl/types/span.h"
+#include "formats/pdf/constants.h"
+#include "formats/pdf/extractor.h"
+#include "formats/pdf/object_writer.h"
+#include "formats/pdf/objects.h"
+#include "formats/pdf/reader.h"
+#include "riegeli/bytes/reader.h"
+#include "riegeli/bytes/writer.h"
+
+namespace credentio {
+namespace {
+
+using Dictionary = Object::Dictionary;
+
+constexpr absl::string_view kPdfHeader = "%PDF-1.7\n%\x90\x91\xC2\x9A\n";
+
+struct XrefTableEntry {
+  uint32_t object_number;
+  uint32_t generation_number;
+
+  int64_t offset;
+  bool in_use;
+
+  friend bool operator<(const XrefTableEntry& lhs, const XrefTableEntry& rhs) {
+    return std::tie(lhs.object_number, lhs.generation_number, lhs.offset,
+                    lhs.in_use) < std::tie(rhs.object_number,
+                                           rhs.generation_number, rhs.offset,
+                                           rhs.in_use);
+  }
+};
+
+template <typename T>
+absl::StatusOr<T> DereferenceObject(const Object::IndirectReference& ref,
+                                    PdfReader& reader) {
+  ABSL_ASSIGN_OR_RETURN(
+      const auto& indirect_obj,
+      reader.GetObject(ref.object_number, ref.generation_number));
+  return Object::As<T>(indirect_obj.object);
+}
+
+// Using the basic mechanism to form a linked list from the free entries.
+// See ISO 32000-2 Section 7.5.4 for more details.
+std::vector<XrefTableEntry> ReconstructXrefTableFreeEntries(
+    const absl::btree_set<Object::IndirectReference>& free_entries) {
+  if (free_entries.empty()) {
+    return {};
+  }
+  std::vector<XrefTableEntry> xref_table_entries;
+  xref_table_entries.reserve(free_entries.size());
+
+  for (const auto& free_entry : free_entries) {
+    xref_table_entries.push_back(
+        XrefTableEntry{.object_number = free_entry.object_number,
+                       .generation_number = free_entry.generation_number,
+                       .offset = 0,
+                       .in_use = false});
+  }
+
+  // Set the offset of each free entry to the object number of the next free
+  // entry.
+  for (int i = 0; i < xref_table_entries.size() - 1; ++i) {
+    xref_table_entries[i].offset = xref_table_entries[i + 1].object_number;
+  }
+
+  // Signify the end of the list by having an offset value of 0.
+  xref_table_entries.back().offset = 0;
+
+  return xref_table_entries;
+}
+
+// Returns true if the object numbers in the given vector are consecutive,
+// starting from 0. Assumes the vector is already sorted by object number.
+bool AreObjectNumbersConsecutive(
+    absl::Span<const XrefTableEntry> xref_table_entries) {
+  for (auto i = 0; i < xref_table_entries.size(); i++) {
+    if (xref_table_entries[i].object_number != i) {
+      return false;
+    }
+  }
+  return true;
+}
+
+absl::Status WritePdfHeader(riegeli::Writer& output) {
+  if (!output.Write(kPdfHeader)) {
+    return output.status();
+  }
+  return absl::OkStatus();
+}
+
+// See ISO 32000-2 Section 7.5.4 for the cross-reference table structure.
+absl::Status WriteXrefTable(std::vector<XrefTableEntry>& xref_table_entries,
+                            riegeli::Writer& output) {
+  // Write the xref header.
+  if (!output.Write("xref\n")) {
+    return output.status();
+  }
+
+  absl::c_sort(xref_table_entries);
+  if (!AreObjectNumbersConsecutive(xref_table_entries)) {
+    return absl::UnimplementedError(
+        "Non-consecutive object numbers in the xref table are not supported.");
+  }
+
+  // Write the object count section.
+  if (!output.Write(absl::StrCat(0, " ", xref_table_entries.size(), "\n"))) {
+    return output.status();
+  }
+
+  // Write the xref table entries.
+  for (const auto& entry : xref_table_entries) {
+    if (!output.Write(absl::StrFormat("%010d %05d %c \n", entry.offset,
+                                      entry.generation_number,
+                                      entry.in_use ? 'n' : 'f'))) {
+      return output.status();
+    }
+  }
+  return absl::OkStatus();
+}
+
+// See ISO 32000-2 Section 7.5.5 for the trailer structure.
+absl::Status WriteTrailer(const Dictionary& trailer_dict,
+                          int64_t xref_table_offset, riegeli::Writer& output) {
+  // Write the trailer header.
+  if (!output.Write("trailer\n")) {
+    return output.status();
+  }
+
+  // Write the trailer dictionary object.
+  Object obj = Object{.value = trailer_dict};
+  ABSL_RETURN_IF_ERROR(WriteObject(obj, output));
+
+  // Write the trailer footer and the offset to the last cross-reference table.
+  if (!output.Write(
+          absl::StrCat("\nstartxref\n", xref_table_offset, "\n%%EOF"))) {
+    return output.status();
+  }
+
+  return absl::OkStatus();
+}
+
+absl::Status CopyObjects(
+    PdfReader& reader,
+    absl::Span<const std::optional<Object::IndirectReference>> objects_to_skip,
+    int64_t start_offset, std::vector<XrefTableEntry>& xref_table_entries,
+    riegeli::Writer& output) {
+  while (reader.HasNext()) {
+    ABSL_ASSIGN_OR_RETURN(auto obj, reader.Next());
+
+    bool should_skip = absl::c_any_of(
+        objects_to_skip,
+        [&](const std::optional<Object::IndirectReference>& skip_ref) {
+          return skip_ref.has_value() &&
+                 obj.object_number == skip_ref->object_number &&
+                 obj.generation_number == skip_ref->generation_number;
+        });
+
+    if (should_skip) continue;
+    int64_t obj_offset = output.pos();
+    ABSL_RETURN_IF_ERROR(WriteIndirectObject(obj, output));
+    xref_table_entries.push_back(
+        XrefTableEntry{.object_number = obj.object_number,
+                       .generation_number = obj.generation_number,
+                       .offset = obj_offset - start_offset,
+                       .in_use = true});
+  }
+  return absl::OkStatus();
+}
+
+// Creates an indirect stream Object with a zero-filled manifest store.
+IndirectObject BuildC2paManifestTemplate(uint32_t object_number,
+                                         uint32_t generation_number,
+                                         int64_t manifest_store_size) {
+  IndirectObject store_template;
+  store_template.object_number = object_number;
+  store_template.generation_number = generation_number;
+
+  std::string formatted_date = absl::FormatTime(
+      "D:%Y%m%d%H%M%S+00'00'", absl::Now(), absl::UTCTimeZone());
+  Dictionary params_dict{
+      .entries = {
+          {std::string(kPdfSizeKey),
+           Object{.value = Object::Integer{.value = manifest_store_size}}},
+          {std::string(kPdfModDateKey),
+           Object{.value = Object::LiteralString{.raw_value = formatted_date}}},
+      }};
+
+  store_template.stream_dictionary = Dictionary{
+      .entries = {
+          {"Length",
+           Object{.value = Object::Integer{.value = manifest_store_size}}},
+          {std::string(kPdfSubtypeKey),
+           Object{.value = Object::Name{.value = std::string(
+                                            kPdfC2paManifestMimeType)}}},
+          {std::string(kPdfParamsKey), Object{.value = std::move(params_dict)}},
+      }};
+  store_template.object =
+      Object{.value = Object::Stream{
+                 .raw_value = std::string(manifest_store_size, '\0')}};
+  return store_template;
+}
+
+IndirectObject BuildC2paManifestFileSpec(
+    uint32_t object_number, uint32_t generation_number,
+    const IndirectObject& c2pa_manifest_store_obj) {
+  IndirectObject file_spec;
+  file_spec.object_number = object_number;
+  file_spec.generation_number = generation_number;
+  file_spec.object = Object{
+      .value = Dictionary{
+          .entries = {
+              {std::string(kPdfTypeKey),
+               Object{.value = Object::Name{.value = std::string(
+                                                kPdfFileSpecType)}}},
+              {std::string(kPdfAfRelationshipKey),
+               Object{.value =
+                          Object::Name{.value = std::string(
+                                           kPdfC2paManifestAfRelationship)}}},
+              {std::string(kPdfFKey),
+               Object{.value =
+                          Object::LiteralString{
+                              .raw_value =
+                                  std::string(kPdfC2paEmbeddedFileName)}}},
+              {std::string(kPdfUfKey),
+               Object{.value =
+                          Object::LiteralString{
+                              .raw_value =
+                                  std::string(kPdfC2paEmbeddedFileName)}}},
+              {std::string(kPdfEfKey),
+               Object{
+                   .value = Dictionary{
+                       .entries = {
+                           {std::string(kPdfFKey),
+                            Object{.value = Object::IndirectReference{
+                                       .object_number = c2pa_manifest_store_obj
+                                                            .object_number,
+                                       .generation_number =
+                                           c2pa_manifest_store_obj
+                                               .generation_number}}}}}}}}}};
+  return file_spec;
+}
+
+absl::Status InsertNameTreePair(absl::string_view key,
+                                const Object::IndirectReference& value,
+                                Object::Array& name_tree_array) {
+  if (name_tree_array.objects.size() % 2 != 0) {
+    return absl::InternalError(
+        "Name tree array must contain an even number of objects.");
+  }
+
+  // Find the correct position to insert the new key-value pair to maintain
+  // lexicographical order of keys. The `name_tree_array` stores keys and
+  // values interleaved, e.g., [key1, value1, key2, value2, ...].
+  auto insertion_it = name_tree_array.objects.begin();
+  while (insertion_it != name_tree_array.objects.end()) {
+    // We only need to check the keys, which are at even indices.
+    ABSL_ASSIGN_OR_RETURN(const auto& current_key,
+                          Object::As<Object::LiteralString>(*insertion_it));
+    if (key < current_key.raw_value) {
+      // Found the position. The new key should be inserted before this one.
+      break;
+    }
+    // Advance the iterator to the next key.
+    insertion_it += 2;
+  }
+
+  name_tree_array.objects.insert(
+      insertion_it,
+      {Object{.value = Object::LiteralString{.raw_value = std::string(key)}},
+       Object{.value = value}});
+
+  return absl::OkStatus();
+}
+
+}  // namespace
+
+absl::StatusOr<int64_t> PdfEmbedder::RewriteForEmbedding(
+    riegeli::Reader& input, int64_t estimated_manifest_store_size,
+    riegeli::Writer& output) const {
+  ABSL_ASSIGN_OR_RETURN(auto reader, PdfReader::Create(&input));
+
+  if (auto manifest_store = PdfExtractor().ExtractManifestStore(input);
+      manifest_store.ok()) {
+    return absl::AlreadyExistsError(
+        "Rewriting failed because a C2PA manifest store already exists ");
+  }
+
+  // The start offset of the output stream.
+  int64_t start_offset = output.pos();
+
+  // Step 1:
+  // Locate and identify objects that need to be updated/created for C2PA
+  // embedding.
+  auto file_trailer_dict = reader->file_trailer_dictionary();
+  ABSL_ASSIGN_OR_RETURN(
+      auto object_count_obj,
+      FindObjectInDictionary<Object::Integer>(file_trailer_dict, kPdfSizeKey));
+  uint32_t next_available_object_number = object_count_obj.value;
+
+  // /Root
+  ABSL_ASSIGN_OR_RETURN(auto doc_catalog_ref,
+                        FindObjectInDictionary<Object::IndirectReference>(
+                            file_trailer_dict, kPdfRootKey));
+  ABSL_ASSIGN_OR_RETURN(auto doc_catalog_dict, DereferenceObject<Dictionary>(
+                                                   doc_catalog_ref, *reader));
+
+  // /Root/Names
+  std::optional<Dictionary> catalog_names = std::nullopt;
+  std::optional<Object::IndirectReference> catalog_names_ref = std::nullopt;
+  if (auto it = doc_catalog_dict.entries.find(kPdfNamesKey);
+      it != doc_catalog_dict.entries.end()) {
+    if (std::holds_alternative<Dictionary>(it->second.value)) {
+      // If we find the catalog names dictionary to be a direct object, move it
+      // into an indirect object then update the document catalog dictionary to
+      // point to it.
+      catalog_names = std::move(std::get<Dictionary>(it->second.value));
+      catalog_names_ref = Object::IndirectReference{
+          .object_number = next_available_object_number++,
+          .generation_number = 0};
+      doc_catalog_dict.entries[kPdfNamesKey] =
+          Object{.value = *catalog_names_ref};
+    } else {
+      ABSL_ASSIGN_OR_RETURN(catalog_names_ref,
+                            Object::As<Object::IndirectReference>(it->second));
+      ABSL_ASSIGN_OR_RETURN(
+          catalog_names,
+          DereferenceObject<Dictionary>(catalog_names_ref.value(), *reader));
+    }
+  }
+
+  // /Root/Names/EmbeddedFiles
+  std::optional<Dictionary> name_tree = std::nullopt;
+  std::optional<Object::IndirectReference> name_tree_ref = std::nullopt;
+  if (catalog_names.has_value()) {
+    if (auto it = catalog_names->entries.find(kPdfEmbeddedFilesKey);
+        it != catalog_names->entries.end()) {
+      ABSL_ASSIGN_OR_RETURN(name_tree_ref,
+                            Object::As<Object::IndirectReference>(it->second));
+      ABSL_ASSIGN_OR_RETURN(name_tree, DereferenceObject<Dictionary>(
+                                           name_tree_ref.value(), *reader));
+    }
+  }
+
+  // Step 2: Write the header.
+  ABSL_RETURN_IF_ERROR(WritePdfHeader(output));
+
+  // Step 3: Copy the objects that can be written as-is from the input stream to
+  // the output stream.
+
+  // A list of xref table entries that will be written to the output stream,
+  // including both in-use and free entries. In-use entry's offset is relative
+  // to `start_offset`.
+  std::vector<XrefTableEntry> xref_table_entries;
+  ABSL_RETURN_IF_ERROR(CopyObjects(
+      *reader,
+      /*objects_to_skip=*/{doc_catalog_ref, catalog_names_ref, name_tree_ref},
+      start_offset, xref_table_entries, output));
+
+  // Step 4.1: Create the embedded file stream object that holds the C2PA
+  // manifest store.
+  IndirectObject c2pa_manifest_store_obj = BuildC2paManifestTemplate(
+      /*object_number=*/next_available_object_number++,
+      /*generation_number=*/0, estimated_manifest_store_size);
+
+  int64_t c2pa_manifest_store_offset = output.pos();
+  ABSL_RETURN_IF_ERROR(WriteIndirectObject(c2pa_manifest_store_obj, output));
+  xref_table_entries.push_back(XrefTableEntry{
+      .object_number = c2pa_manifest_store_obj.object_number,
+      .generation_number = c2pa_manifest_store_obj.generation_number,
+      .offset = c2pa_manifest_store_offset - start_offset,
+      .in_use = true});
+
+  // Step 4.2: Create a file specification object for the C2PA manifest
+  // store.
+  IndirectObject c2pa_manifest_store_file_spec = BuildC2paManifestFileSpec(
+      /*object_number=*/next_available_object_number++,
+      /*generation_number=*/0, c2pa_manifest_store_obj);
+
+  int64_t c2pa_manifest_store_file_spec_offset = output.pos();
+  ABSL_RETURN_IF_ERROR(
+      WriteIndirectObject(c2pa_manifest_store_file_spec, output));
+  xref_table_entries.push_back(XrefTableEntry{
+      .object_number = c2pa_manifest_store_file_spec.object_number,
+      .generation_number = c2pa_manifest_store_file_spec.generation_number,
+      .offset = c2pa_manifest_store_file_spec_offset - start_offset,
+      .in_use = true});
+
+  // Step 4.3: Add/Update the embedded files name tree.
+  if (!name_tree.has_value()) {
+    // Since we don't find an existing name tree in the input PDF, we can safely
+    // create a new one.
+    name_tree = Dictionary{};
+    name_tree_ref = Object::IndirectReference{
+        .object_number = next_available_object_number++,
+        .generation_number = 0};
+  }
+  auto name_tree_it = name_tree->entries.find(kPdfNamesKey);
+  if (name_tree_it == name_tree->entries.end() ||
+      !std::holds_alternative<Object::Array>(name_tree_it->second.value)) {
+    name_tree_it =
+        name_tree->entries
+            .insert_or_assign(kPdfNamesKey, Object{.value = Object::Array{}})
+            .first;
+  }
+  Object::Array& name_tree_array =
+      std::get<Object::Array>(name_tree_it->second.value);
+  ABSL_RETURN_IF_ERROR(InsertNameTreePair(
+      kPdfC2paEmbeddedFileName,
+      Object::IndirectReference{
+          .object_number = c2pa_manifest_store_file_spec.object_number,
+          .generation_number = c2pa_manifest_store_file_spec.generation_number},
+      name_tree_array));
+
+  int64_t name_tree_offset = output.pos();
+  ABSL_RETURN_IF_ERROR(WriteIndirectObject(
+      IndirectObject{.object_number = name_tree_ref->object_number,
+                     .generation_number = name_tree_ref->generation_number,
+                     .object = Object{.value = *name_tree}},
+      output));
+  xref_table_entries.push_back(
+      XrefTableEntry{.object_number = name_tree_ref->object_number,
+                     .generation_number = name_tree_ref->generation_number,
+                     .offset = name_tree_offset - start_offset,
+                     .in_use = true});
+
+  // Step 4.4: Add/Update the catalog names dictionary.
+  if (!catalog_names.has_value()) {
+    // Since we don't find an existing catalog names dictionary in the input
+    // PDF, we can safely create a new one.
+    catalog_names = Dictionary{};
+    catalog_names_ref = Object::IndirectReference{
+        .object_number = next_available_object_number++,
+        .generation_number = 0};
+    doc_catalog_dict.entries[kPdfNamesKey] =
+        Object{.value = *catalog_names_ref};
+  }
+  if (!catalog_names->entries.contains(kPdfEmbeddedFilesKey)) {
+    catalog_names->entries[kPdfEmbeddedFilesKey] =
+        Object{.value = *name_tree_ref};
+  }
+
+  int64_t catalog_names_offset = output.pos();
+  ABSL_RETURN_IF_ERROR(WriteIndirectObject(
+      IndirectObject{.object_number = catalog_names_ref->object_number,
+                     .generation_number = catalog_names_ref->generation_number,
+                     .object = Object{.value = *catalog_names}},
+      output));
+  xref_table_entries.push_back(
+      XrefTableEntry{.object_number = catalog_names_ref->object_number,
+                     .generation_number = catalog_names_ref->generation_number,
+                     .offset = catalog_names_offset - start_offset,
+                     .in_use = true});
+
+  // Step 4.5: Update the document catalog dictionary.
+  auto af_it = doc_catalog_dict.entries.find(kPdfAfKey);
+  if (af_it == doc_catalog_dict.entries.end() ||
+      !std::holds_alternative<Object::Array>(af_it->second.value)) {
+    af_it = doc_catalog_dict.entries
+                .insert_or_assign(kPdfAfKey, Object{.value = Object::Array{}})
+                .first;
+  }
+  std::get<Object::Array>(af_it->second.value)
+      .objects.push_back(Object{
+          .value = Object::IndirectReference{
+              .object_number = c2pa_manifest_store_file_spec.object_number,
+              .generation_number =
+                  c2pa_manifest_store_file_spec.generation_number}});
+  int64_t doc_catalog_dict_offset = output.pos();
+  ABSL_RETURN_IF_ERROR(WriteIndirectObject(
+      IndirectObject{.object_number = doc_catalog_ref.object_number,
+                     .generation_number = doc_catalog_ref.generation_number,
+                     .object = Object{.value = doc_catalog_dict}},
+      output));
+  xref_table_entries.push_back(
+      XrefTableEntry{.object_number = doc_catalog_ref.object_number,
+                     .generation_number = doc_catalog_ref.generation_number,
+                     .offset = doc_catalog_dict_offset - start_offset,
+                     .in_use = true});
+
+  // Step 5: Compute and write the xref table.
+  int64_t xref_table_offset = output.pos() - start_offset;
+
+  // Add all existing free entries to the xref table.
+  auto xref_table_free_entries =
+      ReconstructXrefTableFreeEntries(reader->free_entries());
+  xref_table_entries.insert(
+      xref_table_entries.end(),
+      std::make_move_iterator(xref_table_free_entries.begin()),
+      std::make_move_iterator(xref_table_free_entries.end()));
+
+  ABSL_RETURN_IF_ERROR(WriteXrefTable(xref_table_entries, output));
+
+  // Step 6: Write the trailer section, including the trailer dictionary and
+  // startxref pointer.
+
+  // Update the object count in the trailer dictionary.
+  file_trailer_dict.entries[kPdfSizeKey] =
+      Object{.value = Object::Integer{
+                 .value = static_cast<int64_t>(xref_table_entries.size())}};
+  ABSL_RETURN_IF_ERROR(
+      WriteTrailer(file_trailer_dict, xref_table_offset, output));
+
+  ABSL_ASSIGN_OR_RETURN(int64_t header_len, ComputeIndirectStreamHeaderByteSize(
+                                                c2pa_manifest_store_obj));
+  return c2pa_manifest_store_offset + header_len;
+}
+
+absl::Status PdfEmbedder::Embed(absl::string_view manifest_store,
+                                int64_t insert_at_offset,
+                                absl::Cord& embeddable_asset) const {
+  absl::Cord result = embeddable_asset.Subcord(0, insert_at_offset);
+  result.Append(manifest_store);
+  result.Append(embeddable_asset.Subcord(
+      insert_at_offset + manifest_store.size(), embeddable_asset.size()));
+  embeddable_asset = std::move(result);
+  return absl::OkStatus();
+}
+}  // namespace credentio
diff --git a/formats/pdf/embedder.h b/formats/pdf/embedder.h
new file mode 100644
index 0000000..079fbfe
--- /dev/null
+++ b/formats/pdf/embedder.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_PDF_EMBEDDER_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_PDF_EMBEDDER_H_
+
+#include <cstdint>
+
+#include "absl/status/status.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/cord.h"
+#include "absl/strings/string_view.h"
+#include "riegeli/bytes/reader.h"
+#include "riegeli/bytes/writer.h"
+
+namespace credentio {
+
+// A utility class for embedding a C2PA manifest store as an embedded file
+// stream in a non-C2PA PDF file.
+class PdfEmbedder {
+ public:
+  explicit PdfEmbedder() = default;
+
+  // Rewrites a PDF file to be C2PA-embeddable, making necessary updates while
+  // preserving the original structure. Returns the offset of the zero-filled
+  // manifest store in the output. Fails if the input PDF already contains a
+  // C2PA manifest store or if rewriting fails.
+  absl::StatusOr<int64_t> RewriteForEmbedding(
+      riegeli::Reader& input, int64_t estimated_manifest_store_size,
+      riegeli::Writer& output) const;
+
+  // Embeds the C2PA manifest store at the insertion point.
+  absl::Status Embed(absl::string_view manifest_store, int64_t insert_at_offset,
+                     absl::Cord& embeddable_asset) const;
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_PDF_EMBEDDER_H_
diff --git a/formats/pdf/embedder_test.cc b/formats/pdf/embedder_test.cc
new file mode 100644
index 0000000..0315081
--- /dev/null
+++ b/formats/pdf/embedder_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 "formats/pdf/embedder.h"
+
+#include <cstdint>
+#include <string>
+
+#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/cord.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/string_view.h"
+#include "crypto/algorithms.h"
+#include "formats/pdf/constants.h"
+#include "formats/pdf/extractor.h"
+#include "formats/pdf/objects.h"
+#include "formats/pdf/reader.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "proto/data_hash_assertion.pb.h"
+#include "proto/generator.pb.h"
+#include "riegeli/bytes/string_reader.h"
+#include "riegeli/bytes/string_writer.h"
+#include "testing/nonprod_generator.h"
+
+namespace credentio {
+namespace {
+
+using ::absl_testing::IsOk;
+using ::absl_testing::IsOkAndHolds;
+using ::absl_testing::StatusIs;
+using ::testing::ElementsAre;
+using ::testing::HasSubstr;
+
+constexpr absl::string_view kTestPdfContent = 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 4>>
+startxref
+98
+%%EOF
+)";
+
+constexpr absl::string_view kTestPdfContentWithDirectNamesDict = R"(%PDF-1.7
+%%%%%
+1 0 obj
+/test_name
+endobj
+2 0 obj
+123.456
+endobj
+3 0 obj
+<</Names <<>> /Type /Catalog>>
+endobj
+xref
+0 4
+0000000000 65535 f 
+0000000015 00000 n 
+0000000041 00000 n 
+0000000064 00000 n 
+trailer
+<</Root 3 0 R /Size 4>>
+startxref
+110
+%%EOF
+)";
+
+constexpr absl::string_view kTestPdfContentWithFreeObjectEntries = R"(%PDF-1.7
+%%%%%
+1 0 obj
+/test_name
+endobj
+3 0 obj
+123.456
+endobj
+5 0 obj
+<</Type /Catalog>>
+endobj
+xref
+0 6
+0000000002 65535 f 
+0000000015 00000 n 
+0000000004 00000 f 
+0000000041 00000 n 
+0000000000 00000 f 
+0000000064 00000 n 
+trailer
+<</Root 5 0 R /Size 6>>
+startxref
+98
+%%EOF
+)";
+
+absl::StatusOr<std::string> CreateManifestStore() {
+  ABSL_ASSIGN_OR_RETURN(auto generator,
+                        CreateNonProdManifestStoreGenerator({}));
+  GenerateManifestStoreRequest request;
+  DataHashAssertion* data_hash =
+      request.mutable_hard_binding()->mutable_data_hash();
+  data_hash->set_alg(absl::StrCat(HashAlgorithm::kSha256));
+  data_hash->set_hash("data_hash");
+  request.mutable_actions()->mutable_created();
+  ABSL_ASSIGN_OR_RETURN(auto response,
+                        generator->GenerateManifestStore(request));
+  return std::string(response.manifest_store());
+}
+
+TEST(PdfRewriterTest, RewriteForEmbedding) {
+  riegeli::StringReader<> input(kTestPdfContent);
+  std::string output_s;
+  riegeli::StringWriter<> output(&output_s);
+  EXPECT_THAT(PdfEmbedder().RewriteForEmbedding(
+                  input, /*estimated_manifest_store_size=*/100, output),
+              IsOk());
+  ASSERT_TRUE(output.Close());
+
+  riegeli::StringReader<> output_reader(output_s);
+  EXPECT_THAT(PdfExtractor().ExtractManifestStore(output_reader),
+              IsOkAndHolds(std::string(100, '\0')));
+}
+
+TEST(PdfRewriterTest, RewriteForEmbeddingMovesDirectNamesDict) {
+  riegeli::StringReader<> input(kTestPdfContentWithDirectNamesDict);
+  std::string output_s;
+  riegeli::StringWriter<> output(&output_s);
+  EXPECT_THAT(PdfEmbedder().RewriteForEmbedding(
+                  input, /*estimated_manifest_store_size=*/100, output),
+              IsOk());
+  ASSERT_TRUE(output.Close());
+
+  riegeli::StringReader<> output_reader(output_s);
+  auto reader = PdfReader::Create(&output_reader);
+  ABSL_ASSERT_OK(reader);
+  auto trailer_dict = (*reader)->file_trailer_dictionary();
+  auto doc_catalog_dict_obj = (*reader)->GetObject(3, 0);
+  ABSL_ASSERT_OK(doc_catalog_dict_obj);
+  auto doc_catalog_dict =
+      Object::As<Object::Dictionary>(doc_catalog_dict_obj->object);
+  ABSL_ASSERT_OK(doc_catalog_dict);
+
+  // Verify that the catalog names dictionary is moved into an indirect object
+  // and referenced from the document catalog dictionary.
+  EXPECT_THAT(
+      (*reader)->GetObject(4, 0),
+      IsOkAndHolds(IndirectObject{
+          .object_number = 4,
+          .generation_number = 0,
+          .object = Object{
+              .value = Object::Dictionary{
+                  .entries = {{"EmbeddedFiles",
+                               Object{.value = Object::IndirectReference{
+                                          .object_number = 7,
+                                          .generation_number = 0}}}}}}}));
+  EXPECT_THAT(doc_catalog_dict->entries.at("Names"),
+              (Object{.value = Object::IndirectReference{
+                          .object_number = 4, .generation_number = 0}}));
+}
+
+TEST(PdfRewriterTest, RewriteForEmbeddingRespectsFreeObjectEntries) {
+  riegeli::StringReader<> input(kTestPdfContentWithFreeObjectEntries);
+  std::string output_s;
+  riegeli::StringWriter<> output(&output_s);
+  EXPECT_THAT(PdfEmbedder().RewriteForEmbedding(
+                  input, /*estimated_manifest_store_size=*/100, output),
+              IsOk());
+  ASSERT_TRUE(output.Close());
+
+  riegeli::StringReader<> output_reader(output_s);
+  auto reader = PdfReader::Create(&output_reader);
+  ABSL_ASSERT_OK(reader);
+  EXPECT_THAT((*reader)->free_entries(),
+              ElementsAre(Object::IndirectReference{.object_number = 0,
+                                                    .generation_number = 65535},
+                          Object::IndirectReference{.object_number = 2,
+                                                    .generation_number = 0},
+                          Object::IndirectReference{.object_number = 4,
+                                                    .generation_number = 0}));
+}
+
+TEST(PdfRewriterTest, RewriteForEmbeddingFailsIfC2paManifestAlreadyExists) {
+  // Prepare a C2PA-embeddable PDF.
+  riegeli::StringReader<> input(kTestPdfContent);
+  std::string output_s;
+  riegeli::StringWriter<> output(&output_s);
+  EXPECT_THAT(PdfEmbedder().RewriteForEmbedding(
+                  input, /*estimated_manifest_store_size=*/100, output),
+              IsOk());
+  ASSERT_TRUE(output.Close());
+
+  // Rewrite the PDF again, it should fail.
+  riegeli::StringReader<> output_reader(output_s);
+  std::string output_s2;
+  riegeli::StringWriter<> output2(&output_s2);
+  EXPECT_THAT(
+      PdfEmbedder().RewriteForEmbedding(
+          output_reader, /*estimated_manifest_store_size=*/100, output2),
+      StatusIs(absl::StatusCode::kAlreadyExists,
+               HasSubstr("C2PA manifest store already exists")));
+}
+
+TEST(PdfRewriterTest, Embed) {
+  auto manifest_store = CreateManifestStore();
+  ABSL_ASSERT_OK(manifest_store);
+
+  // Prepare a C2PA-embeddable PDF.
+  riegeli::StringReader<> input(kTestPdfContent);
+  std::string output_s;
+  riegeli::StringWriter<> output(&output_s);
+  absl::StatusOr<int64_t> manifest_store_offset =
+      PdfEmbedder().RewriteForEmbedding(input, manifest_store->size(), output);
+  ABSL_ASSERT_OK(manifest_store_offset);
+  ASSERT_TRUE(output.Close());
+
+  // Embed the manifest store.
+  absl::Cord embeddable_asset(output_s);
+  EXPECT_THAT(PdfEmbedder().Embed(*manifest_store, *manifest_store_offset,
+                                  embeddable_asset),
+              IsOk());
+
+  riegeli::StringReader<> output_reader(embeddable_asset.Flatten());
+  EXPECT_THAT(PdfExtractor().ExtractManifestStore(output_reader),
+              IsOkAndHolds(*manifest_store));
+}
+
+TEST(PdfRewriterTest, RewriteForEmbeddingCorrectLayout) {
+  riegeli::StringReader<> input(kTestPdfContent);
+  std::string output_s;
+  riegeli::StringWriter<> output(&output_s);
+  EXPECT_THAT(PdfEmbedder().RewriteForEmbedding(
+                  input, /*estimated_manifest_store_size=*/100, output),
+              IsOk());
+  ASSERT_TRUE(output.Close());
+
+  riegeli::StringReader<> output_reader(output_s);
+  auto reader = PdfReader::Create(&output_reader);
+  ABSL_ASSERT_OK(reader);
+
+  // The catalog should have /AF pointing to the file spec.
+  auto doc_catalog_dict_obj = (*reader)->GetObject(3, 0);
+  ABSL_ASSERT_OK(doc_catalog_dict_obj);
+  auto doc_catalog_dict =
+      Object::As<Object::Dictionary>(doc_catalog_dict_obj->object);
+  ABSL_ASSERT_OK(doc_catalog_dict);
+  auto af_array =
+      FindObjectInDictionary<Object::Array>(*doc_catalog_dict, kPdfAfKey);
+  ABSL_ASSERT_OK(af_array);
+  ASSERT_EQ(af_array->objects.size(), 1);
+  auto file_spec_ref =
+      Object::As<Object::IndirectReference>(af_array->objects[0]);
+  ABSL_ASSERT_OK(file_spec_ref);
+
+  // Inspect the file spec object.
+  auto file_spec_obj = (*reader)->GetObject(file_spec_ref->object_number,
+                                            file_spec_ref->generation_number);
+  ABSL_ASSERT_OK(file_spec_obj);
+  auto file_spec_dict = Object::As<Object::Dictionary>(file_spec_obj->object);
+  ABSL_ASSERT_OK(file_spec_dict);
+
+  // Subtype should NOT be in file spec dictionary.
+  EXPECT_THAT(
+      FindObjectInDictionary<Object::Name>(*file_spec_dict, kPdfSubtypeKey),
+      StatusIs(absl::StatusCode::kNotFound));
+
+  // AFRelationship should be C2PA_Manifest.
+  EXPECT_THAT(FindObjectInDictionary<Object::Name>(*file_spec_dict,
+                                                   kPdfAfRelationshipKey),
+              IsOkAndHolds(Object::Name{
+                  .value = std::string(kPdfC2paManifestAfRelationship)}));
+
+  // Get the embedded file stream ref.
+  auto ef_dict =
+      FindObjectInDictionary<Object::Dictionary>(*file_spec_dict, kPdfEfKey);
+  ABSL_ASSERT_OK(ef_dict);
+  auto stream_ref =
+      FindObjectInDictionary<Object::IndirectReference>(*ef_dict, kPdfFKey);
+  ABSL_ASSERT_OK(stream_ref);
+
+  // Inspect the stream object.
+  auto stream_obj = (*reader)->GetObject(stream_ref->object_number,
+                                         stream_ref->generation_number);
+  ABSL_ASSERT_OK(stream_obj);
+  ASSERT_TRUE(stream_obj->stream_dictionary.has_value());
+  const auto& stream_dict = *stream_obj->stream_dictionary;
+
+  // Subtype should be in stream dictionary.
+  EXPECT_THAT(FindObjectInDictionary<Object::Name>(stream_dict, kPdfSubtypeKey),
+              IsOkAndHolds(Object::Name{
+                  .value = std::string(kPdfC2paManifestMimeType)}));
+
+  // Params should be in stream dictionary.
+  auto params_dict =
+      FindObjectInDictionary<Object::Dictionary>(stream_dict, kPdfParamsKey);
+  ABSL_ASSERT_OK(params_dict);
+  // Params should contain Size.
+  EXPECT_THAT(
+      FindObjectInDictionary<Object::Integer>(*params_dict, kPdfSizeKey),
+      IsOkAndHolds(Object::Integer{.value = 100}));
+
+  // Params should contain ModDate.
+  auto mod_date_obj = FindObjectInDictionary<Object::LiteralString>(
+      *params_dict, kPdfModDateKey);
+  ABSL_ASSERT_OK(mod_date_obj);
+  EXPECT_THAT(mod_date_obj->raw_value,
+              testing::MatchesRegex("D:[0-9]{14}[+]00'00'"));
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/formats/pdf/extractor.cc b/formats/pdf/extractor.cc
index b2e0a9d..4e8b4eb 100644
--- a/formats/pdf/extractor.cc
+++ b/formats/pdf/extractor.cc
@@ -19,6 +19,7 @@
 #include <string>
 #include <utility>
 #include <variant>
+#include <vector>
 
 #include "absl/algorithm/container.h"
 #include "absl/status/status.h"
@@ -28,6 +29,7 @@
 #include "constants/labels.h"
 #include "formats/byte_range.h"
 #include "formats/pdf/constants.h"
+#include "formats/pdf/manifest_store_merger.h"
 #include "formats/pdf/objects.h"
 #include "formats/pdf/reader.h"
 #include "jumbf/utils.h"
@@ -190,6 +192,8 @@
   ABSL_ASSIGN_OR_RETURN(auto name_tree_array,
                         GetEmbeddedFilesNameTreeArray(catalog_dict, *reader));
 
+  std::vector<C2paManifestStore> found_manifest_stores;
+
   for (const auto& file_spec_obj : file_spec_array->objects) {
     Object::Dictionary file_spec_dict;
     Object::IndirectReference file_spec_ref;
@@ -213,6 +217,7 @@
     if (!is_c2pa_manifest_file_spec) {
       continue;
     }
+
     ABSL_ASSIGN_OR_RETURN(auto embedded_file_ref,
                           GetEmbeddedFileRef(file_spec_dict));
 
@@ -248,12 +253,28 @@
         auto embedded_file_offset,
         reader->GetObjectOffset(embedded_file_ref.object_number,
                                 embedded_file_ref.generation_number));
-    return C2paManifestStore{
+    found_manifest_stores.push_back(C2paManifestStore{
         .location = {.offset = embedded_file_offset,
                      .length = input.pos() - embedded_file_offset},
-        .content = embedded_file_stream.raw_value};
+        .content = embedded_file_stream.raw_value});
   }
-  return absl::NotFoundError("No manifest store found.");
+
+  if (found_manifest_stores.empty()) {
+    return absl::NotFoundError("No manifest store found.");
+  }
+
+  std::vector<absl::string_view> store_contents;
+  store_contents.reserve(found_manifest_stores.size());
+  for (const auto& store : found_manifest_stores) {
+    store_contents.push_back(store.content);
+  }
+  ABSL_ASSIGN_OR_RETURN(std::string merged,
+                        MergeManifestStores(store_contents));
+
+  return C2paManifestStore{
+      .location = found_manifest_stores.back().location,
+      .content = std::move(merged),
+  };
 }
 }  // namespace
 
diff --git a/formats/pdf/extractor_test.cc b/formats/pdf/extractor_test.cc
index 60ded2f..500f947 100644
--- a/formats/pdf/extractor_test.cc
+++ b/formats/pdf/extractor_test.cc
@@ -15,37 +15,45 @@
 
 #include "formats/pdf/extractor.h"
 
+#include <cstddef>
+#include <cstdio>
+#include <optional>
 #include <string>
+#include <utility>
+#include <vector>
 
+#include "absl/log/absl_check.h"
 #include "absl/status/status.h"
 #include "absl/status/status_matchers.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/cord.h"
+#include "absl/strings/str_cat.h"
 #include "absl/strings/string_view.h"
+#include "constants/labels.h"
 #include "formats/byte_range.h"
 #include "gmock/gmock.h"
 #include "gtest/gtest.h"
+#include "jumbf/box.h"
+#include "jumbf/box_builder.h"
+#include "jumbf/constants.h"
+#include "jumbf/encode_params.h"
+#include "jumbf/parse.h"
 #include "riegeli/bytes/string_reader.h"
 
 namespace credentio {
 namespace {
 
+using ::absl_testing::IsOkAndHolds;
 using ::absl_testing::StatusIs;
+using ::credentio::kManifestStoreLabel;
+using ::credentio::kManifestStoreUuid;
+using ::credentio::kStandardManifestUuid;
+using ::jumbf::DescriptionBoxOptions;
+using ::jumbf::kCborBoxType;
+using ::jumbf::SuperBoxBuilder;
 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"
@@ -538,25 +546,22 @@
 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");
+  EXPECT_THAT(extractor.ExtractManifestStore(input),
+              IsOkAndHolds("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");
+  EXPECT_THAT(extractor.ExtractManifestStore(input),
+              IsOkAndHolds("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");
+  EXPECT_THAT(extractor.ExtractManifestStore(input),
+              IsOkAndHolds("test_c2pa_manifest_raw_bytestream"));
 }
 
 TEST(PdfExtractorTest,
@@ -570,21 +575,17 @@
 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}));
+  EXPECT_THAT(
+      extractor.ExtractManifestStoreLocation(input, {.requires_c2pa = true}),
+      IsOkAndHolds(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}));
+  EXPECT_THAT(
+      extractor.ExtractManifestStoreLocation(input, {.requires_c2pa = true}),
+      IsOkAndHolds(Optional(ByteRange{.offset = 74, .length = 80})));
 }
 
 TEST(PdfExtractorTest, ExtractManifestStoreFailsWithInvalidNamesType) {
@@ -597,6 +598,115 @@
                                 HasSubstr("credentio::Object::"))));
 }
 
+std::string MakeValidPdfWithMultipleManifests(absl::string_view manifest1,
+                                              absl::string_view manifest2) {
+  std::vector<std::string> objs;
+  objs.push_back(
+      "1 0 obj\n<</Length 11>>\nstream\ntest_stream\nendstream\nendobj\n");
+  objs.push_back(absl::StrCat("2 0 obj\n<</Length ", manifest1.size(),
+                              " /Subtype /application#2fc2pa>>\nstream\n",
+                              manifest1, "\nendstream\nendobj\n"));
+  objs.push_back(
+      "3 0 obj\n<</EF <</F 2 0 R>> /AFRelationship /C2PA_Manifest "
+      "/Type /FileSpec>>\nendobj\n");
+  objs.push_back(absl::StrCat("4 0 obj\n<</Length ", manifest2.size(),
+                              " /Subtype /application#2fc2pa>>\nstream\n",
+                              manifest2, "\nendstream\nendobj\n"));
+  objs.push_back(
+      "5 0 obj\n<</EF <</F 4 0 R>> /AFRelationship /C2PA_Manifest "
+      "/Type /FileSpec>>\nendobj\n");
+  objs.push_back(
+      "6 0 obj\n<</Names [(manifest1.c2pa) 3 0 R (manifest2.c2pa) 5 0 "
+      "R]>>\nendobj\n");
+  objs.push_back("7 0 obj\n<</EmbeddedFiles 6 0 R>>\nendobj\n");
+  objs.push_back(
+      "8 0 obj\n<</AF [3 0 R 5 0 R] /Type /Catalog /Names 7 0 R>>\nendobj\n");
+
+  std::string pdf = "%PDF-1.7\n";
+  std::vector<size_t> offsets;
+  for (const auto& obj : objs) {
+    offsets.push_back(pdf.size());
+    pdf += obj;
+  }
+
+  size_t xref_pos = pdf.size();
+  pdf += "xref\n0 9\n0000000000 65535 f \n";
+  for (size_t offset : offsets) {
+    char buf[32];
+    snprintf(buf, sizeof(buf), "%010zu 00000 n \n", offset);
+    pdf += buf;
+  }
+  absl::StrAppend(&pdf, "trailer\n<</Root 8 0 R>>\nstartxref\n", xref_pos,
+                  "\n%%EOF");
+  return pdf;
+}
+
+TEST(PdfExtractorTest, ExtractManifestStoreSucceedsWithMultipleManifestsInAf) {
+  SuperBoxBuilder manifest_builder1(
+      kStandardManifestUuid,
+      DescriptionBoxOptions{.requestable_iff_label = true,
+                            .label = absl::Cord("manifest_1")});
+  ABSL_CHECK_OK(
+      manifest_builder1.AddContent(kCborBoxType, absl::Cord("data_1")));
+  absl::StatusOr<jumbf::BuiltSuperBox> manifest_box1 =
+      std::move(manifest_builder1).Finalize();
+  ABSL_ASSERT_OK(manifest_box1);
+
+  SuperBoxBuilder store_builder1(
+      kManifestStoreUuid,
+      DescriptionBoxOptions{.requestable_iff_label = true,
+                            .label = absl::Cord(kManifestStoreLabel)});
+  ABSL_CHECK_OK(store_builder1.AddChild(*std::move(manifest_box1)));
+  absl::StatusOr<jumbf::BuiltSuperBox> store_box1 =
+      std::move(store_builder1).Finalize();
+  ABSL_ASSERT_OK(store_box1);
+  std::string store1 = std::string(std::move(*store_box1).AsCord());
+
+  SuperBoxBuilder manifest_builder2(
+      kStandardManifestUuid,
+      DescriptionBoxOptions{.requestable_iff_label = true,
+                            .label = absl::Cord("manifest_2")});
+  ABSL_CHECK_OK(
+      manifest_builder2.AddContent(kCborBoxType, absl::Cord("data_2")));
+  absl::StatusOr<jumbf::BuiltSuperBox> manifest_box2 =
+      std::move(manifest_builder2).Finalize();
+  ABSL_ASSERT_OK(manifest_box2);
+
+  SuperBoxBuilder store_builder2(
+      kManifestStoreUuid,
+      DescriptionBoxOptions{.requestable_iff_label = true,
+                            .label = absl::Cord(kManifestStoreLabel)});
+  ABSL_CHECK_OK(store_builder2.AddChild(*std::move(manifest_box2)));
+  absl::StatusOr<jumbf::BuiltSuperBox> store_box2 =
+      std::move(store_builder2).Finalize();
+  ABSL_ASSERT_OK(store_box2);
+  std::string store2 = std::string(std::move(*store_box2).AsCord());
+
+  std::string pdf = MakeValidPdfWithMultipleManifests(store1, store2);
+
+  riegeli::StringReader<> input(pdf);
+  PdfExtractor extractor;
+  absl::StatusOr<std::string> merged = extractor.ExtractManifestStore(input);
+  ABSL_ASSERT_OK(merged);
+  absl::string_view view = *merged;
+  absl::StatusOr<jumbf::SuperBox> superbox =
+      jumbf::ConsumeSuperBox(&view, /*recursion_limit=*/9);
+  ABSL_ASSERT_OK(superbox);
+  ASSERT_EQ(superbox->contents.size(), 2);
+  EXPECT_EQ(superbox->contents.back().Get<jumbf::SuperBox>().description.label,
+            "manifest_2");
+
+  // Verify location points to the active (second) manifest store stream.
+  absl::StatusOr<std::optional<ByteRange>> manifest_store_location =
+      extractor.ExtractManifestStoreLocation(input, {.requires_c2pa = true});
+  ABSL_ASSERT_OK(manifest_store_location);
+  ASSERT_TRUE(manifest_store_location->has_value());
+  absl::string_view active_stream = absl::string_view(pdf).substr(
+      (*manifest_store_location)->offset, (*manifest_store_location)->length);
+  EXPECT_THAT(active_stream, HasSubstr("4 0 obj"));
+  EXPECT_THAT(active_stream, HasSubstr(store2));
+}
+
 TEST(PdfExtractorTest, ExtractBoxesUnimplemented) {
   riegeli::StringReader<> input("test_content");
   PdfExtractor extractor;
diff --git a/formats/pdf/manifest_store_merger.cc b/formats/pdf/manifest_store_merger.cc
new file mode 100644
index 0000000..f67680e
--- /dev/null
+++ b/formats/pdf/manifest_store_merger.cc
@@ -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.
+//
+
+#include "formats/pdf/manifest_store_merger.h"
+
+#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/cord.h"
+#include "absl/strings/string_view.h"
+#include "absl/types/span.h"
+#include "constants/labels.h"
+#include "jumbf/box.h"
+#include "jumbf/box_builder.h"
+#include "jumbf/encode_params.h"
+#include "jumbf/parse.h"
+
+namespace credentio {
+
+using ::jumbf::DescriptionBoxOptions;
+using ::jumbf::SuperBox;
+using ::jumbf::SuperBoxBuilder;
+
+absl::StatusOr<std::string> MergeManifestStores(
+    absl::Span<const absl::string_view> stores) {
+  if (stores.empty()) {
+    return absl::InvalidArgumentError("No manifest stores provided to merge.");
+  }
+  if (stores.size() == 1) {
+    return std::string(stores.front());
+  }
+
+  SuperBoxBuilder builder(credentio::kManifestStoreUuid,
+                          DescriptionBoxOptions{
+                              .requestable_iff_label = true,
+                              .label = absl::Cord(kManifestStoreLabel),
+                          });
+
+  // SuperBoxBuilder uses a CordBackwardWriter (LIFO insertion order).
+  // To ensure the newest PDF increment's manifest ends up at the BACK of the
+  // content list, we iterate through stores in REVERSE chronological order
+  // (stores.rbegin() to stores.rend()).
+  for (auto store_it = stores.rbegin(); store_it != stores.rend(); ++store_it) {
+    absl::string_view store_view = *store_it;
+    ABSL_ASSIGN_OR_RETURN(
+        SuperBox ms_superbox,
+        jumbf::ConsumeSuperBox(&store_view, /*recursion_limit=*/9));
+
+    // Iterate contents in REVERSE order so LIFO AddChild restores the exact
+    // original order of children inside each store.
+    for (auto box_it = ms_superbox.contents.rbegin();
+         box_it != ms_superbox.contents.rend(); ++box_it) {
+      ABSL_RETURN_IF_ERROR(builder.AddChild(absl::Cord(box_it->raw_bytes)));
+    }
+  }
+
+  ABSL_ASSIGN_OR_RETURN(auto built_box, std::move(builder).Finalize());
+  return std::string(std::move(built_box).AsCord());
+}
+
+}  // namespace credentio
diff --git a/formats/pdf/manifest_store_merger.h b/formats/pdf/manifest_store_merger.h
new file mode 100644
index 0000000..420ef24
--- /dev/null
+++ b/formats/pdf/manifest_store_merger.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_PDF_MANIFEST_STORE_MERGER_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_PDF_MANIFEST_STORE_MERGER_H_
+
+#include <string>
+
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "absl/types/span.h"
+
+namespace credentio {
+
+// Merges one or more serialized C2PA Manifest Store SuperBoxes ('c2pa') into
+// a single unified Manifest Store SuperBox.
+//
+// In accordance with C2PA Spec 2.2 §10.4.3, §15.5.1, and §A.4.2.1, child boxes
+// are ordered such that the manifest from the newest PDF increment (the active
+// manifest in stores.back()) is placed last in the resulting SuperBox.
+absl::StatusOr<std::string> MergeManifestStores(
+    absl::Span<const absl::string_view> stores);
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_PDF_MANIFEST_STORE_MERGER_H_
diff --git a/formats/pdf/manifest_store_merger_test.cc b/formats/pdf/manifest_store_merger_test.cc
new file mode 100644
index 0000000..6e493d0
--- /dev/null
+++ b/formats/pdf/manifest_store_merger_test.cc
@@ -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.
+//
+
+#include "formats/pdf/manifest_store_merger.h"
+
+#include <string>
+#include <utility>
+#include <vector>
+
+#include "absl/log/absl_check.h"
+#include "absl/status/status.h"
+#include "absl/status/status_matchers.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/cord.h"
+#include "absl/strings/string_view.h"
+#include "constants/labels.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "jumbf/box.h"
+#include "jumbf/box_builder.h"
+#include "jumbf/constants.h"
+#include "jumbf/encode_params.h"
+#include "jumbf/parse.h"
+#include "uuid/uuid.h"
+
+namespace credentio {
+namespace {
+
+using ::absl_testing::IsOkAndHolds;
+using ::absl_testing::StatusIs;
+using ::credentio::kManifestStoreLabel;
+using ::credentio::kManifestStoreUuid;
+using ::credentio::kStandardManifestUuid;
+using ::jumbf::DescriptionBoxOptions;
+using ::jumbf::kCborBoxType;
+using ::jumbf::SuperBox;
+using ::jumbf::SuperBoxBuilder;
+
+std::string CreateTestManifestStore(absl::string_view manifest_label,
+                                    absl::string_view payload_data) {
+  SuperBoxBuilder manifest_builder(
+      kStandardManifestUuid,
+      DescriptionBoxOptions{.requestable_iff_label = true,
+                            .label = absl::Cord(manifest_label)});
+  ABSL_CHECK_OK(
+      manifest_builder.AddContent(kCborBoxType, absl::Cord(payload_data)));
+  absl::StatusOr<jumbf::BuiltSuperBox> manifest_box =
+      std::move(manifest_builder).Finalize();
+  ABSL_CHECK_OK(manifest_box);
+
+  SuperBoxBuilder store_builder(
+      kManifestStoreUuid,
+      DescriptionBoxOptions{.requestable_iff_label = true,
+                            .label = absl::Cord(kManifestStoreLabel)});
+  ABSL_CHECK_OK(store_builder.AddChild(*std::move(manifest_box)));
+  absl::StatusOr<jumbf::BuiltSuperBox> store_box =
+      std::move(store_builder).Finalize();
+  ABSL_CHECK_OK(store_box);
+  return std::string(std::move(*store_box).AsCord());
+}
+
+TEST(ManifestStoreMergerTest, EmptyStoresListReturnsError) {
+  EXPECT_THAT(MergeManifestStores({}),
+              StatusIs(absl::StatusCode::kInvalidArgument));
+}
+
+TEST(ManifestStoreMergerTest, SingleStoreReturnedUnmodified) {
+  std::string store = CreateTestManifestStore("test_manifest", "data");
+  EXPECT_THAT(MergeManifestStores({store}), IsOkAndHolds(store));
+}
+
+TEST(ManifestStoreMergerTest, MultipleStoresMergedInCorrectOrder) {
+  std::string store1 = CreateTestManifestStore("manifest_1", "data_1");
+  std::string store2 = CreateTestManifestStore("manifest_2", "data_2");
+
+  absl::StatusOr<std::string> merged = MergeManifestStores({store1, store2});
+  ABSL_ASSERT_OK(merged);
+
+  absl::string_view view = *merged;
+  absl::StatusOr<SuperBox> superbox =
+      jumbf::ConsumeSuperBox(&view, /*recursion_limit=*/9);
+  ABSL_ASSERT_OK(superbox);
+
+  EXPECT_TRUE(view.empty());  // Entire buffer consumed.
+  EXPECT_EQ(superbox->description.type_uuid, kManifestStoreUuid);
+  ASSERT_EQ(superbox->contents.size(), 2);
+
+  // Active manifest from store2 should be placed LAST in contents.
+  const auto& box2 = superbox->contents.back();
+  EXPECT_TRUE(box2.Holds<SuperBox>());
+  EXPECT_EQ(box2.Get<SuperBox>().description.type_uuid, kStandardManifestUuid);
+  EXPECT_EQ(box2.Get<SuperBox>().description.label, "manifest_2");
+
+  // Initial manifest from store1 should be placed FIRST in contents.
+  const auto& box1 = superbox->contents.front();
+  EXPECT_TRUE(box1.Holds<SuperBox>());
+  EXPECT_EQ(box1.Get<SuperBox>().description.type_uuid, kStandardManifestUuid);
+  EXPECT_EQ(box1.Get<SuperBox>().description.label, "manifest_1");
+}
+
+TEST(ManifestStoreMergerTest, MalformedStoreReturnsError) {
+  std::string valid_store = CreateTestManifestStore("manifest_1", "data_1");
+  std::string malformed_store = "not a jumbf box";
+
+  EXPECT_FALSE(MergeManifestStores({valid_store, malformed_store}).ok());
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/formats/pdf/number_validator.cc b/formats/pdf/number_validator.cc
new file mode 100644
index 0000000..ec7f355
--- /dev/null
+++ b/formats/pdf/number_validator.cc
@@ -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.
+//
+
+#include "formats/pdf/number_validator.h"
+
+#include <cstddef>
+
+#include "absl/strings/string_view.h"
+
+namespace credentio {
+
+bool IsValidRealNumber(absl::string_view s) {
+  if (s.empty()) return false;
+  bool seen_decimal = false;
+  bool seen_e = false;
+  for (size_t i = 0; i < s.size(); ++i) {
+    const char c = s[i];
+    if (c == '+' || c == '-') {
+      if (i != 0 && s[i - 1] != 'e' && s[i - 1] != 'E') return false;
+      if (i == s.size() - 1) return false;
+    } else if (c == '.') {
+      if (seen_decimal || seen_e) return false;
+      seen_decimal = true;
+    } else if (c == 'e' || c == 'E') {
+      if (seen_e || i == 0 || i == s.size() - 1) return false;
+      seen_e = true;
+    } else if (c < '0' || c > '9') {
+      return false;
+    }
+  }
+  bool has_digit = false;
+  for (const char c : s) {
+    if (c >= '0' && c <= '9') {
+      has_digit = true;
+      break;
+    }
+  }
+  return has_digit;
+}
+
+bool IsValidHexadecimalString(absl::string_view s) {
+  for (const char c : s) {
+    if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') ||
+          (c >= 'A' && c <= 'F') || c == ' ' || c == '\t' || c == '\r' ||
+          c == '\n' || c == '\f' || c == '\0')) {
+      return false;
+    }
+  }
+  return true;
+}
+
+}  // namespace credentio
diff --git a/formats/pdf/number_validator.h b/formats/pdf/number_validator.h
new file mode 100644
index 0000000..975f3b3
--- /dev/null
+++ b/formats/pdf/number_validator.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_PDF_NUMBER_VALIDATOR_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_PDF_NUMBER_VALIDATOR_H_
+
+#include "absl/strings/string_view.h"
+
+namespace credentio {
+
+// Returns true if the string is a valid representation of a real number in PDF.
+// This supports typical real numbers conformant to PDF specifications, with
+// optional decimal point, signs, and scientific notation (e.g. 1.23e-4 or
+// -12.34E+2).
+bool IsValidRealNumber(absl::string_view s);
+
+// Returns true if the string is a valid representation of a hexadecimal string
+// in PDF. Allowed characters include hex digits, whitespaces, and null chars.
+bool IsValidHexadecimalString(absl::string_view s);
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_PDF_NUMBER_VALIDATOR_H_
diff --git a/formats/pdf/number_validator_test.cc b/formats/pdf/number_validator_test.cc
new file mode 100644
index 0000000..4ee9f61
--- /dev/null
+++ b/formats/pdf/number_validator_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/pdf/number_validator.h"
+
+#include "gtest/gtest.h"
+
+namespace credentio {
+namespace {
+
+TEST(NumberValidatorTest, ValidRealNumbers) {
+  EXPECT_TRUE(IsValidRealNumber("1.23"));
+  EXPECT_TRUE(IsValidRealNumber("0.0"));
+  EXPECT_TRUE(IsValidRealNumber("-0.0"));
+  EXPECT_TRUE(IsValidRealNumber("+1.5"));
+  EXPECT_TRUE(IsValidRealNumber(".5"));
+  EXPECT_TRUE(IsValidRealNumber("+.5"));
+  EXPECT_TRUE(IsValidRealNumber("-5."));
+  EXPECT_TRUE(IsValidRealNumber(".0"));
+  EXPECT_TRUE(IsValidRealNumber("-.0"));
+  EXPECT_TRUE(IsValidRealNumber("+.0"));
+}
+
+TEST(NumberValidatorTest, ValidIntegers) {
+  EXPECT_TRUE(IsValidRealNumber("0"));
+  EXPECT_TRUE(IsValidRealNumber("123"));
+  EXPECT_TRUE(IsValidRealNumber("-456"));
+  EXPECT_TRUE(IsValidRealNumber("+789"));
+}
+
+TEST(NumberValidatorTest, ValidScientificNotation) {
+  EXPECT_TRUE(IsValidRealNumber("1e5"));
+  EXPECT_TRUE(IsValidRealNumber("1E5"));
+  EXPECT_TRUE(IsValidRealNumber("1.2e-3"));
+  EXPECT_TRUE(IsValidRealNumber("+1.23E+4"));
+  EXPECT_TRUE(IsValidRealNumber("-1.2e3"));
+  EXPECT_TRUE(IsValidRealNumber(".5e+2"));
+  EXPECT_TRUE(IsValidRealNumber("5.e-2"));
+}
+
+TEST(NumberValidatorTest, InvalidEmptyOrNoDigits) {
+  EXPECT_FALSE(IsValidRealNumber(""));
+  EXPECT_FALSE(IsValidRealNumber("+"));
+  EXPECT_FALSE(IsValidRealNumber("-"));
+  EXPECT_FALSE(IsValidRealNumber("."));
+  EXPECT_FALSE(IsValidRealNumber("e"));
+  EXPECT_FALSE(IsValidRealNumber("E"));
+  EXPECT_FALSE(IsValidRealNumber("+e"));
+  EXPECT_FALSE(IsValidRealNumber(".e"));
+  EXPECT_FALSE(IsValidRealNumber("+."));
+  EXPECT_FALSE(IsValidRealNumber("+e-"));
+  EXPECT_FALSE(IsValidRealNumber(".e+"));
+}
+
+TEST(NumberValidatorTest, InvalidCharacters) {
+  EXPECT_FALSE(IsValidRealNumber("abc"));
+  EXPECT_FALSE(IsValidRealNumber("1.2a"));
+  EXPECT_FALSE(IsValidRealNumber(" 1.2"));
+  EXPECT_FALSE(IsValidRealNumber("1.2 "));
+  EXPECT_FALSE(IsValidRealNumber("1e 5"));
+  EXPECT_FALSE(IsValidRealNumber("0x123"));
+  EXPECT_FALSE(IsValidRealNumber("NaN"));
+  EXPECT_FALSE(IsValidRealNumber("inf"));
+  EXPECT_FALSE(IsValidRealNumber("infinity"));
+}
+
+TEST(NumberValidatorTest, InvalidMultipleSymbols) {
+  EXPECT_FALSE(IsValidRealNumber("1..2"));
+  EXPECT_FALSE(IsValidRealNumber("1ee5"));
+  EXPECT_FALSE(IsValidRealNumber("1e-5e-6"));
+  EXPECT_FALSE(IsValidRealNumber("--5"));
+  EXPECT_FALSE(IsValidRealNumber("++5"));
+  EXPECT_FALSE(IsValidRealNumber("1e+-5"));
+  EXPECT_FALSE(IsValidRealNumber("1e--5"));
+  EXPECT_FALSE(IsValidRealNumber("1e++5"));
+}
+
+TEST(NumberValidatorTest, InvalidSymbolPositions) {
+  EXPECT_FALSE(IsValidRealNumber("1+"));
+  EXPECT_FALSE(IsValidRealNumber("1-"));
+  EXPECT_FALSE(IsValidRealNumber("1e5+"));
+  EXPECT_FALSE(IsValidRealNumber("1e5-"));
+  EXPECT_FALSE(IsValidRealNumber("e1"));
+  EXPECT_FALSE(IsValidRealNumber("E12"));
+}
+
+TEST(NumberValidatorTest, InvalidDecimalAfterExponent) {
+  EXPECT_FALSE(IsValidRealNumber("1e5.5"));
+  EXPECT_FALSE(IsValidRealNumber("1.2e3.4"));
+  EXPECT_FALSE(IsValidRealNumber(".5e2."));
+}
+
+TEST(HexadecimalStringValidatorTest, ValidHexadecimalStrings) {
+  EXPECT_TRUE(IsValidHexadecimalString(""));
+  EXPECT_TRUE(IsValidHexadecimalString("4E6F76"));
+  EXPECT_TRUE(IsValidHexadecimalString("4e6f76"));
+  EXPECT_TRUE(IsValidHexadecimalString("0123456789ABCDEFabcdef"));
+  EXPECT_TRUE(IsValidHexadecimalString("4E 6F 76"));
+  EXPECT_TRUE(IsValidHexadecimalString("4E\t6F\r76\n"));
+}
+
+TEST(HexadecimalStringValidatorTest, InvalidHexadecimalStrings) {
+  EXPECT_FALSE(IsValidHexadecimalString("4E6F76g"));
+  EXPECT_FALSE(IsValidHexadecimalString("4e6f76-"));
+  EXPECT_FALSE(IsValidHexadecimalString("4E6F76+"));
+  EXPECT_FALSE(IsValidHexadecimalString("abc/def"));
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/formats/pdf/object_reader_test.cc b/formats/pdf/object_reader_test.cc
index 97b21b8..bc9a513 100644
--- a/formats/pdf/object_reader_test.cc
+++ b/formats/pdf/object_reader_test.cc
@@ -54,10 +54,10 @@
   std::string content = absl::StrCat("true false", kContentPadding);
   riegeli::StringReader<> input(content);
   Object obj;
-  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj));
   EXPECT_THAT(obj.value,
               VariantWith<Object::Boolean>(Object::Boolean{.value = true}));
-  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj));
   EXPECT_THAT(obj.value,
               VariantWith<Object::Boolean>(Object::Boolean{.value = false}));
 }
@@ -77,19 +77,19 @@
       absl::StrCat("123   43445 +17   -98 0", kContentPadding);
   riegeli::StringReader<> input(content);
   Object obj;
-  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj));
   EXPECT_THAT(obj.value,
               VariantWith<Object::Integer>(Object::Integer{.value = 123}));
-  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj));
   EXPECT_THAT(obj.value,
               VariantWith<Object::Integer>(Object::Integer{.value = 43445}));
-  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj));
   EXPECT_THAT(obj.value,
               VariantWith<Object::Integer>(Object::Integer{.value = 17}));
-  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj));
   EXPECT_THAT(obj.value,
               VariantWith<Object::Integer>(Object::Integer{.value = -98}));
-  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj));
   EXPECT_THAT(obj.value,
               VariantWith<Object::Integer>(Object::Integer{.value = 0}));
 }
@@ -108,7 +108,7 @@
   std::string content = absl::StrCat("123(", kContentPadding);
   riegeli::StringReader<> input(content);
   Object obj;
-  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj));
   EXPECT_THAT(obj.value,
               VariantWith<Object::Integer>(Object::Integer{.value = 123}));
 }
@@ -117,7 +117,7 @@
   std::string content = absl::StrCat("123\t", kContentPadding);
   riegeli::StringReader<> input(content);
   Object obj;
-  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj));
   EXPECT_THAT(obj.value,
               VariantWith<Object::Integer>(Object::Integer{.value = 123}));
 }
@@ -127,22 +127,22 @@
       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());
+  ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj));
   EXPECT_THAT(obj.value, VariantWith<Object::RealNumber>(
                              Object::RealNumber{.raw_value = "34.5"}));
-  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj));
   EXPECT_THAT(obj.value, VariantWith<Object::RealNumber>(
                              Object::RealNumber{.raw_value = "-3.62"}));
-  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj));
   EXPECT_THAT(obj.value, VariantWith<Object::RealNumber>(
                              Object::RealNumber{.raw_value = "+123.6"}));
-  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj));
   EXPECT_THAT(obj.value, VariantWith<Object::RealNumber>(
                              Object::RealNumber{.raw_value = "4."}));
-  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj));
   EXPECT_THAT(obj.value, VariantWith<Object::RealNumber>(
                              Object::RealNumber{.raw_value = "-.002"}));
-  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj));
   EXPECT_THAT(obj.value, VariantWith<Object::RealNumber>(
                              Object::RealNumber{.raw_value = "0.0"}));
 }
@@ -158,28 +158,28 @@
 (It has zero (0) length.))";
   riegeli::StringReader<> input(content);
   Object obj;
-  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj));
   EXPECT_THAT(obj.value,
               VariantWith<Object::LiteralString>(
                   Object::LiteralString{.raw_value = R"(This is a string)"}));
-  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj));
   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());
+  ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj));
   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());
+  ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj));
   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());
+  ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj));
   EXPECT_THAT(obj.value, VariantWith<Object::LiteralString>(
                              Object::LiteralString{.raw_value = R"()"}));
-  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj));
   EXPECT_THAT(obj.value,
               VariantWith<Object::LiteralString>(Object::LiteralString{
                   .raw_value = R"(It has zero (0) length.)"}));
@@ -189,14 +189,14 @@
   std::string content = "<4E6F762073686D6F7A206B6120706F702E> <901FA3> <901FA>";
   riegeli::StringReader<> input(content);
   Object obj;
-  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj));
   EXPECT_THAT(obj.value,
               VariantWith<Object::HexadecimalString>(Object::HexadecimalString{
                   .raw_value = "4E6F762073686D6F7A206B6120706F702E"}));
-  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj));
   EXPECT_THAT(obj.value, VariantWith<Object::HexadecimalString>(
                              Object::HexadecimalString{.raw_value = "901FA3"}));
-  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj));
   EXPECT_THAT(obj.value, VariantWith<Object::HexadecimalString>(
                              Object::HexadecimalString{.raw_value = "901FA"}));
 }
@@ -210,37 +210,37 @@
       kContentPadding);
   riegeli::StringReader<> input(content);
   Object obj;
-  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj));
   EXPECT_THAT(obj.value,
               VariantWith<Object::Name>(Object::Name{.value = "Name1"}));
-  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj));
   EXPECT_THAT(obj.value, VariantWith<Object::Name>(
                              Object::Name{.value = "ASomewhatLongerName"}));
-  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj));
   EXPECT_THAT(obj.value, VariantWith<Object::Name>(Object::Name{
                              .value = "A;Name_With-Various***Characters?"}));
-  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj));
   EXPECT_THAT(obj.value,
               VariantWith<Object::Name>(Object::Name{.value = "1.2"}));
-  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj));
   EXPECT_THAT(obj.value,
               VariantWith<Object::Name>(Object::Name{.value = "$$"}));
-  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj));
   EXPECT_THAT(obj.value,
               VariantWith<Object::Name>(Object::Name{.value = "@pattern"}));
-  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj));
   EXPECT_THAT(obj.value,
               VariantWith<Object::Name>(Object::Name{.value = ".notdef"}));
-  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj));
   EXPECT_THAT(obj.value,
               VariantWith<Object::Name>(Object::Name{.value = "Lime Green"}));
-  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj));
   EXPECT_THAT(obj.value, VariantWith<Object::Name>(
                              Object::Name{.value = "paired()parentheses"}));
-  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj));
   EXPECT_THAT(obj.value, VariantWith<Object::Name>(
                              Object::Name{.value = "The_Key_of_F#_Minor"}));
-  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj));
   EXPECT_THAT(obj.value,
               VariantWith<Object::Name>(Object::Name{.value = "AB"}));
 }
@@ -249,7 +249,7 @@
   std::string content = absl::StrCat("/Name]", kContentPadding);
   riegeli::StringReader<> input(content);
   Object obj;
-  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj));
   EXPECT_THAT(obj.value,
               VariantWith<Object::Name>(Object::Name{.value = "Name"}));
 }
@@ -258,7 +258,7 @@
   std::string content = absl::StrCat("/Name\n", kContentPadding);
   riegeli::StringReader<> input(content);
   Object obj;
-  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj));
   EXPECT_THAT(obj.value,
               VariantWith<Object::Name>(Object::Name{.value = "Name"}));
 }
@@ -268,7 +268,7 @@
       absl::StrCat("[549 3.14 false (Ralph) /SomeName]", kContentPadding);
   riegeli::StringReader<> input(content);
   Object obj;
-  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj));
   EXPECT_THAT(
       obj.value,
       VariantWith<Object::Array>(Object::Array{
@@ -284,7 +284,7 @@
   std::string content = absl::StrCat("[549 [3.14]]", kContentPadding);
   riegeli::StringReader<> input(content);
   Object obj;
-  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj));
   EXPECT_THAT(
       obj.value,
       VariantWith<Object::Array>(Object::Array{
@@ -303,7 +303,7 @@
       absl::StrCat("[549 %comment \n 3.14 % comment \n]", kContentPadding);
   riegeli::StringReader<> input(content);
   Object obj;
-  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj));
   EXPECT_THAT(
       obj.value,
       VariantWith<Object::Array>(Object::Array{
@@ -317,7 +317,7 @@
       absl::StrCat("<</Type /Example /Subtype /Table>>", kContentPadding);
   riegeli::StringReader<> input(content);
   Object obj;
-  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj));
 
   Object expected_obj;
   Object::Dictionary& dict = expected_obj.value.emplace<Object::Dictionary>();
@@ -331,7 +331,7 @@
       "<</Type /Example %comment \n /Subtype /Table>>", kContentPadding);
   riegeli::StringReader<> input(content);
   Object obj;
-  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj));
 
   Object expected_obj;
   Object::Dictionary& dict = expected_obj.value.emplace<Object::Dictionary>();
@@ -346,11 +346,11 @@
                                      kContentPadding);
   riegeli::StringReader<> input(content);
   Object obj;
-  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj));
   EXPECT_THAT(obj.value,
               VariantWith<Object::Name>(Object::Name{.value = "abc"}));
 
-  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj));
   EXPECT_THAT(obj.value,
               VariantWith<Object::Integer>(Object::Integer{.value = 123}));
 }
@@ -359,7 +359,7 @@
   std::string content = absl::StrCat("<<>>", kContentPadding);
   riegeli::StringReader<> input(content);
   Object obj;
-  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj));
 
   Object expected_obj = Object{.value = Object::Dictionary{}};
   EXPECT_EQ(obj, expected_obj);
@@ -370,7 +370,7 @@
       absl::StrCat("<</Subdictionary << /Item1 0.4 >> >>", kContentPadding);
   riegeli::StringReader<> input(content);
   Object obj;
-  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj));
 
   Object expected_obj;
   Object::Dictionary& dict = expected_obj.value.emplace<Object::Dictionary>();
@@ -386,7 +386,7 @@
   std::string content = "null";
   riegeli::StringReader<> input(content);
   Object obj;
-  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj));
   EXPECT_THAT(obj.value, VariantWith<Object::Null>(Object::Null{}));
 }
 
@@ -403,7 +403,7 @@
   std::string content = absl::StrCat("123 321 R", kContentPadding);
   riegeli::StringReader<> input(content);
   Object obj;
-  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj));
   EXPECT_THAT(obj.value,
               VariantWith<Object::IndirectReference>(Object::IndirectReference{
                   .object_number = 123, .generation_number = 321}));
@@ -413,12 +413,12 @@
   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());
+  ABSL_ASSERT_OK(ReadObject(input, /*obj_offset=*/0, obj));
   // "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());
+  ABSL_ASSERT_OK(ReadObject(input, /*obj_offset=*/10, obj));
   // "1 2 R ..." should be matched as an indirect reference.
   EXPECT_THAT(obj.value,
               VariantWith<Object::IndirectReference>(Object::IndirectReference{
@@ -429,11 +429,11 @@
   std::string content = absl::StrCat("123 321 R /Name", kContentPadding);
   riegeli::StringReader<> input(content);
   Object obj;
-  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj));
   EXPECT_THAT(obj.value,
               VariantWith<Object::IndirectReference>(Object::IndirectReference{
                   .object_number = 123, .generation_number = 321}));
-  ASSERT_THAT(ReadObject(input, input.pos(), obj), IsOk());
+  ABSL_ASSERT_OK(ReadObject(input, input.pos(), obj));
   EXPECT_THAT(obj.value,
               VariantWith<Object::Name>(Object::Name{.value = "Name"}));
 }
@@ -465,8 +465,7 @@
 endobj)";
   riegeli::StringReader<> input(content);
   IndirectObject obj;
-  ASSERT_THAT(ReadIndirectObject(input, input.pos(), MockResolver(), obj),
-              IsOk());
+  ABSL_ASSERT_OK(ReadIndirectObject(input, input.pos(), MockResolver(), obj));
   EXPECT_EQ(obj.object_number, 12);
   EXPECT_EQ(obj.generation_number, 0);
   EXPECT_THAT(obj.object.value,
@@ -519,8 +518,7 @@
       "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());
+  ABSL_ASSERT_OK(ReadIndirectObject(input, input.pos(), MockResolver(), obj));
   EXPECT_EQ(obj.object_number, 1);
   EXPECT_EQ(obj.generation_number, 2);
   EXPECT_THAT(obj.stream_dictionary.value().entries,
@@ -535,8 +533,7 @@
       "1 2 obj <</Length 10>> stream\n1234567890\nendstream endobj";
   riegeli::StringReader<> input(content);
   IndirectObject obj;
-  ASSERT_THAT(ReadIndirectObject(input, input.pos(), MockResolver(), obj),
-              IsOk());
+  ABSL_ASSERT_OK(ReadIndirectObject(input, input.pos(), MockResolver(), obj));
   EXPECT_EQ(obj.object_number, 1);
   EXPECT_EQ(obj.generation_number, 2);
   EXPECT_THAT(obj.stream_dictionary.value().entries,
@@ -575,8 +572,7 @@
       "m endobj";
   riegeli::StringReader<> input(content);
   IndirectObject obj;
-  ASSERT_THAT(ReadIndirectObject(input, input.pos(), MockResolver(), obj),
-              IsOk());
+  ABSL_ASSERT_OK(ReadIndirectObject(input, input.pos(), MockResolver(), obj));
   EXPECT_EQ(obj.object_number, 1);
   EXPECT_EQ(obj.generation_number, 2);
   EXPECT_THAT(obj.stream_dictionary.value().entries,
@@ -609,9 +605,8 @@
                     .generation_number = 4,
                     .object = Object{.value = Object::Integer{.value = 10}}});
   IndirectObject obj;
-  ASSERT_THAT(
-      ReadIndirectObject(input, input.pos(), std::move(mock_resolver), obj),
-      IsOk());
+  ABSL_ASSERT_OK(
+      ReadIndirectObject(input, input.pos(), std::move(mock_resolver), obj));
   EXPECT_EQ(obj.object_number, 1);
   EXPECT_EQ(obj.generation_number, 2);
   EXPECT_THAT(
diff --git a/formats/pdf/object_writer.cc b/formats/pdf/object_writer.cc
new file mode 100644
index 0000000..e54b754
--- /dev/null
+++ b/formats/pdf/object_writer.cc
@@ -0,0 +1,314 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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_writer.h"
+
+#include <cstddef>
+#include <cstdint>
+#include <string>
+#include <variant>
+
+#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 "formats/pdf/number_validator.h"
+#include "formats/pdf/objects.h"
+#include "riegeli/bytes/string_writer.h"
+#include "riegeli/bytes/writer.h"
+
+namespace credentio {
+namespace {
+
+constexpr absl::string_view kTrue = "true";
+constexpr absl::string_view kFalse = "false";
+constexpr absl::string_view kNull = "null";
+
+absl::Status WriteObject(const Object::Boolean& obj, riegeli::Writer& output) {
+  std::string buffer = obj.value ? std::string(kTrue) : std::string(kFalse);
+  if (!output.Write(buffer)) {
+    return output.status();
+  }
+  return absl::OkStatus();
+}
+
+absl::Status WriteObject(const Object::Integer& obj, riegeli::Writer& output) {
+  std::string buffer = absl::StrCat(obj.value);
+  if (!output.Write(buffer)) {
+    return output.status();
+  }
+  return absl::OkStatus();
+}
+
+absl::Status WriteObject(const Object::RealNumber& obj,
+                         riegeli::Writer& output) {
+  if (!IsValidRealNumber(obj.raw_value)) {
+    return absl::InvalidArgumentError(
+        absl::StrCat("Invalid real number: ", obj.raw_value));
+  }
+  std::string buffer = obj.raw_value;
+  if (!output.Write(buffer)) {
+    return output.status();
+  }
+  return absl::OkStatus();
+}
+
+absl::Status WriteObject(const Object::LiteralString& obj,
+                         riegeli::Writer& output) {
+  std::string buffer = "(";
+  for (const char c : obj.raw_value) {
+    if (c == '(' || c == ')' || c == '\\') {
+      buffer += '\\';
+      buffer += c;
+    } else {
+      buffer += c;
+    }
+  }
+  buffer += ')';
+  if (!output.Write(buffer)) {
+    return output.status();
+  }
+  return absl::OkStatus();
+}
+
+absl::Status WriteObject(const Object::HexadecimalString& obj,
+                         riegeli::Writer& output) {
+  if (!IsValidHexadecimalString(obj.raw_value)) {
+    return absl::InvalidArgumentError(
+        absl::StrCat("Invalid hexadecimal string: ", obj.raw_value));
+  }
+  std::string buffer = absl::StrCat("<", obj.raw_value, ">");
+  if (!output.Write(buffer)) {
+    return output.status();
+  }
+  return absl::OkStatus();
+}
+
+absl::Status WriteObject(const Object::Name& obj, riegeli::Writer& output) {
+  std::string buffer = "/";
+  for (const char c : obj.value) {
+    // Escape control characters, non-ASCII, slash, and any PDF delimiter
+    // character. Delimiters include: (, ), <, >, [, ], {, }, %, #, /
+    if (c < 0x21 || c > 0x7e || c == 0x2f || c == '(' || c == ')' || c == '<' ||
+        c == '>' || c == '[' || c == ']' || c == '{' || c == '}' || c == '%' ||
+        c == '#') {
+      absl::StrAppendFormat(&buffer, "#%02x", static_cast<unsigned char>(c));
+    } else {
+      buffer += c;
+    }
+  }
+  if (!output.Write(buffer)) {
+    return output.status();
+  }
+  return absl::OkStatus();
+}
+
+absl::Status WriteObject(const Object::Array& obj, riegeli::Writer& output) {
+  // Write the open bracket.
+  std::string delimiter = "[";
+  if (!output.Write(delimiter)) {
+    return output.status();
+  }
+
+  // Write the elements separated by spaces.
+  std::string space = " ";
+  for (auto i = 0; i < obj.objects.size(); ++i) {
+    ABSL_RETURN_IF_ERROR(WriteObject(obj.objects[i], output));
+    // Write the space as a delimiter when necessary.
+    if (i < obj.objects.size() - 1) {
+      if (!output.Write(space)) {
+        return output.status();
+      }
+    }
+  }
+  // Write the close bracket.
+  delimiter = "]";
+  if (!output.Write(delimiter)) {
+    return output.status();
+  }
+  return absl::OkStatus();
+}
+
+absl::Status WriteObject(const Object::Dictionary& obj,
+                         riegeli::Writer& output) {
+  // Write the open brackets.
+  std::string delimiter = "<<";
+  if (!output.Write(delimiter)) {
+    return output.status();
+  }
+
+  // Write the entries separated by spaces.
+  std::string space = " ";
+  int entries_written = 0;
+  for (const auto& [key, value] : obj.entries) {
+    // Write the key as a Name object.
+    auto key_obj = Object{.value = Object::Name{.value = key}};
+    ABSL_RETURN_IF_ERROR(WriteObject(key_obj, output));
+
+    // Write the space as a delimiter.
+    if (!output.Write(space)) {
+      return output.status();
+    }
+
+    // Write the value object.
+    ABSL_RETURN_IF_ERROR(WriteObject(value, output));
+
+    // Write the space as a delimiter when necessary.
+    if (entries_written < obj.entries.size() - 1) {
+      if (!output.Write(space)) {
+        return output.status();
+      }
+    }
+    ++entries_written;
+  }
+  delimiter = ">>";
+  if (!output.Write(delimiter)) {
+    return output.status();
+  }
+  return absl::OkStatus();
+}
+
+absl::Status WriteObject(const Object::Stream& obj, riegeli::Writer& output) {
+  std::string buffer = "stream\n";
+  if (!output.Write(buffer)) {
+    return output.status();
+  }
+
+  buffer = obj.raw_value;
+  if (!output.Write(buffer)) {
+    return output.status();
+  }
+
+  buffer = "\nendstream";
+  if (!output.Write(buffer)) {
+    return output.status();
+  }
+  return absl::OkStatus();
+}
+
+absl::Status WriteObject(const Object::Null& obj, riegeli::Writer& output) {
+  std::string buffer = std::string(kNull);
+  if (!output.Write(buffer)) {
+    return output.status();
+  }
+  return absl::OkStatus();
+}
+
+absl::Status WriteObject(const Object::IndirectReference& obj,
+                         riegeli::Writer& output) {
+  std::string buffer =
+      absl::StrCat(obj.object_number, " ", obj.generation_number, " R");
+  if (!output.Write(buffer)) {
+    return output.status();
+  }
+  return absl::OkStatus();
+}
+
+}  // namespace
+
+absl::Status WriteObject(const Object& obj, riegeli::Writer& output) {
+  if (std::holds_alternative<Object::Stream>(obj.value)) {
+    return absl::InvalidArgumentError(
+        "Stream must be written as an indirect object. Call "
+        "WriteIndirectObject() instead.");
+  }
+  return std::visit(
+      [&output](const auto& obj) { return WriteObject(obj, output); },
+      obj.value);
+}
+
+absl::Status WriteIndirectObject(const IndirectObject& obj,
+                                 riegeli::Writer& output, bool is_measurement) {
+  // Write the object header.
+  std::string buffer =
+      absl::StrCat(obj.object_number, " ", obj.generation_number, " obj\n");
+  if (!output.Write(buffer)) {
+    return output.status();
+  }
+  if (obj.stream_dictionary.has_value()) {
+    ABSL_ASSIGN_OR_RETURN(auto obj_stream,
+                          Object::As<Object::Stream>(obj.object));
+    Object::Dictionary serialized_dict = *obj.stream_dictionary;
+
+    if (!obj_stream.raw_value.empty()) {
+      serialized_dict.entries["Length"] =
+          Object{.value = Object::Integer{.value = static_cast<int64_t>(
+                                              obj_stream.raw_value.length())}};
+    } else if (!is_measurement) {
+      // Safely lock genuinely empty streams to /Length 0 to prevent smuggling.
+      serialized_dict.entries["Length"] =
+          Object{.value = Object::Integer{.value = 0}};
+    }
+    ABSL_RETURN_IF_ERROR(WriteObject(Object{.value = serialized_dict}, output));
+
+    // Write a newline to separate the stream dictionary from the stream.
+    buffer = "\n";
+    if (!output.Write(buffer)) {
+      return output.status();
+    }
+    ABSL_RETURN_IF_ERROR(WriteObject(obj_stream, output));
+  } else {
+    ABSL_RETURN_IF_ERROR(WriteObject(obj.object, output));
+  }
+
+  // Write the end-of-object marker.
+  buffer = "\nendobj\n";
+  if (!output.Write(buffer)) {
+    return output.status();
+  }
+  return absl::OkStatus();
+}
+
+absl::StatusOr<int64_t> ComputeIndirectStreamHeaderByteSize(
+    const IndirectObject& indirect_stream_obj) {
+  if (!std::holds_alternative<Object::Stream>(
+          indirect_stream_obj.object.value)) {
+    return absl::InvalidArgumentError(
+        "The object is not an indirect stream object.");
+  }
+  // Create a copy of the indirect stream object with only the header info to
+  // avoid serializing the entire stream.
+  IndirectObject indirect_stream_obj_copy;
+  indirect_stream_obj_copy.object_number = indirect_stream_obj.object_number;
+  indirect_stream_obj_copy.generation_number =
+      indirect_stream_obj.generation_number;
+  indirect_stream_obj_copy.stream_dictionary =
+      indirect_stream_obj.stream_dictionary;
+  indirect_stream_obj_copy.object = Object{.value = Object::Stream{}};
+
+  std::string serialized_obj;
+  riegeli::StringWriter<> output(&serialized_obj);
+  ABSL_RETURN_IF_ERROR(WriteIndirectObject(indirect_stream_obj_copy, output,
+                                           /*is_measurement=*/true));
+  if (!output.Close()) {
+    return output.status();
+  }
+
+  size_t pos = serialized_obj.length();
+  while ((pos = serialized_obj.rfind("stream\n", pos)) != std::string::npos) {
+    if (pos >= 3 && serialized_obj.substr(pos - 3, 3) == "end") {
+      if (pos == 0) break;
+      pos--;
+      continue;
+    }
+    return pos + 7;
+  }
+
+  return absl::InternalError(
+      "Failed to find the stream marker in the serialized object.");
+}
+}  // namespace credentio
diff --git a/formats/pdf/object_writer.h b/formats/pdf/object_writer.h
new file mode 100644
index 0000000..a827666
--- /dev/null
+++ b/formats/pdf/object_writer.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_PDF_OBJECT_WRITER_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_PDF_OBJECT_WRITER_H_
+
+#include <cstdint>
+
+#include "absl/status/status.h"
+#include "absl/status/statusor.h"
+#include "formats/pdf/objects.h"
+#include "riegeli/bytes/writer.h"
+
+namespace credentio {
+
+// Writes the given object to the output at the current position.
+absl::Status WriteObject(const Object& obj, riegeli::Writer& output);
+
+// Writes the given indirect object to the output at the current position.
+absl::Status WriteIndirectObject(const IndirectObject& obj,
+                                 riegeli::Writer& output,
+                                 bool is_measurement = false);
+
+// Computes the size of the "header" of an indirect stream object in bytes. The
+// "header" refers to all the content written for an indirect stream object
+// before the actual stream data begins.
+absl::StatusOr<int64_t> ComputeIndirectStreamHeaderByteSize(
+    const IndirectObject& indirect_stream_obj);
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_PDF_OBJECT_WRITER_H_
diff --git a/formats/pdf/object_writer_test.cc b/formats/pdf/object_writer_test.cc
new file mode 100644
index 0000000..957eff1
--- /dev/null
+++ b/formats/pdf/object_writer_test.cc
@@ -0,0 +1,570 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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_writer.h"
+
+#include <algorithm>
+#include <cstddef>
+#include <ostream>
+#include <string>
+
+#include "absl/status/status.h"
+#include "absl/status/status_matchers.h"
+#include "formats/pdf/objects.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "riegeli/bytes/string_writer.h"
+#include "riegeli/bytes/writer.h"
+
+namespace credentio {
+namespace {
+
+using ::absl_testing::StatusIs;
+using ::testing::HasSubstr;
+
+class FailingWriter : public riegeli::Writer {
+ public:
+  explicit FailingWriter(absl::Status status) { Fail(status); }
+
+  bool PushSlow(size_t min_length, size_t recommended_length) override {
+    return false;
+  }
+};
+
+class StepFailingWriter : public riegeli::Writer {
+ public:
+  explicit StepFailingWriter(int fail_after_bytes, absl::Status status)
+      : fail_after_bytes_(fail_after_bytes), status_(status) {
+    set_buffer(buffer_, 1);
+  }
+
+  bool PushSlow(size_t min_length, size_t recommended_length) override {
+    if (fail_after_bytes_ <= 0) {
+      Fail(status_);
+      return false;
+    }
+    size_t write_len =
+        std::min(min_length, static_cast<size_t>(fail_after_bytes_));
+    fail_after_bytes_ -= write_len;
+    if (fail_after_bytes_ <= 0) {
+      Fail(status_);
+      return false;
+    }
+    set_buffer(buffer_, 1);
+    return true;
+  }
+
+ private:
+  int fail_after_bytes_;
+  absl::Status status_;
+  char buffer_[1];
+};
+
+TEST(WriteObjectTest, WriteBoolean) {
+  Object obj = {.value = Object::Boolean{.value = true}};
+
+  std::string output_s;
+  riegeli::StringWriter<> output(&output_s);
+  ABSL_ASSERT_OK(WriteObject(obj, output));
+  ASSERT_TRUE(output.Close());
+
+  EXPECT_EQ(output_s, "true");
+}
+
+TEST(WriteObjectTest, WriteInteger) {
+  Object obj = {.value = Object::Integer{.value = 123}};
+
+  std::string output_s;
+  riegeli::StringWriter<> output(&output_s);
+  ABSL_ASSERT_OK(WriteObject(obj, output));
+  ASSERT_TRUE(output.Close());
+
+  EXPECT_EQ(output_s, "123");
+}
+
+TEST(WriteObjectTest, WriteIntegerPreservesNegativeSign) {
+  Object obj = {.value = Object::Integer{.value = -123}};
+
+  std::string output_s;
+  riegeli::StringWriter<> output(&output_s);
+  ABSL_ASSERT_OK(WriteObject(obj, output));
+  ASSERT_TRUE(output.Close());
+
+  EXPECT_EQ(output_s, "-123");
+}
+
+TEST(WriteObjectTest, WriteRealNumber) {
+  Object obj = {.value = Object::RealNumber{.raw_value = "1.23"}};
+
+  std::string output_s;
+  riegeli::StringWriter<> output(&output_s);
+  ABSL_ASSERT_OK(WriteObject(obj, output));
+  ASSERT_TRUE(output.Close());
+
+  EXPECT_EQ(output_s, "1.23");
+}
+
+TEST(WriteObjectTest, WriteLiteralString) {
+  Object obj = {.value = Object::LiteralString{.raw_value = "abc"}};
+
+  std::string output_s;
+  riegeli::StringWriter<> output(&output_s);
+  ABSL_ASSERT_OK(WriteObject(obj, output));
+  ASSERT_TRUE(output.Close());
+
+  EXPECT_EQ(output_s, "(abc)");
+}
+
+TEST(WriteObjectTest, WriteHexadecimalString) {
+  Object obj = {.value = Object::HexadecimalString{.raw_value = "4E6F76"}};
+
+  std::string output_s;
+  riegeli::StringWriter<> output(&output_s);
+  ABSL_ASSERT_OK(WriteObject(obj, output));
+  ASSERT_TRUE(output.Close());
+
+  EXPECT_EQ(output_s, "<4E6F76>");
+}
+
+TEST(WriteObjectTest, WriteName) {
+  Object obj = {.value = Object::Name{.value = "Catalog"}};
+
+  std::string output_s;
+  riegeli::StringWriter<> output(&output_s);
+  ABSL_ASSERT_OK(WriteObject(obj, output));
+  ASSERT_TRUE(output.Close());
+
+  EXPECT_EQ(output_s, "/Catalog");
+}
+
+TEST(WriteObjectTest, WriteNameWithEscapedCharacters) {
+  Object obj = {.value = Object::Name{.value = "/\x20\x80"}};
+
+  std::string output_s;
+  riegeli::StringWriter<> output(&output_s);
+  ABSL_ASSERT_OK(WriteObject(obj, output));
+  ASSERT_TRUE(output.Close());
+
+  EXPECT_EQ(output_s, "/#2f#20#80");
+}
+
+TEST(WriteObjectTest, WriteArray) {
+  Object obj = {
+      .value = Object::Array{
+          .objects = {
+              Object{.value = Object::LiteralString{.raw_value = "abc"}},
+              Object{.value = Object::LiteralString{.raw_value = "def"}}}}};
+
+  std::string output_s;
+  riegeli::StringWriter<> output(&output_s);
+  ABSL_ASSERT_OK(WriteObject(obj, output));
+  ASSERT_TRUE(output.Close());
+
+  EXPECT_EQ(output_s, "[(abc) (def)]");
+}
+
+TEST(WriteObjectTest, WriteDictionary) {
+  Object obj = {
+      .value = Object::Dictionary{
+          .entries = {
+              {"key_1", Object{.value = Object::Name{.value = "name"}}},
+              {"key_2", Object{.value = Object::Integer{.value = 123}}}}}};
+
+  std::string output_s;
+  riegeli::StringWriter<> output(&output_s);
+  ABSL_ASSERT_OK(WriteObject(obj, output));
+  ASSERT_TRUE(output.Close());
+
+  EXPECT_EQ(output_s.substr(0, 2), "<<");
+  // The order of the entries is non-deterministic, so we check the substrings
+  // instead of the entire string.
+  EXPECT_THAT(output_s, HasSubstr("/key_1 /name"));
+  EXPECT_THAT(output_s, HasSubstr("/key_2 123"));
+  EXPECT_EQ(output_s.substr(output_s.size() - 2, 2), ">>");
+}
+
+TEST(WriteObjectTest, WriteStreamFailure) {
+  Object obj = {.value = Object::Stream{.raw_value = "1234567890"}};
+
+  std::string output_s;
+  riegeli::StringWriter<> output(&output_s);
+
+  EXPECT_THAT(
+      WriteObject(obj, output),
+      StatusIs(absl::StatusCode::kInvalidArgument,
+               HasSubstr("Stream must be written as an indirect "
+                         "object. Call WriteIndirectObject() instead.")));
+}
+
+TEST(WriteObjectTest, WriteNull) {
+  Object obj = {.value = Object::Null{}};
+
+  std::string output_s;
+  riegeli::StringWriter<> output(&output_s);
+  ABSL_ASSERT_OK(WriteObject(obj, output));
+  ASSERT_TRUE(output.Close());
+
+  EXPECT_EQ(output_s, "null");
+}
+
+TEST(WriteIndirectObjectTest, WriteStream) {
+  IndirectObject indirect_obj = {
+      .object_number = 1,
+      .generation_number = 2,
+      .stream_dictionary =
+          Object::Dictionary{
+              .entries = {{"Length",
+                           Object{.value = Object::Integer{.value = 10}}}}},
+      .object = {.value = Object::Stream{.raw_value = "1234567890"}}};
+
+  std::string output_s;
+  riegeli::StringWriter<> output(&output_s);
+  ABSL_ASSERT_OK(WriteIndirectObject(indirect_obj, output));
+  ASSERT_TRUE(output.Close());
+
+  EXPECT_EQ(output_s, R"(1 2 obj
+<</Length 10>>
+stream
+1234567890
+endstream
+endobj
+)");
+}
+
+TEST(WriteIndirectObjectTest, WriteName) {
+  IndirectObject indirect_obj = {
+      .object_number = 1,
+      .generation_number = 2,
+      .object = {.value = Object::Name{.value = "Catalog"}}};
+
+  std::string output_s;
+  riegeli::StringWriter<> output(&output_s);
+  ABSL_ASSERT_OK(WriteIndirectObject(indirect_obj, output));
+  ASSERT_TRUE(output.Close());
+
+  EXPECT_EQ(output_s, R"(1 2 obj
+/Catalog
+endobj
+)");
+}
+
+TEST(ComputeIndirectStreamHeaderLengthTest, ComputeHeaderLengthCorrectly) {
+  IndirectObject indirect_obj = {
+      .object_number = 1,
+      .generation_number = 2,
+      .stream_dictionary =
+          Object::Dictionary{
+              .entries = {{"Length",
+                           Object{.value = Object::Integer{.value = 10}}}}},
+      .object = {.value = Object::Stream{.raw_value = "1234567890"}}};
+
+  auto header_len = ComputeIndirectStreamHeaderByteSize(indirect_obj);
+  ABSL_ASSERT_OK(header_len);
+  EXPECT_EQ(*header_len, 30);
+}
+
+TEST(ComputeIndirectStreamHeaderLengthTest, FailsForNonStreamObject) {
+  IndirectObject indirect_obj = {
+      .object_number = 1,
+      .generation_number = 2,
+      .object = {.value = Object::LiteralString{.raw_value = "abc"}}};
+
+  EXPECT_THAT(
+      ComputeIndirectStreamHeaderByteSize(indirect_obj),
+      StatusIs(absl::StatusCode::kInvalidArgument,
+               HasSubstr("The object is not an indirect stream object")));
+}
+
+TEST(ComputeIndirectStreamHeaderLengthTest,
+     HandlesStreamSubstringInDictionaryField) {
+  IndirectObject indirect_obj = {
+      .object_number = 1,
+      .generation_number = 2,
+      .stream_dictionary =
+          Object::Dictionary{
+              .entries = {{"Title",
+                           Object{.value =
+                                      Object::LiteralString{
+                                          .raw_value = "my stream\n title"}}}}},
+      .object = {.value = Object::Stream{.raw_value = "1234567890"}}};
+
+  auto header_len = ComputeIndirectStreamHeaderByteSize(indirect_obj);
+  ABSL_ASSERT_OK(header_len);
+  EXPECT_EQ(*header_len, 45);
+}
+
+TEST(WriteObjectTest, InjectionBlocked) {
+  // 1. LiteralString Injection is escaped safely
+  {
+    Object obj = {.value = Object::LiteralString{
+                      .raw_value = "payload) /Injected /Dictionary ("}};
+    std::string output_s;
+    riegeli::StringWriter<> output(&output_s);
+    ABSL_ASSERT_OK(WriteObject(obj, output));
+    ASSERT_TRUE(output.Close());
+    EXPECT_EQ(output_s, "(payload\\) /Injected /Dictionary \\()");
+  }
+
+  // 2. HexadecimalString Injection is rejected as invalid hex
+  {
+    Object obj = {.value = Object::HexadecimalString{
+                      .raw_value = "4E6F76> /Injected /Dictionary <"}};
+    std::string output_s;
+    riegeli::StringWriter<> output(&output_s);
+    EXPECT_THAT(WriteObject(obj, output),
+                StatusIs(absl::StatusCode::kInvalidArgument,
+                         HasSubstr("Invalid hexadecimal string")));
+  }
+
+  // 3. RealNumber Injection is rejected as invalid real number
+  {
+    Object obj = {.value = Object::RealNumber{.raw_value = "1.23 /Injected"}};
+    std::string output_s;
+    riegeli::StringWriter<> output(&output_s);
+    EXPECT_THAT(WriteObject(obj, output),
+                StatusIs(absl::StatusCode::kInvalidArgument,
+                         HasSubstr("Invalid real number")));
+  }
+
+  // 4. Name Injection is escaped safely
+  {
+    Object obj = {.value = Object::Name{.value = "MyName)(InjectedString)"}};
+    std::string output_s;
+    riegeli::StringWriter<> output(&output_s);
+    ABSL_ASSERT_OK(WriteObject(obj, output));
+    ASSERT_TRUE(output.Close());
+    EXPECT_EQ(output_s, "/MyName#29#28InjectedString#29");
+  }
+}
+
+TEST(WriteIndirectObjectTest, EmptyStreamLengthSynchronization) {
+  // 1. Genuinely empty stream (not C2PA template) with non-zero /Length should
+  // have its /Length forced to 0.
+  {
+    IndirectObject indirect_obj = {
+        .object_number = 1,
+        .generation_number = 2,
+        .stream_dictionary =
+            Object::Dictionary{
+                .entries = {{"Length",
+                             Object{.value = Object::Integer{.value = 10}}}}},
+        .object = {.value = Object::Stream{.raw_value = ""}}};
+
+    std::string output_s;
+    riegeli::StringWriter<> output(&output_s);
+    ABSL_ASSERT_OK(WriteIndirectObject(indirect_obj, output));
+    ASSERT_TRUE(output.Close());
+
+    EXPECT_EQ(output_s, R"(1 2 obj
+<</Length 0>>
+stream
+
+endstream
+endobj
+)");
+  }
+
+  // 2. C2PA manifest store template stream during measurement (empty raw_value)
+  // with is_measurement = true should preserve its original /Length.
+  {
+    IndirectObject indirect_obj = {
+        .object_number = 1,
+        .generation_number = 2,
+        .stream_dictionary =
+            Object::Dictionary{
+                .entries =
+                    {{"Length", Object{.value = Object::Integer{.value = 100}}},
+                     {"Subtype",
+                      Object{.value =
+                                 Object::Name{.value = "application/c2pa"}}}}},
+        .object = {.value = Object::Stream{.raw_value = ""}}};
+
+    std::string output_s;
+    riegeli::StringWriter<> output(&output_s);
+    ABSL_ASSERT_OK(
+        WriteIndirectObject(indirect_obj, output, /*is_measurement=*/true));
+    ASSERT_TRUE(output.Close());
+
+    // Substrings checked as dict serialization order is non-deterministic.
+    EXPECT_THAT(output_s, HasSubstr("/Length 100"));
+    EXPECT_THAT(output_s, HasSubstr("/Subtype /application#2fc2pa"));
+    EXPECT_THAT(output_s, HasSubstr("stream\n\nendstream"));
+  }
+
+  // 3. Attack vector: Malicious stream dictionary containing
+  // '_IsMeasurementCopy' key
+  //    with is_measurement = false should still have its /Length forced to 0.
+  {
+    IndirectObject indirect_obj = {
+        .object_number = 1,
+        .generation_number = 2,
+        .stream_dictionary =
+            Object::Dictionary{
+                .entries = {{"Length",
+                             Object{.value = Object::Integer{.value = 100}}},
+                            {"_IsMeasurementCopy",
+                             Object{.value = Object::Boolean{.value = true}}}}},
+        .object = {.value = Object::Stream{.raw_value = ""}}};
+
+    std::string output_s;
+    riegeli::StringWriter<> output(&output_s);
+    ABSL_ASSERT_OK(
+        WriteIndirectObject(indirect_obj, output, /*is_measurement=*/false));
+    ASSERT_TRUE(output.Close());
+
+    EXPECT_THAT(output_s, HasSubstr("/Length 0"));
+    EXPECT_THAT(output_s, HasSubstr("/_IsMeasurementCopy true"));
+  }
+}
+
+struct FailureTestCase {
+  Object obj;
+  std::string name;
+
+  friend std::ostream& operator<<(std::ostream& os, const FailureTestCase& tc) {
+    return os << tc.name;
+  }
+};
+
+class WriteObjectFailureTest
+    : public ::testing::TestWithParam<FailureTestCase> {};
+
+TEST_P(WriteObjectFailureTest, WriteFailsAndReturnsWriterStatus) {
+  const FailureTestCase& param = GetParam();
+  absl::Status expected_status = absl::InternalError("Simulated write failure");
+  FailingWriter writer(expected_status);
+
+  absl::Status status = WriteObject(param.obj, writer);
+
+  EXPECT_THAT(status, StatusIs(absl::StatusCode::kInternal,
+                               HasSubstr("Simulated write failure")));
+}
+
+INSTANTIATE_TEST_SUITE_P(
+    WriteObjectFailureTests, WriteObjectFailureTest,
+    ::testing::Values(
+        FailureTestCase{.obj = Object{.value = Object::Boolean{.value = true}},
+                        .name = "Boolean"},
+        FailureTestCase{.obj = Object{.value = Object::Integer{.value = 42}},
+                        .name = "Integer"},
+        FailureTestCase{
+            .obj = Object{.value = Object::RealNumber{.raw_value = "3.14"}},
+            .name = "RealNumber"},
+        FailureTestCase{
+            .obj = Object{.value = Object::LiteralString{.raw_value = "test"}},
+            .name = "LiteralString"},
+        FailureTestCase{
+            .obj =
+                Object{.value = Object::HexadecimalString{.raw_value = "AB"}},
+            .name = "HexadecimalString"},
+        FailureTestCase{.obj = Object{.value = Object::Name{.value = "test"}},
+                        .name = "Name"},
+        FailureTestCase{.obj = Object{.value = Object::Array{.objects = {}}},
+                        .name = "Array"},
+        FailureTestCase{
+            .obj = Object{.value = Object::Dictionary{.entries = {}}},
+            .name = "Dictionary"},
+        FailureTestCase{.obj = Object{.value = Object::Null{}}, .name = "Null"},
+        FailureTestCase{
+            .obj = Object{.value =
+                              Object::IndirectReference{
+                                  .object_number = 1, .generation_number = 0}},
+            .name = "IndirectReference"}),
+    [](const ::testing::TestParamInfo<WriteObjectFailureTest::ParamType>&
+           info) { return info.param.name; });
+
+TEST(WriteIndirectObjectTest, WriteFailsAndReturnsWriterStatus) {
+  IndirectObject indirect_obj = {
+      .object_number = 1,
+      .generation_number = 2,
+      .object = {.value = Object::Name{.value = "Catalog"}}};
+  absl::Status expected_status =
+      absl::InternalError("Simulated indirect write failure");
+  FailingWriter writer(expected_status);
+
+  absl::Status status = WriteIndirectObject(indirect_obj, writer);
+
+  EXPECT_THAT(status, StatusIs(absl::StatusCode::kInternal,
+                               HasSubstr("Simulated indirect write failure")));
+}
+
+TEST(WriteObjectTest, ArraySpaceWriteFailure) {
+  Object obj = {.value = Object::Array{
+                    .objects = {Object{.value = Object::Integer{.value = 1}},
+                                Object{.value = Object::Integer{.value = 2}}}}};
+  StepFailingWriter writer(2, absl::InternalError("Simulated space failure"));
+
+  absl::Status status = WriteObject(obj, writer);
+
+  EXPECT_THAT(status, StatusIs(absl::StatusCode::kInternal,
+                               HasSubstr("Simulated space failure")));
+}
+
+TEST(WriteObjectTest, ArrayCloseBracketWriteFailure) {
+  Object obj = {.value = Object::Array{
+                    .objects = {Object{.value = Object::Integer{.value = 1}}}}};
+  StepFailingWriter writer(
+      2, absl::InternalError("Simulated close bracket failure"));
+
+  absl::Status status = WriteObject(obj, writer);
+
+  EXPECT_THAT(status, StatusIs(absl::StatusCode::kInternal,
+                               HasSubstr("Simulated close bracket failure")));
+}
+
+TEST(WriteObjectTest, DictionarySpaceWriteFailure) {
+  Object obj = {
+      .value = Object::Dictionary{
+          .entries = {{"K", Object{.value = Object::Integer{.value = 1}}}}}};
+  StepFailingWriter writer(4, absl::InternalError("Simulated space failure"));
+
+  absl::Status status = WriteObject(obj, writer);
+
+  EXPECT_THAT(status, StatusIs(absl::StatusCode::kInternal,
+                               HasSubstr("Simulated space failure")));
+}
+
+TEST(WriteObjectTest, DictionaryCloseBracketsWriteFailure) {
+  Object obj = {
+      .value = Object::Dictionary{
+          .entries = {{"K", Object{.value = Object::Integer{.value = 1}}}}}};
+  StepFailingWriter writer(
+      6, absl::InternalError("Simulated close brackets failure"));
+
+  absl::Status status = WriteObject(obj, writer);
+
+  EXPECT_THAT(status, StatusIs(absl::StatusCode::kInternal,
+                               HasSubstr("Simulated close brackets failure")));
+}
+
+TEST(WriteIndirectObjectTest, WriteStreamObjectFailure) {
+  IndirectObject indirect_obj = {
+      .object_number = 1,
+      .generation_number = 2,
+      .stream_dictionary =
+          Object::Dictionary{
+              .entries = {{"Length",
+                           Object{.value = Object::Integer{.value = 10}}}}},
+      .object = {.value = Object::Stream{.raw_value = "1234567890"}}};
+  StepFailingWriter writer(23, absl::InternalError("Simulated stream failure"));
+
+  absl::Status status = WriteIndirectObject(indirect_obj, writer);
+
+  EXPECT_THAT(status, StatusIs(absl::StatusCode::kInternal,
+                               HasSubstr("Simulated stream failure")));
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/formats/pdf/reader.cc b/formats/pdf/reader.cc
index dbe3973..6c635a0 100644
--- a/formats/pdf/reader.cc
+++ b/formats/pdf/reader.cc
@@ -202,17 +202,18 @@
     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.");
+    // Xref tables are traversed backwards (newest to oldest via /Prev),
+    // entries encountered earlier represent newer updates and override older
+    // ones.
+    for (const auto& [ref, offset] : prev_xref_table) {
+      if (!free_entries.contains(ref)) {
+        xref_table.try_emplace(ref, offset);
+      }
     }
-    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.");
+    for (const auto& ref : prev_free_entries) {
+      if (!xref_table.contains(ref)) {
+        free_entries.insert(ref);
+      }
     }
   }
   auto reader = absl::WrapUnique(new PdfReader(input, std::move(xref_table),
diff --git a/formats/pdf/reader_test.cc b/formats/pdf/reader_test.cc
index 75dbbb7..cd9cb71 100644
--- a/formats/pdf/reader_test.cc
+++ b/formats/pdf/reader_test.cc
@@ -24,6 +24,7 @@
 #include "absl/container/flat_hash_map.h"
 #include "absl/status/status.h"
 #include "absl/status/status_matchers.h"
+#include "absl/status/statusor.h"
 #include "absl/strings/str_replace.h"
 #include "absl/strings/string_view.h"
 #include "formats/pdf/objects.h"
@@ -42,20 +43,6 @@
 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)}};
 }
@@ -115,12 +102,14 @@
 TEST(PdfReaderTest, ReadsAllObjects) {
   std::string content = GetTestPdfContent();
   riegeli::StringReader<> input(content);
-  ASSERT_OK_AND_ASSIGN(auto reader, PdfReader::Create(&input));
+  absl::StatusOr<std::unique_ptr<PdfReader>> reader = PdfReader::Create(&input);
+  ABSL_ASSERT_OK(reader);
 
   std::vector<IndirectObject> objects;
-  while (reader->HasNext()) {
-    ASSERT_OK_AND_ASSIGN(auto obj, reader->Next());
-    objects.push_back(std::move(obj));
+  while ((*reader)->HasNext()) {
+    absl::StatusOr<IndirectObject> obj = (*reader)->Next();
+    ABSL_ASSERT_OK(obj);
+    objects.push_back(*std::move(obj));
   }
 
   EXPECT_THAT(
@@ -140,26 +129,29 @@
 TEST(PdfReaderTest, FailsWhenReadingPastAllObjects) {
   std::string content = GetTestPdfContent();
   riegeli::StringReader<> input(content);
-  ASSERT_OK_AND_ASSIGN(auto reader, PdfReader::Create(&input));
+  absl::StatusOr<std::unique_ptr<PdfReader>> reader = PdfReader::Create(&input);
+  ABSL_ASSERT_OK(reader);
 
   int64_t count = 0;
-  while (reader->HasNext()) {
-    ASSERT_OK_AND_ASSIGN(auto obj, reader->Next());
+  while ((*reader)->HasNext()) {
+    ABSL_ASSERT_OK((*reader)->Next());
     ++count;
   }
 
   EXPECT_EQ(count, 3);
 
-  EXPECT_THAT(reader->Next(), StatusIs(absl::StatusCode::kOutOfRange,
-                                       HasSubstr("No more objects to read")));
+  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));
+  absl::StatusOr<std::unique_ptr<PdfReader>> reader = PdfReader::Create(&input);
+  ABSL_ASSERT_OK(reader);
 
-  auto file_trailer_dict = reader->file_trailer_dictionary();
+  auto file_trailer_dict = (*reader)->file_trailer_dictionary();
   EXPECT_THAT(file_trailer_dict.entries,
               UnorderedElementsAre(Pair("Root", CreateIndirectRef(3, 0)),
                                    Pair("Size", CreateInteger(3))));
@@ -184,8 +176,9 @@
 %%EOF)";
   content = absl::StrReplaceAll(content, {{"@", " "}});
   riegeli::StringReader<> input(content);
-  ASSERT_OK_AND_ASSIGN(auto reader, PdfReader::Create(&input));
-  EXPECT_THAT(reader->free_entries(),
+  absl::StatusOr<std::unique_ptr<PdfReader>> reader = PdfReader::Create(&input);
+  ABSL_ASSERT_OK(reader);
+  EXPECT_THAT((*reader)->free_entries(),
               ElementsAre(Object::IndirectReference{.object_number = 0,
                                                     .generation_number = 65535},
                           Object::IndirectReference{.object_number = 3,
@@ -197,41 +190,47 @@
 TEST(PdfReaderTest, GetObjectOffset) {
   std::string content = GetTestPdfContent();
   riegeli::StringReader<> input(content);
-  ASSERT_OK_AND_ASSIGN(auto reader, PdfReader::Create(&input));
+  absl::StatusOr<std::unique_ptr<PdfReader>> reader = PdfReader::Create(&input);
+  ABSL_ASSERT_OK(reader);
 
   EXPECT_THAT(
-      reader->GetObjectOffset(/*object_number=*/2, /*generation_number=*/0),
+      (*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));
+  absl::StatusOr<std::unique_ptr<PdfReader>> reader = PdfReader::Create(&input);
+  ABSL_ASSERT_OK(reader);
 
   EXPECT_THAT(
-      reader->GetObjectOffset(/*object_number=*/10, /*generation_number=*/0),
+      (*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));
+  absl::StatusOr<std::unique_ptr<PdfReader>> reader = PdfReader::Create(&input);
+  ABSL_ASSERT_OK(reader);
 
-  ASSERT_OK_AND_ASSIGN(auto obj, reader->GetObject(2, 0));
-  EXPECT_THAT(obj, (IndirectObject{.object_number = 2,
-                                   .generation_number = 0,
-                                   .object = CreateRealNumber("123.456")}));
+  EXPECT_THAT(
+      (*reader)->GetObject(2, 0),
+      IsOkAndHolds(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));
+  absl::StatusOr<std::unique_ptr<PdfReader>> reader = PdfReader::Create(&input);
+  ABSL_ASSERT_OK(reader);
 
-  EXPECT_THAT(reader->GetObject(10, 0), StatusIs(absl::StatusCode::kNotFound,
-                                                 HasSubstr("No offset found")));
+  EXPECT_THAT(
+      (*reader)->GetObject(10, 0),
+      StatusIs(absl::StatusCode::kNotFound, HasSubstr("No offset found")));
 }
 
 TEST(PdfReaderTest, ShortFileCausesInitializationFailure) {
@@ -391,12 +390,14 @@
   content = absl::StrReplaceAll(content, {{"@", " "}});
 
   riegeli::StringReader<> input(content);
-  ASSERT_OK_AND_ASSIGN(auto reader, PdfReader::Create(&input));
+  absl::StatusOr<std::unique_ptr<PdfReader>> reader = PdfReader::Create(&input);
+  ABSL_ASSERT_OK(reader);
 
   std::vector<IndirectObject> objects;
-  while (reader->HasNext()) {
-    ASSERT_OK_AND_ASSIGN(auto obj, reader->Next());
-    objects.push_back(std::move(obj));
+  while ((*reader)->HasNext()) {
+    absl::StatusOr<IndirectObject> obj = (*reader)->Next();
+    ABSL_ASSERT_OK(obj);
+    objects.push_back(*std::move(obj));
   }
 
   ASSERT_THAT(
@@ -566,7 +567,7 @@
                        HasSubstr("Circular cross-reference table detected")));
 }
 
-TEST(PdfReaderTest, CollisionInUseEntriesFailure) {
+TEST(PdfReaderTest, IncrementalUpdateOverlappingXrefEntriesSuccess) {
   std::string content = R"(%PDF-1.7
 1 0 obj
 << /Type /Catalog
@@ -648,10 +649,80 @@
 %%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")));
+  absl::StatusOr<std::unique_ptr<PdfReader>> reader = PdfReader::Create(&input);
+  ABSL_ASSERT_OK(reader);
+
+  // Object 1 was redefined in the second xref section, so it should have the
+  // newer offset.
+  EXPECT_THAT((*reader)->GetObjectOffset(1, 0), IsOkAndHolds(450));
+  // Object 2 was only defined in the first xref section.
+  EXPECT_THAT((*reader)->GetObjectOffset(2, 0), IsOkAndHolds(58));
+}
+
+TEST(PdfReaderTest, IncrementalUpdateReDeclaresObject) {
+  // This test uses exact offsets generated by a custom script to ensure
+  // GetObject can actually read the objects.
+  std::string content = "%PDF-1.7\n%\xe2\xe3\xcf\xd3\n" + std::string(R"(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 612 792] >>
+endobj
+xref
+0 4
+0000000000 65535 f@
+0000000015 00000 n@
+0000000064 00000 n@
+0000000121 00000 n@
+trailer
+<< /Size 4 /Root 1 0 R >>
+startxref
+192
+%%EOF
+1 0 obj
+<< /Type /Catalog /Pages 2 0 R /Note (updated) >>
+endobj
+xref
+1 1
+0000000335 00000 n@
+trailer
+<< /Size 4 /Root 1 0 R /Prev 192 >>
+startxref
+400
+%%EOF)");
+
+  content = absl::StrReplaceAll(content, {{"@", " "}});
+  riegeli::StringReader<> input(content);
+  absl::StatusOr<std::unique_ptr<PdfReader>> reader = PdfReader::Create(&input);
+  ABSL_ASSERT_OK(reader);
+
+  // Object 1 should be the updated one.
+  absl::StatusOr<IndirectObject> obj1 = (*reader)->GetObject(1, 0);
+  ABSL_ASSERT_OK(obj1);
+
+  // Verify it is a dictionary and contains the Note key.
+  absl::StatusOr<Object::Dictionary> dict =
+      Object::As<Object::Dictionary>(obj1->object);
+  ABSL_ASSERT_OK(dict);
+  EXPECT_TRUE(dict->entries.contains("Note"));
+
+  // Verify the value of Note.
+  auto note_val = dict->entries.at("Note");
+  absl::StatusOr<Object::LiteralString> note_str =
+      Object::As<Object::LiteralString>(note_val);
+  ABSL_ASSERT_OK(note_str);
+  EXPECT_EQ(note_str->raw_value, "updated");
+
+  // Object 3 should be the original one from base.
+  absl::StatusOr<IndirectObject> obj3 = (*reader)->GetObject(3, 0);
+  ABSL_ASSERT_OK(obj3);
+  absl::StatusOr<Object::Dictionary> dict3 =
+      Object::As<Object::Dictionary>(obj3->object);
+  ABSL_ASSERT_OK(dict3);
+  EXPECT_TRUE(dict3->entries.contains("MediaBox"));
 }
 
 TEST(PdfReaderTest, CyclicStreamLengthFailure) {
@@ -674,8 +745,9 @@
 %%EOF)";
   content = absl::StrReplaceAll(content, {{"@", " "}});
   riegeli::StringReader<> input(content);
-  ASSERT_OK_AND_ASSIGN(auto reader, PdfReader::Create(&input));
-  EXPECT_THAT(reader->GetObject(1, 0),
+  absl::StatusOr<std::unique_ptr<PdfReader>> reader = PdfReader::Create(&input);
+  ABSL_ASSERT_OK(reader);
+  EXPECT_THAT((*reader)->GetObject(1, 0),
               StatusIs(absl::StatusCode::kInvalidArgument,
                        HasSubstr("Circular reference detected")));
 }
diff --git a/formats/pdf/writer.cc b/formats/pdf/writer.cc
new file mode 100644
index 0000000..8797136
--- /dev/null
+++ b/formats/pdf/writer.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/pdf/writer.h"
+
+#include <cstdint>
+#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/cord.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/string_view.h"
+#include "crypto/algorithms.h"
+#include "formats/hasher_data.h"
+#include "formats/pdf/embedder.h"
+#include "proto/generator.pb.h"
+#include "riegeli/bytes/cord_reader.h"
+#include "riegeli/bytes/cord_writer.h"
+
+namespace credentio {
+namespace {
+
+absl::StatusOr<int64_t> RewriteToCordForEmbedding(
+    riegeli::Reader& input, int64_t estimated_manifest_store_size,
+    absl::Cord* result) {
+  if (!input.Seek(0)) {
+    return input.StatusOrAnnotate(
+        absl::DataLossError("failed to seek to start of input"));
+  }
+  riegeli::CordWriter output(result);
+  PdfEmbedder embedder;
+  ABSL_ASSIGN_OR_RETURN(int64_t manifest_store_offset,
+                        embedder.RewriteForEmbedding(
+                            input, estimated_manifest_store_size, output));
+  if (!output.Close()) {
+    return output.status();
+  }
+  return manifest_store_offset;
+}
+}  // namespace
+
+absl::Status PdfWriter::Write(GenerateManifestStoreRequest request,
+                              riegeli::Reader* absl_nonnull source,
+                              riegeli::Writer* absl_nonnull destination) {
+  if (request.has_hard_binding()) {
+    return absl::InvalidArgumentError(
+        "request must not include `hard_binding` (binding is computed by "
+        "PdfWriter)");
+  }
+
+  // The two-phase approach is required for PDF: The sections to be hashed can't
+  // be finalized until the manifest store size is known, because the manifest
+  // size affects the offsets of objects in the cross-reference table.
+
+  // Phase 1: Rewrite the asset to enable embedding, compute preliminary hard
+  // binding, produce a manifest store template, and embed a zero-filled
+  // placeholder in the asset.
+  HashAlgorithm alg = HashAlgorithm::kSha256;
+  ABSL_ASSIGN_OR_RETURN(*request.mutable_hard_binding()->mutable_data_hash(),
+                        DataHashTemplate(alg));
+  ABSL_ASSIGN_OR_RETURN(ManifestStoreTemplate manifest_store_template,
+                        generator_.GenerateManifestStoreTemplate(request));
+  constexpr int64_t kMaxManifestStoreSizeBytes = 100 * 1024 * 1024;  // 100 MiB
+  if (manifest_store_template.manifest_store_size() <= 0 ||
+      manifest_store_template.manifest_store_size() >
+          kMaxManifestStoreSizeBytes) {
+    return absl::InvalidArgumentError(
+        absl::StrCat("invalid manifest store template size: ",
+                     manifest_store_template.manifest_store_size()));
+  }
+  absl::Cord phase1_result;
+  ABSL_ASSIGN_OR_RETURN(
+      int64_t manifest_store_offset,
+      RewriteToCordForEmbedding(*source,
+                                /*estimated_manifest_store_size=*/
+                                manifest_store_template.manifest_store_size(),
+                                &phase1_result),
+      _ << "embedding manifest store template");
+
+  // Phase 2: Compute final hard binding based on the updated content, finalize
+  // the manifest store, and embed the final manifest store in the asset.
+  riegeli::CordReader phase2_reader(&phase1_result);
+  FinalizeManifestStoreRequest finalize_request;
+  *finalize_request.mutable_manifest_store_template() =
+      std::move(manifest_store_template);
+  ABSL_ASSIGN_OR_RETURN(
+      *finalize_request.mutable_hard_binding()->mutable_data_hash(),
+      DigestDataHash(
+          phase2_reader, HashAlgorithm::kSha256,
+          /*manifest_store_offset=*/manifest_store_offset,
+          /*manifest_store_length=*/
+          finalize_request.manifest_store_template().manifest_store_size()));
+  ABSL_ASSIGN_OR_RETURN(auto response,
+                        generator_.FinalizeManifestStore(finalize_request));
+  if (response.manifest_store().size() !=
+      finalize_request.manifest_store_template().manifest_store_size()) {
+    return absl::InternalError("manifest store size does not match template");
+  }
+  ABSL_RETURN_IF_ERROR(PdfEmbedder().Embed(
+      response.manifest_store(), manifest_store_offset, phase1_result));
+  if (!destination->Write(phase1_result)) {
+    return absl::InternalError("failed to write result");
+  }
+  return absl::OkStatus();
+}
+}  // namespace credentio
diff --git a/formats/pdf/writer.h b/formats/pdf/writer.h
new file mode 100644
index 0000000..74b9ab0
--- /dev/null
+++ b/formats/pdf/writer.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_PDF_WRITER_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_PDF_WRITER_H_
+
+#include "absl/base/nullability.h"
+#include "absl/log/die_if_null.h"
+#include "absl/status/status.h"
+#include "generator/generator.h"
+#include "proto/generator.pb.h"
+#include "riegeli/bytes/reader.h"
+#include "riegeli/bytes/writer.h"
+
+namespace credentio {
+
+// Writer of C2PA manifest stores into PDF assets.
+class PdfWriter {
+ public:
+  // Creates a PDF writer.
+  //
+  // The generator must outlive the writer.
+  explicit PdfWriter(const ManifestStoreGenerator* absl_nonnull generator)
+      : generator_(*ABSL_DIE_IF_NULL(generator)) {}
+
+  // Generates a C2PA manifest store and embeds it into a copy of the given
+  // asset. The riegeli::Reader and riegeli::Writer cannot be pointed to the
+  // same underlying data. The request must not include `hard_binding`.
+
+  // Fails if C2PA metadata already exists.
+  absl::Status Write(GenerateManifestStoreRequest request,
+                     riegeli::Reader* absl_nonnull source,
+                     riegeli::Writer* absl_nonnull destination);
+
+ private:
+  const ManifestStoreGenerator& generator_;
+};
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_PDF_WRITER_H_
diff --git a/formats/pdf/writer_test.cc b/formats/pdf/writer_test.cc
new file mode 100644
index 0000000..9656960
--- /dev/null
+++ b/formats/pdf/writer_test.cc
@@ -0,0 +1,197 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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/writer.h"
+
+#include <cstdint>
+#include <memory>
+#include <string>
+#include <utility>
+
+#include "absl/log/absl_check.h"
+#include "absl/status/status.h"
+#include "absl/status/status_matchers.h"
+#include "absl/strings/string_view.h"
+#include "formats/byte_range.h"
+#include "formats/pdf/extractor.h"
+#include "generator/generator.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "proto/generator.pb.h"
+#include "proto/manifest.pb.h"
+#include "proto/validation_status.pb.h"
+#include "riegeli/bytes/string_reader.h"
+#include "riegeli/bytes/string_writer.h"
+#include "testing/fake_manifest_store_generator.h"
+#include "testing/nonprod_generator.h"
+#include "testing/nonprod_validator.h"
+#include "validator/asset_validator.h"
+
+namespace credentio {
+namespace {
+
+using ::absl_testing::IsOkAndHolds;
+using ::absl_testing::StatusIs;
+using ::testing::AllOf;
+using ::testing::Field;
+using ::testing::Gt;
+using ::testing::HasSubstr;
+using ::testing::IsEmpty;
+using ::testing::Optional;
+
+constexpr absl::string_view kTestPdfContent = 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 4>>
+startxref
+98
+%%EOF
+)";
+
+class PdfWriterTest : public ::testing::Test {
+ protected:
+  void SetUp() override {
+    auto generator = CreateNonProdManifestStoreGenerator({});
+    ABSL_CHECK_OK(generator);
+    generator_ = std::move(*generator);
+    writer_ = std::make_unique<PdfWriter>(generator_.get());
+    auto validator = CreateNonProdValidator({});
+    ABSL_CHECK_OK(validator);
+    validator_ = std::move(*validator);
+  }
+  std::unique_ptr<ManifestStoreGenerator> generator_;
+  std::unique_ptr<PdfWriter> writer_;
+  std::unique_ptr<AssetValidator> validator_;
+};
+
+TEST_F(PdfWriterTest, Write) {
+  GenerateManifestStoreRequest request;
+  request.mutable_actions()
+      ->mutable_created()
+      ->mutable_action_metadata()
+      ->set_description("test only");
+  riegeli::StringReader source(kTestPdfContent);
+  ABSL_ASSERT_OK(source.status());
+  std::string result;
+  riegeli::StringWriter destination(&result);
+  ABSL_ASSERT_OK(destination.status());
+  ABSL_ASSERT_OK(writer_->Write(request, &source, &destination));
+  ASSERT_TRUE(source.Close());
+  ASSERT_TRUE(destination.Close());
+
+  riegeli::StringReader result_reader(result);
+  ABSL_ASSERT_OK(result_reader.status());
+
+  EXPECT_THAT(PdfExtractor().ExtractManifestStoreLocation(result_reader, {}),
+              IsOkAndHolds(Optional(AllOf(Field(&ByteRange::offset, Gt(0)),
+                                          Field(&ByteRange::length, Gt(0))))));
+
+  ASSERT_TRUE(result_reader.Seek(0));
+  auto validation_result =
+      validator_->Validate(result_reader, "application/pdf");
+  ABSL_ASSERT_OK(validation_result);
+  EXPECT_THAT(
+      (*validation_result)->proto().active_manifest().validation().failures(),
+      IsEmpty());
+}
+
+TEST_F(PdfWriterTest, WriteTwiceFails) {
+  GenerateManifestStoreRequest request;
+  request.mutable_actions()
+      ->mutable_created()
+      ->mutable_action_metadata()
+      ->set_description("test only");
+
+  riegeli::StringReader source1(kTestPdfContent);
+  ABSL_ASSERT_OK(source1.status());
+  std::string result1;
+  riegeli::StringWriter destination1(&result1);
+  ABSL_ASSERT_OK(destination1.status());
+  ABSL_ASSERT_OK(writer_->Write(request, &source1, &destination1));
+  ASSERT_TRUE(source1.Close());
+  ASSERT_TRUE(destination1.Close());
+
+  riegeli::StringReader source2(result1);
+  ABSL_ASSERT_OK(source2.status());
+  std::string result2;
+  riegeli::StringWriter destination2(&result2);
+  ABSL_ASSERT_OK(destination2.status());
+  EXPECT_THAT(
+      writer_->Write(request, &source2, &destination2),
+      StatusIs(absl::StatusCode::kAlreadyExists, HasSubstr("already exists")));
+}
+
+TEST_F(PdfWriterTest, WriteWithHardBindingFails) {
+  GenerateManifestStoreRequest request;
+  request.mutable_actions()
+      ->mutable_created()
+      ->mutable_action_metadata()
+      ->set_description("test only");
+  request.mutable_hard_binding();
+
+  riegeli::StringReader source(kTestPdfContent);
+  ABSL_ASSERT_OK(source.status());
+  std::string result;
+  riegeli::StringWriter destination(&result);
+  ABSL_ASSERT_OK(destination.status());
+  EXPECT_THAT(writer_->Write(request, &source, &destination),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("must not include `hard_binding`")));
+}
+
+class PdfWriterInvalidSizeTest : public ::testing::TestWithParam<int64_t> {};
+
+TEST_P(PdfWriterInvalidSizeTest, RejectsInvalidManifestStoreSize) {
+  int64_t invalid_size = GetParam();
+  FakeManifestStoreGenerator generator;
+  generator.mutable_manifest_store_template()->set_manifest_store_size(
+      invalid_size);
+  PdfWriter writer(&generator);
+  GenerateManifestStoreRequest request;
+  request.mutable_actions()
+      ->mutable_created()
+      ->mutable_action_metadata()
+      ->set_description("test only");
+  riegeli::StringReader source(kTestPdfContent);
+  ABSL_ASSERT_OK(source.status());
+  std::string result;
+  riegeli::StringWriter destination(&result);
+  ABSL_ASSERT_OK(destination.status());
+
+  EXPECT_THAT(writer.Write(request, &source, &destination),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("invalid manifest store template size:")));
+}
+
+INSTANTIATE_TEST_SUITE_P(PdfWriterInvalidSizeTests, PdfWriterInvalidSizeTest,
+                         ::testing::Values(-5, 0, 100LL * 1024 * 1024 + 1));
+
+}  // namespace
+}  // namespace credentio
diff --git a/formats/png/BUILD b/formats/png/BUILD
index 493902a..0fa09aa 100644
--- a/formats/png/BUILD
+++ b/formats/png/BUILD
@@ -175,3 +175,126 @@
         "@abseil-cpp//absl/status:statusor",
     ],
 )
+
+cc_library(
+    name = "hasher",
+    srcs = ["hasher.cc"],
+    hdrs = ["hasher.h"],
+    deps = [
+        ":constants",
+        ":reader",
+        "//bindings:input_hasher",
+        "//crypto:algorithms",
+        "//formats:byte_range",
+        "//proto:data_hash_assertion_cc_proto",
+        "@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_library(
+    name = "embedder",
+    srcs = ["embedder.cc"],
+    hdrs = ["embedder.h"],
+    deps = [
+        ":constants",
+        ":crc",
+        ":hasher",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/strings:string_view",
+        "@riegeli//riegeli/bytes:copy_all",
+        "@riegeli//riegeli/bytes:reader",
+        "@riegeli//riegeli/bytes:writer",
+        "@riegeli//riegeli/endian:endian_writing",
+    ],
+)
+
+cc_test(
+    name = "embedder_test",
+    srcs = ["embedder_test.cc"],
+    deps = [
+        ":embedder",
+        ":extractor",
+        ":hasher",
+        ":test_utils",
+        "//formats:asset_byte_info",
+        "//formats:byte_range",
+        "//formats:extractor_result",
+        "@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",
+        "@riegeli//riegeli/bytes:string_writer",
+    ],
+)
+
+cc_library(
+    name = "writer",
+    srcs = ["writer.cc"],
+    hdrs = ["writer.h"],
+    deps = [
+        ":constants",
+        ":embedder",
+        ":hasher",
+        ":reader",
+        "//async:async_context",
+        "//crypto:algorithms",
+        "//formats:byte_range",
+        "//generator",
+        "//proto:generator_cc_proto",
+        "@abseil-cpp//absl/base:nullability",
+        "@abseil-cpp//absl/functional:any_invocable",
+        "@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/synchronization",
+        "@riegeli//riegeli/bytes:reader",
+        "@riegeli//riegeli/bytes:writer",
+    ],
+)
+
+cc_test(
+    name = "writer_test",
+    srcs = ["writer_test.cc"],
+    deps = [
+        ":extractor",
+        ":reader",
+        ":test_utils",
+        ":writer",
+        "//async:async_context",
+        "//formats:byte_range",
+        "//generator",
+        "//proto:generator_cc_proto",
+        "//proto:ingredient_assertion_cc_proto",
+        "//proto:ingredient_validation_result_cc_proto",
+        "//proto:manifest_cc_proto",
+        "//proto:validation_status_cc_proto",
+        "//testing:nonprod_generator",
+        "//testing:nonprod_validator",
+        "//validator:asset_validator",
+        "//validator:ingredient_result",
+        "//validator:ingredient_result_converter",
+        "//validator:result",
+        "@abseil-cpp//absl/log:absl_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:string_view",
+        "@abseil-cpp//absl/synchronization",
+        "@googletest//:gtest_main",
+        "@riegeli//riegeli/bytes:reader",
+        "@riegeli//riegeli/bytes:string_reader",
+        "@riegeli//riegeli/bytes:string_writer",
+    ],
+)
diff --git a/formats/png/embedder.cc b/formats/png/embedder.cc
new file mode 100644
index 0000000..bdb7ff0
--- /dev/null
+++ b/formats/png/embedder.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/embedder.h"
+
+#include <cstdint>
+
+#include "absl/status/status.h"
+#include "absl/status/status_macros.h"
+#include "absl/strings/string_view.h"
+#include "formats/png/constants.h"
+#include "formats/png/crc.h"
+#include "formats/png/hasher.h"
+#include "riegeli/bytes/copy_all.h"
+#include "riegeli/bytes/reader.h"
+#include "riegeli/bytes/writer.h"
+#include "riegeli/endian/endian_writing.h"
+
+namespace credentio {
+namespace {
+
+// Returns the offset of the data.
+absl::Status WriteChunk(absl::string_view type, absl::string_view data,
+                        riegeli::Writer& writer) {
+  if (!riegeli::WriteBigEndian<uint32_t>(data.size(), writer)) {
+    return writer.StatusOrAnnotate(
+        absl::InternalError("Failed to write chunk size"));
+  }
+  if (!writer.Write(type)) {
+    return writer.StatusOrAnnotate(
+        absl::InternalError("Failed to write chunk type"));
+  }
+  if (!data.empty() && !writer.Write(data)) {
+    return writer.StatusOrAnnotate(
+        absl::InternalError("Failed to write chunk data"));
+  }
+  uint32_t crc = PngChunkCrc(type, data);
+  if (!riegeli::WriteBigEndian<uint32_t>(crc, writer)) {
+    return writer.StatusOrAnnotate(
+        absl::InternalError("Failed to write chunk CRC"));
+  }
+  return absl::OkStatus();
+}
+
+}  // namespace
+
+absl::Status PngEmbedder::CopyAndEmbed(riegeli::Reader& reader,
+                                       absl::string_view manifest_store,
+                                       const DigestPngResult& digest_result,
+                                       riegeli::Writer& writer) const {
+  if (!reader.Seek(0) || reader.pos() != 0) {
+    return reader.StatusOrAnnotate(
+        absl::InternalError("failed to seek to beginning of reader"));
+  }
+
+  // Write everything up to the insertion offset.
+  if (!reader.Copy(digest_result.insertion_offset, writer)) {
+    return reader.StatusOrAnnotate(
+        absl::InternalError("Failed to copy data up to insertion offset"));
+  }
+
+  // Write the manifest store.
+  ABSL_RETURN_IF_ERROR(WriteChunk(kPngChunkTypeC2pa, manifest_store, writer));
+  if (!reader.Skip(digest_result.existing_c2pa_length.value_or(0))) {
+    return reader.StatusOrAnnotate(
+        absl::InternalError("Failed to skip existing C2PA chunk"));
+  }
+
+  // Write the rest of the file.
+  return riegeli::CopyAll(reader, writer);
+}
+
+}  // namespace credentio
diff --git a/formats/png/embedder.h b/formats/png/embedder.h
new file mode 100644
index 0000000..a6cd6d9
--- /dev/null
+++ b/formats/png/embedder.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_EMBEDDER_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_PNG_EMBEDDER_H_
+
+#include "absl/status/status.h"
+#include "absl/strings/string_view.h"
+#include "formats/png/hasher.h"
+#include "riegeli/bytes/reader.h"
+#include "riegeli/bytes/writer.h"
+
+namespace credentio {
+
+// A utility class for embedding a C2PA manifest store as a `caBX` chunk in a
+// non-C2PA PNG file.
+class PngEmbedder {
+ public:
+  PngEmbedder() = default;
+
+  // Copies the PNG read from `reader` to `writer`, inserting `manifest_store`
+  // as a `caBX` chunk at `digest_result.insertion_offset`, the offset
+  // `DigestPng()` reserved for it: the start of the first `IDAT` chunk, or of
+  // an already existing `caBX` chunk.
+  //
+  // An existing `caBX` chunk, whose size `digest_result` reports as
+  // `existing_c2pa_length`, is replaced by the new one.
+  //
+  // NOTE: Input and output must be disjoint, i.e., in-place embedding is not
+  // supported.
+  absl::Status CopyAndEmbed(riegeli::Reader& reader,
+                            absl::string_view manifest_store,
+                            const DigestPngResult& digest_result,
+                            riegeli::Writer& writer) const;
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_PNG_EMBEDDER_H_
diff --git a/formats/png/embedder_test.cc b/formats/png/embedder_test.cc
new file mode 100644
index 0000000..f57d6b1
--- /dev/null
+++ b/formats/png/embedder_test.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 "formats/png/embedder.h"
+
+#include <cstdint>
+#include <optional>
+#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/string_view.h"
+#include "formats/asset_byte_info.h"
+#include "formats/byte_range.h"
+#include "formats/extractor_result.h"
+#include "formats/png/extractor.h"
+#include "formats/png/hasher.h"
+#include "formats/png/test_utils.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::StatusIs;
+using ::testing::ElementsAre;
+using ::testing::Eq;
+using ::testing::HasSubstr;
+using ::testing::SizeIs;
+
+constexpr int64_t kChunkHeaderSize = 8;
+
+absl::StatusOr<ExtractorResult> Extract(absl::string_view png) {
+  riegeli::StringReader input(png);
+  PngExtractor extractor;
+  ExtractorResult result = {.manifest_store = "", .asset_byte_info = {}};
+
+  ABSL_ASSIGN_OR_RETURN(result.manifest_store,
+                        extractor.ExtractManifestStore(input));
+
+  if (!input.Seek(0)) {
+    return input.StatusOrAnnotate(absl::DataLossError("Failure to seek"));
+  }
+  ABSL_ASSIGN_OR_RETURN(std::optional<ByteRange> manifest_store_location,
+                        extractor.ExtractManifestStoreLocation(input, {}));
+  result.asset_byte_info.manifest_store_location =
+      manifest_store_location.value_or(ByteRange{.offset = 0, .length = 0});
+
+  if (!input.Seek(0)) {
+    return input.StatusOrAnnotate(absl::DataLossError("Failure to seek"));
+  }
+  ABSL_ASSIGN_OR_RETURN(result.asset_byte_info.boxes,
+                        extractor.ExtractBoxes(input, {}));
+  return result;
+}
+
+std::vector<std::string> GetBoxNames(const ExtractorResult& result) {
+  std::vector<std::string> box_names;
+  for (const auto& box : result.asset_byte_info.boxes) {
+    box_names.push_back(box.identifier);
+  }
+  return box_names;
+}
+
+TEST(PngEmbedderTest, CopyAndEmbedBeforeIdat) {
+  absl::StatusOr<std::string> input_s =
+      CreatePng({{.type = 'ABCD', .payload = "first chunk"},
+                 {.type = 'EFGH', .payload = "second chunk"},
+                 {.type = 'IDAT', .payload = "third chunk"},
+                 {.type = 'XYZW', .payload = "fourth chunk"}});
+  ABSL_ASSERT_OK(input_s);
+  constexpr int64_t kInsertionOffset = 55;
+  constexpr absl::string_view kManifestStore = "C2PA manifest store bytes";
+  riegeli::StringReader input(*input_s);
+  std::string output_s;
+  riegeli::StringWriter output(&output_s);
+
+  PngEmbedder embedder;
+  ABSL_ASSERT_OK(embedder.CopyAndEmbed(
+      input, kManifestStore, DigestPngResult{.insertion_offset = 55}, output));
+  ASSERT_TRUE(output.Close());
+
+  EXPECT_THAT(output_s.substr(kInsertionOffset + kChunkHeaderSize,
+                              kManifestStore.size()),
+              Eq(kManifestStore));
+  EXPECT_THAT(output_s, SizeIs(input_s->size() + 12 + kManifestStore.size()));
+  absl::StatusOr<ExtractorResult> extracted = Extract(output_s);
+  ABSL_ASSERT_OK(extracted);
+  EXPECT_THAT(extracted->manifest_store, Eq(kManifestStore));
+  EXPECT_THAT(
+      GetBoxNames(*extracted),
+      ElementsAre("PNGh", "ABCD", "EFGH", "C2PA", "IDAT", "XYZW", "IEND"));
+}
+
+TEST(PngEmbedderTest, SeeksToBeginningBeforeCopying) {
+  absl::StatusOr<std::string> input_s =
+      CreatePng({{.type = 'ABCD', .payload = "first chunk"},
+                 {.type = 'EFGH', .payload = "second chunk"},
+                 {.type = 'IDAT', .payload = "third chunk"},
+                 {.type = 'XYZW', .payload = "fourth chunk"}});
+  ABSL_ASSERT_OK(input_s);
+  constexpr int64_t kInsertionOffset = 55;
+  constexpr absl::string_view kManifestStore = "C2PA manifest store bytes";
+  riegeli::StringReader input(*input_s);
+  std::string output_s;
+  riegeli::StringWriter output(&output_s);
+
+  // Seek to a non-zero offset.
+  ASSERT_TRUE(input.Seek(10));
+
+  PngEmbedder embedder;
+  ABSL_ASSERT_OK(embedder.CopyAndEmbed(
+      input, kManifestStore, DigestPngResult{.insertion_offset = 55}, output));
+  ASSERT_TRUE(output.Close());
+
+  EXPECT_THAT(output_s.substr(kInsertionOffset + kChunkHeaderSize,
+                              kManifestStore.size()),
+              Eq(kManifestStore));
+  EXPECT_THAT(output_s, SizeIs(input_s->size() + 12 + kManifestStore.size()));
+  absl::StatusOr<ExtractorResult> extracted = Extract(output_s);
+  ABSL_ASSERT_OK(extracted);
+  EXPECT_THAT(extracted->manifest_store, Eq(kManifestStore));
+  EXPECT_THAT(
+      GetBoxNames(*extracted),
+      ElementsAre("PNGh", "ABCD", "EFGH", "C2PA", "IDAT", "XYZW", "IEND"));
+}
+
+TEST(PngEmbedderTest, CopyAndEmbedBeforeIend) {
+  absl::StatusOr<std::string> input_s =
+      CreatePng({{.type = 'ABCD', .payload = "first chunk"},
+                 {.type = 'EFGH', .payload = "second chunk"},
+                 {.type = 'XYZW', .payload = "third chunk"}});
+  ABSL_ASSERT_OK(input_s);
+  constexpr int64_t kInsertionOffset = 78;
+  constexpr absl::string_view kManifestStore = "C2PA manifest store bytes";
+  riegeli::StringReader input(*input_s);
+  std::string output_s;
+  riegeli::StringWriter output(&output_s);
+
+  PngEmbedder embedder;
+  ABSL_ASSERT_OK(embedder.CopyAndEmbed(
+      input, kManifestStore,
+      DigestPngResult{.insertion_offset = kInsertionOffset}, output));
+  ASSERT_TRUE(output.Close());
+
+  EXPECT_THAT(output_s.substr(kInsertionOffset + kChunkHeaderSize,
+                              kManifestStore.size()),
+              Eq(kManifestStore));
+  EXPECT_THAT(output_s, SizeIs(input_s->size() + 12 + kManifestStore.size()));
+  absl::StatusOr<ExtractorResult> extracted = Extract(output_s);
+  ABSL_ASSERT_OK(extracted);
+  EXPECT_THAT(extracted->manifest_store, Eq(kManifestStore));
+  EXPECT_THAT(GetBoxNames(*extracted),
+              ElementsAre("PNGh", "ABCD", "EFGH", "XYZW", "C2PA", "IEND"));
+}
+
+TEST(PngEmbedderTest, CopyAndEmbedC2paReplaceBeforeIdat) {
+  absl::StatusOr<std::string> input_s =
+      CreatePng({{.type = 'ABCD', .payload = "first chunk"},
+                 {.type = 'caBX', .payload = "C2PA chunk"},
+                 {.type = 'IDAT', .payload = "some data"}});
+  ABSL_ASSERT_OK(input_s);
+  constexpr int64_t kInsertionOffset = 31;
+  constexpr int64_t kExistingC2paLength = 22;
+  constexpr absl::string_view kManifestStore = "C2PA manifest store bytes";
+  riegeli::StringReader input(*input_s);
+  std::string output_s;
+  riegeli::StringWriter output(&output_s);
+  PngEmbedder embedder;
+  ABSL_ASSERT_OK(embedder.CopyAndEmbed(
+      input, kManifestStore,
+      DigestPngResult{.insertion_offset = kInsertionOffset,
+                      .existing_c2pa_length = kExistingC2paLength},
+      output));
+  ASSERT_TRUE(output.Close());
+
+  EXPECT_THAT(output_s.substr(kInsertionOffset + kChunkHeaderSize,
+                              kManifestStore.size()),
+              Eq(kManifestStore));
+  absl::StatusOr<ExtractorResult> extracted = Extract(output_s);
+  ABSL_ASSERT_OK(extracted);
+  EXPECT_THAT(extracted->manifest_store, Eq(kManifestStore));
+  EXPECT_THAT(GetBoxNames(*extracted),
+              ElementsAre("PNGh", "ABCD", "C2PA", "IDAT", "IEND"));
+}
+
+TEST(PngEmbedderTest, CopyAndEmbedC2paAlreadyExistsAfterIdat_Replace) {
+  // Note that the digest operation would fail because there is a C2PA chunk
+  // after the IDAT so this scenario would never actually occur.
+  absl::StatusOr<std::string> input_s = CreatePng({
+      {.type = 'ABCD', .payload = "first chunk"},
+      {.type = 'IDAT', .payload = "some data"},
+      {.type = 'caBX', .payload = "C2PA chunk"},
+  });
+  ABSL_ASSERT_OK(input_s);
+  constexpr int64_t kInsertionOffset = 31;
+  constexpr absl::string_view kManifestStore = "C2PA manifest store bytes";
+  riegeli::StringReader input(*input_s);
+  std::string output_s;
+  riegeli::StringWriter output(&output_s);
+  PngEmbedder embedder;
+  ABSL_ASSERT_OK(embedder.CopyAndEmbed(
+      input, kManifestStore,
+      DigestPngResult{.insertion_offset = kInsertionOffset}, output));
+  ASSERT_TRUE(output.Close());
+
+  EXPECT_THAT(output_s.substr(kInsertionOffset + kChunkHeaderSize,
+                              kManifestStore.size()),
+              Eq(kManifestStore));
+  EXPECT_THAT(Extract(output_s),
+              StatusIs(absl::StatusCode::kNotFound,
+                       HasSubstr("Multiple manifest stores found")));
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/formats/png/extractor_test.cc b/formats/png/extractor_test.cc
index faff925..323701c 100644
--- a/formats/png/extractor_test.cc
+++ b/formats/png/extractor_test.cc
@@ -387,7 +387,7 @@
       {.type = 'IDAT', .payload = "ignored IDAT chunk"},
       {.type = 'caBX', .payload = "A C2PA manifest store", .crc = 0xdb50c4b5},
   });
-  ASSERT_THAT(png, ::absl_testing::IsOk());
+  ABSL_ASSERT_OK(png);
   riegeli::StringReader<> input(*png);
 
   // Set end_offset to 38 (right before caBX).
diff --git a/formats/png/hasher.cc b/formats/png/hasher.cc
new file mode 100644
index 0000000..d1d3482
--- /dev/null
+++ b/formats/png/hasher.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 "formats/png/hasher.h"
+
+#include <algorithm>
+#include <cstddef>
+#include <cstdint>
+#include <memory>
+#include <optional>
+#include <string>
+#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 "bindings/input_hasher.h"
+#include "crypto/algorithms.h"
+#include "formats/byte_range.h"
+#include "formats/png/constants.h"
+#include "formats/png/reader.h"
+#include "proto/data_hash_assertion.pb.h"
+#include "riegeli/bytes/reader.h"
+
+namespace credentio {
+
+namespace {
+
+constexpr uint64_t kMaxChunkBytes = 1024 * 1024;  // 1 MiB
+
+}  // namespace
+
+absl::StatusOr<DigestPngResult> DigestPng(
+    riegeli::Reader& input, HashAlgorithm alg,
+    std::optional<PngExclusionProcessor> exclusion_processor) {
+  DigestPngResult result;
+  result.assertion.set_alg(absl::StrCat(alg));
+
+  std::unique_ptr<InputHasher> hasher;
+  ABSL_ASSIGN_OR_RETURN(hasher, InputHasher::Create(alg));
+
+  std::optional<int64_t> insertion_offset = std::nullopt;
+
+  ABSL_RETURN_IF_ERROR(IterateOverPngChunks(
+      input,
+      [&result, &input, &hasher, &insertion_offset,
+       &exclusion_processor](const PngChunk& chunk) -> absl::Status {
+        if (chunk.type == kPngChunkTypeC2pa) {
+          if (insertion_offset.has_value()) {
+            // Multiple C2PA chunks, or C2PA chunk after IDAT chunk.
+            return absl::InvalidArgumentError("Unexpected C2PA chunk found");
+          }
+          // Since there is an existing C2PA chunk, overwrite it with the new
+          // one.
+          insertion_offset = chunk.offset;
+          result.existing_c2pa_length = chunk.length;
+          DataRange* exclusion = result.assertion.add_exclusions();
+          exclusion->set_start(chunk.offset);
+          exclusion->set_length(0);
+          return absl::OkStatus();
+        }
+
+        if (!insertion_offset.has_value() && chunk.type == kPngChunkTypeIdat) {
+          // Insert the manifest store immediately before the first IDAT chunk
+          // unless there is an existing C2PA chunk.
+          insertion_offset = chunk.offset;
+          DataRange* exclusion = result.assertion.add_exclusions();
+          exclusion->set_start(chunk.offset);
+          exclusion->set_length(0);
+        }
+
+        if (exclusion_processor.has_value()) {
+          ABSL_ASSIGN_OR_RETURN(std::vector<ByteRange> exclusions,
+                                (*exclusion_processor)(input, chunk));
+          if (!exclusions.empty()) {
+            int64_t next_idx_to_hash = chunk.offset;
+            for (const auto& ex : exclusions) {
+              if (ex.offset < next_idx_to_hash ||
+                  ex.offset + ex.length > chunk.offset + chunk.length) {
+                return absl::InvalidArgumentError(
+                    absl::StrCat("Invalid or out-of-order exclusion range [",
+                                 ex.offset, ", ", ex.offset + ex.length,
+                                 ") for chunk at ", chunk.offset));
+              }
+
+              if (next_idx_to_hash < ex.offset) {
+                ABSL_RETURN_IF_ERROR(hasher->Update(
+                    input, next_idx_to_hash, ex.offset - next_idx_to_hash));
+              }
+
+              DataRange* exclusion = result.assertion.add_exclusions();
+              exclusion->set_start(ex.offset);
+              exclusion->set_length(ex.length);
+
+              next_idx_to_hash = ex.offset + ex.length;
+            }
+            // Hash remaining bytes (includes CRC)
+            if (next_idx_to_hash < (chunk.offset + chunk.length)) {
+              ABSL_RETURN_IF_ERROR(hasher->Update(
+                  input, next_idx_to_hash,
+                  chunk.length - (next_idx_to_hash - chunk.offset)));
+            }
+            return absl::OkStatus();
+          }
+        }
+
+        if (!input.Seek(chunk.offset)) {
+          return absl::InternalError("failed to seek to chunk offset");
+        }
+        uint64_t remaining_bytes = chunk.length;
+        while (remaining_bytes > 0) {
+          size_t chunk_size = std::min(remaining_bytes, kMaxChunkBytes);
+          std::string chunk_data;
+          if (!input.Read(chunk_size, chunk_data)) {
+            return absl::DataLossError("failed to read box data");
+          }
+          hasher->Update(chunk_data);
+          remaining_bytes -= chunk_size;
+        }
+        return absl::OkStatus();
+      }));
+
+  if (!insertion_offset.has_value()) {
+    return absl::InvalidArgumentError("No IDAT or C2PA chunk found");
+  }
+  result.insertion_offset = *insertion_offset;
+  // Complete the entry for the post-C2PA boxes.
+  result.assertion.set_hash(hasher->Digest());
+  return result;
+}
+
+}  // namespace credentio
diff --git a/formats/png/hasher.h b/formats/png/hasher.h
new file mode 100644
index 0000000..275e6b9
--- /dev/null
+++ b/formats/png/hasher.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.
+//
+
+#ifndef THIRD_PARTY_CREDENTIO_FORMATS_PNG_HASHER_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_PNG_HASHER_H_
+
+#include <cstdint>
+#include <optional>
+#include <vector>
+
+#include "absl/functional/function_ref.h"
+#include "absl/status/statusor.h"
+#include "crypto/algorithms.h"
+#include "formats/byte_range.h"
+#include "formats/png/reader.h"
+#include "proto/data_hash_assertion.pb.h"
+#include "riegeli/bytes/reader.h"
+
+namespace credentio {
+
+struct DigestPngResult {
+  DataHashAssertion assertion;
+  int64_t insertion_offset;
+  std::optional<int64_t> existing_c2pa_length;
+};
+
+// Determines byte ranges within a given PNG chunk to be excluded from hashing.
+// Returned ByteRange offsets must be absolute file offsets within
+// [chunk.offset, chunk.offset + chunk.length).
+using PngExclusionProcessor =
+    absl::FunctionRef<absl::StatusOr<std::vector<ByteRange>>(
+        riegeli::Reader& reader, const PngChunk& chunk)>;
+
+// Digests the given PNG image and returns DigestPngResult that can be used to
+// generate a C2PA manifest store. It is assumed that a C2PA manifest store will
+// be inserted immediately before the first IDAT chunk or already appears
+// somewhere before the first IDAT chunk.
+//
+// If `exclusion_processor` is provided, it is invoked for chunks to identify
+// byte ranges to exclude from hashing.
+absl::StatusOr<DigestPngResult> DigestPng(
+    riegeli::Reader& input, HashAlgorithm alg,
+    std::optional<PngExclusionProcessor> exclusion_processor = std::nullopt);
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_PNG_HASHER_H_
diff --git a/formats/png/writer.cc b/formats/png/writer.cc
new file mode 100644
index 0000000..ce513f6
--- /dev/null
+++ b/formats/png/writer.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 "formats/png/writer.h"
+
+#include <cstdint>
+#include <optional>
+#include <utility>
+
+#include "absl/base/nullability.h"
+#include "absl/functional/any_invocable.h"
+#include "absl/log/die_if_null.h"
+#include "absl/status/status.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/string_view.h"
+#include "absl/synchronization/notification.h"
+#include "async/async_context.h"
+#include "crypto/algorithms.h"
+#include "formats/png/constants.h"
+#include "formats/png/embedder.h"
+#include "formats/png/hasher.h"
+#include "formats/png/reader.h"
+#include "generator/generator.h"
+#include "proto/generator.pb.h"
+#include "riegeli/bytes/reader.h"
+#include "riegeli/bytes/writer.h"
+
+namespace credentio {
+
+PngWriter::PngWriter(const ManifestStoreGenerator* absl_nonnull generator,
+                     Options options)
+    : generator_(*ABSL_DIE_IF_NULL(generator)), options_(std::move(options)) {}
+
+absl::Status PngWriter::Write(GenerateManifestStoreRequest request,
+                              riegeli::Reader* absl_nonnull source,
+                              riegeli::Writer* absl_nonnull destination,
+                              OutputMetadata* absl_nullable output_metadata) {
+  absl::Notification done;
+  absl::Status status;
+  Write(std::move(request), source, destination, output_metadata,
+        AsyncContext(), [&](absl::Status s) {
+          status = std::move(s);
+          done.Notify();
+        });
+  done.WaitForNotification();
+  return status;
+}
+
+void PngWriter::Write(GenerateManifestStoreRequest request,
+                      riegeli::Reader* absl_nonnull source,
+                      riegeli::Writer* absl_nonnull destination,
+                      OutputMetadata* absl_nullable output_metadata,
+                      AsyncContext context,
+                      absl::AnyInvocable<void(absl::Status) &&> cb) {
+  AsyncResolver<void> resolver(context, std::move(cb));
+  if (request.has_hard_binding()) {
+    resolver.Return(absl::InvalidArgumentError(
+        "request must not include `hard_binding` (binding is computed by "
+        "PngWriter)"));
+    return;
+  }
+
+  // DigestPng() takes an optional processor over (reader, chunk), so bind the
+  // request, which stays alive for the duration of the digest below.
+  auto exclusion_fn = [this, &request](riegeli::Reader& reader,
+                                       const PngChunk& chunk) {
+    return options_.exclusion_processor(reader, chunk, request);
+  };
+  std::optional<PngExclusionProcessor> exclusion_processor = std::nullopt;
+  if (options_.exclusion_processor != nullptr) {
+    exclusion_processor.emplace(std::move(exclusion_fn));
+  }
+  // Hashes the source and reports where the manifest store chunk goes. The
+  // hash covers the asset as it will look once that chunk is in place: the
+  // chunk itself is recorded as a zero-length exclusion for now, and is sized
+  // below once the manifest store template tells us how big it will be.
+  auto digest_result =
+      DigestPng(*source, HashAlgorithm::kSha256, exclusion_processor);
+  if (!digest_result.ok()) {
+    resolver.Return(digest_result.status());
+    return;
+  }
+
+  if (digest_result->existing_c2pa_length.has_value() &&
+      (!request.has_actions() || !request.actions().has_opened())) {
+    resolver.Return(absl::AlreadyExistsError(
+        "C2PA chunk already exists, but no `opened` action was provided"));
+    return;
+  }
+
+  *request.mutable_hard_binding()->mutable_data_hash() =
+      digest_result->assertion;
+
+  // The manifest store covers its own size, so it is generated in two phases:
+  // a template reserves the space, then the exclusions are fixed up to account
+  // for it, and only then is the manifest store finalized and embedded.
+  generator_.GenerateManifestStoreTemplate(
+      request, context,
+      [this, source, request, digest_result = std::move(*digest_result),
+       destination, output_metadata, context, resolver = std::move(resolver)](
+          absl::StatusOr<ManifestStoreTemplate> template_response) mutable {
+        if (!template_response.ok()) {
+          resolver.Return(template_response.status());
+          return;
+        }
+
+        constexpr int64_t kMaxManifestStoreSizeBytes =
+            100 * 1024 * 1024;  // 100 MiB
+        if (template_response->manifest_store_size() <= 0 ||
+            template_response->manifest_store_size() >
+                kMaxManifestStoreSizeBytes) {
+          resolver.Return(absl::InvalidArgumentError(
+              absl::StrCat("invalid manifest store template size: ",
+                           template_response->manifest_store_size())));
+          return;
+        }
+
+        FinalizeManifestStoreRequest finalize_request;
+        *finalize_request.mutable_manifest_store_template() =
+            std::move(*template_response);
+        *finalize_request.mutable_hard_binding()->mutable_data_hash() =
+            request.hard_binding().data_hash();
+
+        // Size of the whole `caBX` chunk record that will be inserted.
+        int64_t embedded_size =
+            finalize_request.manifest_store_template().manifest_store_size() +
+            kPngChunkLengthSize + kPngChunkTypeC2pa.size() + kPngCrcSize;
+        // The exclusions were recorded against the source asset, but they must
+        // describe the output asset. Inserting the chunk shifts everything
+        // after the insertion point by the size it adds (the size of the new
+        // chunk, less any existing `caBX` chunk it replaces), and the
+        // placeholder exclusion at the insertion point becomes the chunk.
+        for (auto& exclusion : *finalize_request.mutable_hard_binding()
+                                    ->mutable_data_hash()
+                                    ->mutable_exclusions()) {
+          if (exclusion.start() > digest_result.insertion_offset) {
+            exclusion.set_start(exclusion.start() + embedded_size -
+                                digest_result.existing_c2pa_length.value_or(0));
+          } else if (exclusion.start() == digest_result.insertion_offset) {
+            exclusion.set_length(embedded_size);
+          }
+        }
+
+        generator_.FinalizeManifestStore(
+            finalize_request, context,
+            [source, digest_result = std::move(digest_result), destination,
+             output_metadata, resolver = std::move(resolver)](
+                absl::StatusOr<FinalizeManifestStoreResponse>
+                    finalize_response) mutable {
+              if (!finalize_response.ok()) {
+                resolver.Return(finalize_response.status());
+                return;
+              }
+
+              if (output_metadata != nullptr) {
+                output_metadata->timestamp_included =
+                    finalize_response->is_timestamped();
+              }
+
+              PngEmbedder embedder;
+              auto status = embedder.CopyAndEmbed(
+                  *source, finalize_response->manifest_store(), digest_result,
+                  *destination);
+              resolver.Return(status);
+            });
+      });
+}
+
+}  // namespace credentio
diff --git a/formats/png/writer.h b/formats/png/writer.h
new file mode 100644
index 0000000..d9be3d1
--- /dev/null
+++ b/formats/png/writer.h
@@ -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.
+//
+
+#ifndef THIRD_PARTY_CREDENTIO_FORMATS_PNG_WRITER_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_PNG_WRITER_H_
+
+#include <vector>
+
+#include "absl/base/nullability.h"
+#include "absl/functional/any_invocable.h"
+#include "absl/status/status.h"
+#include "absl/status/statusor.h"
+#include "async/async_context.h"
+#include "formats/byte_range.h"
+#include "formats/png/reader.h"
+#include "generator/generator.h"
+#include "proto/generator.pb.h"
+#include "riegeli/bytes/reader.h"
+#include "riegeli/bytes/writer.h"
+
+namespace credentio {
+
+// Writes C2PA manifest stores into PNG images.
+//
+// The manifest store is embedded as a `caBX` chunk immediately before the
+// first `IDAT` chunk. If the source already contains a `caBX` chunk, that
+// chunk is replaced.
+//
+// The writer applies no exclusion policy of its own: unless an
+// `ExclusionProcessor` is supplied, every byte of the source, other than the
+// manifest store chunk itself, is covered by the hard binding hash.
+//
+// `Write()` does not mutate the writer, so a single instance may serve
+// concurrent calls as long as the exclusion processor, if any, is thread-safe.
+class PngWriter {
+ public:
+  // Information about the C2PA manifest store embedded in the output asset.
+  struct OutputMetadata {
+    // Whether the C2PA active manifest embedded in the output asset has a
+    // timestamp.
+    bool timestamp_included = false;
+  };
+
+  // A function that determines byte ranges within a given PNG chunk to be
+  // excluded from the hard binding hash.
+  //
+  // It is invoked once per chunk of the source asset, in file order, for every
+  // chunk except an already existing `caBX` chunk. The reader is positioned
+  // arbitrarily and may be freely sought by the processor.
+  //
+  // The returned ByteRanges are absolute file offsets. They must be sorted by
+  // offset, must not overlap, and must lie within the chunk, that is within
+  // [chunk.offset, chunk.offset + chunk.length), which spans the whole chunk
+  // record including its length, type and CRC fields. Returning ranges that
+  // violate these rules fails the write with `kInvalidArgument`. Return an
+  // empty vector to hash the chunk in full.
+  using ExclusionProcessor =
+      absl::AnyInvocable<absl::StatusOr<std::vector<ByteRange>>(
+          riegeli::Reader&, const PngChunk&,
+          const GenerateManifestStoreRequest&) const>;
+
+  struct Options {
+    // If null, no bytes, other than the manifest store chunk itself, are
+    // excluded from the hard binding hash.
+    ExclusionProcessor exclusion_processor = nullptr;
+  };
+
+  // Creates a PNG writer.
+  //
+  // The generator must outlive the writer.
+  explicit PngWriter(const ManifestStoreGenerator* absl_nonnull generator)
+      : PngWriter(generator, Options()) {}
+  PngWriter(const ManifestStoreGenerator* absl_nonnull generator,
+            Options options);
+
+  // Generates a C2PA manifest store for `source` and writes a copy of `source`
+  // with the manifest store embedded to `destination`.
+  //
+  // `source` and `destination` must not be backed by the same data; embedding
+  // in place is not supported. `request` must not include `hard_binding`; the
+  // hard binding is computed here over the whole asset, minus the manifest
+  // store chunk and any ranges reported by the exclusion processor.
+  //
+  // If `output_metadata` is not null, it is populated before `cb` runs, and it
+  // must stay alive until then.
+  //
+  // Returns (via `cb`):
+  //   - `kInvalidArgument` if `request` carries a `hard_binding`, if the
+  //     source is not a PNG holding an `IDAT` or `caBX` chunk, or if the
+  //     exclusion processor reports unusable ranges.
+  //   - `kAlreadyExists` if the source already holds a `caBX` chunk and
+  //     `request` carries no `opened` action.
+  //   - whatever the generator or the underlying reader and writer report.
+  void Write(GenerateManifestStoreRequest request,
+             riegeli::Reader* absl_nonnull source,
+             riegeli::Writer* absl_nonnull destination,
+             OutputMetadata* absl_nullable output_metadata,
+             AsyncContext context,
+             absl::AnyInvocable<void(absl::Status) &&> cb);
+  // Blocking version. Blocks until the write completes.
+  absl::Status Write(GenerateManifestStoreRequest request,
+                     riegeli::Reader* absl_nonnull source,
+                     riegeli::Writer* absl_nonnull destination,
+                     OutputMetadata* absl_nullable output_metadata);
+
+ private:
+  const ManifestStoreGenerator& generator_;
+  Options options_;
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_PNG_WRITER_H_
diff --git a/formats/png/writer_test.cc b/formats/png/writer_test.cc
new file mode 100644
index 0000000..f65cbc6
--- /dev/null
+++ b/formats/png/writer_test.cc
@@ -0,0 +1,260 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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/writer.h"
+
+#include <memory>
+#include <string>
+#include <utility>
+#include <vector>
+
+#include "absl/log/absl_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/string_view.h"
+#include "absl/synchronization/notification.h"
+#include "async/async_context.h"
+#include "formats/byte_range.h"
+#include "formats/png/extractor.h"
+#include "formats/png/reader.h"
+#include "formats/png/test_utils.h"
+#include "generator/generator.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "proto/generator.pb.h"
+#include "proto/ingredient_assertion.pb.h"
+#include "proto/ingredient_validation_result.pb.h"
+#include "proto/manifest.pb.h"
+#include "proto/validation_status.pb.h"
+#include "riegeli/bytes/reader.h"
+#include "riegeli/bytes/string_reader.h"
+#include "riegeli/bytes/string_writer.h"
+#include "testing/nonprod_generator.h"
+#include "testing/nonprod_validator.h"
+#include "validator/asset_validator.h"
+#include "validator/ingredient_result.h"
+#include "validator/ingredient_result_converter.h"
+#include "validator/result.h"
+
+namespace credentio {
+namespace {
+
+using ::absl_testing::IsOkAndHolds;
+using ::absl_testing::StatusIs;
+using ::testing::AllOf;
+using ::testing::Field;
+using ::testing::Gt;
+using ::testing::HasSubstr;
+using ::testing::IsEmpty;
+using ::testing::Optional;
+
+// Returns a minimal 13x17 RGB PNG image: signature, IHDR, IDAT, IEND.
+//
+// The IDAT payload is not a valid zlib stream; C2PA processing never decodes
+// the pixel data.
+absl::StatusOr<std::string> CreatePngImage() {
+  return CreatePng({
+      {.type = 'IHDR',
+       .payload = std::string("\x00\x00\x00\x0d"       // Width: 13.
+                              "\x00\x00\x00\x11"       // Height: 17.
+                              "\x08\x02\x00\x00\x00",  // 8-bit RGB.
+                              13)},
+      {.type = 'IDAT', .payload = std::string(64, '\x40')},
+  });
+}
+
+GenerateManifestStoreRequest CreateRequest() {
+  GenerateManifestStoreRequest request;
+  request.mutable_actions()
+      ->mutable_created()
+      ->mutable_action_metadata()
+      ->set_description("test only");
+  return request;
+}
+
+class PngWriterTest : public ::testing::Test {
+ protected:
+  void SetUp() override {
+    auto generator = CreateNonProdManifestStoreGenerator();
+    ABSL_CHECK_OK(generator);
+    generator_ = *std::move(generator);
+    writer_ = std::make_unique<PngWriter>(generator_.get());
+
+    auto validator = CreateNonProdValidator();
+    ABSL_CHECK_OK(validator);
+    validator_ = *std::move(validator);
+  }
+
+  // Validates `asset` and returns the ingredient validation result describing
+  // it, as needed to reference it as an ingredient.
+  absl::StatusOr<std::unique_ptr<IngredientResult>> ValidateIngredient(
+      absl::string_view asset) {
+    riegeli::StringReader reader(asset);
+    ABSL_ASSIGN_OR_RETURN(std::unique_ptr<ValidationResult> result,
+                          validator_->Validate(reader, "image/png"));
+    return IngredientResultConverter().Convert(std::move(*result));
+  }
+
+  // Writes a manifest store into a copy of `image` using `writer`.
+  static absl::StatusOr<std::string> WriteCopy(
+      PngWriter& writer, GenerateManifestStoreRequest request,
+      absl::string_view image,
+      PngWriter::OutputMetadata* output_metadata = nullptr) {
+    riegeli::StringReader source(image);
+    std::string result;
+    riegeli::StringWriter destination(&result);
+    ABSL_RETURN_IF_ERROR(writer.Write(std::move(request), &source, &destination,
+                                      output_metadata));
+    if (!destination.Close()) return destination.status();
+    return result;
+  }
+
+  std::unique_ptr<ManifestStoreGenerator> generator_;
+  std::unique_ptr<PngWriter> writer_;
+  std::unique_ptr<AssetValidator> validator_;
+};
+
+TEST_F(PngWriterTest, Write) {
+  absl::StatusOr<std::string> image = CreatePngImage();
+  ABSL_ASSERT_OK(image);
+  PngWriter::OutputMetadata output_metadata;
+  absl::StatusOr<std::string> result =
+      WriteCopy(*writer_, CreateRequest(), *image, &output_metadata);
+  ABSL_ASSERT_OK(result);
+  EXPECT_TRUE(output_metadata.timestamp_included);
+
+  riegeli::StringReader result_reader(*result);
+  EXPECT_THAT(PngExtractor().ExtractManifestStoreLocation(result_reader, {}),
+              IsOkAndHolds(Optional(AllOf(Field(&ByteRange::offset, Gt(0)),
+                                          Field(&ByteRange::length, Gt(0))))));
+}
+
+TEST_F(PngWriterTest, WriteWithoutTimestamp) {
+  auto generator =
+      CreateNonProdManifestStoreGenerator({.disable_timestamping = true});
+  ABSL_ASSERT_OK(generator);
+  PngWriter writer(generator->get());
+  absl::StatusOr<std::string> image = CreatePngImage();
+  ABSL_ASSERT_OK(image);
+
+  PngWriter::OutputMetadata output_metadata;
+  ABSL_ASSERT_OK(WriteCopy(writer, CreateRequest(), *image, &output_metadata));
+  EXPECT_FALSE(output_metadata.timestamp_included);
+}
+
+TEST_F(PngWriterTest, ExistingC2paWithoutOpenedActionFails) {
+  absl::StatusOr<std::string> image = CreatePngImage();
+  ABSL_ASSERT_OK(image);
+  absl::StatusOr<std::string> png_with_c2pa =
+      WriteCopy(*writer_, CreateRequest(), *image);
+  ABSL_ASSERT_OK(png_with_c2pa);
+
+  EXPECT_THAT(
+      WriteCopy(*writer_, CreateRequest(), *png_with_c2pa),
+      StatusIs(absl::StatusCode::kAlreadyExists, HasSubstr("already exists")));
+}
+
+TEST_F(PngWriterTest, ExistingC2paWithOpenedActionSucceeds) {
+  absl::StatusOr<std::string> image = CreatePngImage();
+  ABSL_ASSERT_OK(image);
+  absl::StatusOr<std::string> png_with_c2pa =
+      WriteCopy(*writer_, CreateRequest(), *image);
+  ABSL_ASSERT_OK(png_with_c2pa);
+
+  absl::StatusOr<std::unique_ptr<IngredientResult>>
+      png_with_c2pa_validation_results = ValidateIngredient(*png_with_c2pa);
+  ABSL_ASSERT_OK(png_with_c2pa_validation_results);
+  EXPECT_THAT((*png_with_c2pa_validation_results)
+                  ->proto()
+                  .ingredient_validation_results()
+                  .active_manifest()
+                  .failures(),
+              IsEmpty());
+
+  GenerateManifestStoreRequest request;
+  request.mutable_actions()->mutable_opened()->set_ingredient_index(0);
+  *request.add_ingredients()->mutable_ingredient_validation_results() =
+      (*png_with_c2pa_validation_results)->proto();
+  absl::StatusOr<std::string> final_png =
+      WriteCopy(*writer_, std::move(request), *png_with_c2pa);
+  ABSL_ASSERT_OK(final_png);
+
+  riegeli::StringReader source(*final_png);
+  absl::StatusOr<std::unique_ptr<ValidationResult>> result =
+      validator_->Validate(source, "image/png");
+  ABSL_ASSERT_OK(result);
+  EXPECT_THAT((*result)->proto().active_manifest().validation().failures(),
+              IsEmpty());
+}
+
+TEST_F(PngWriterTest, WriteWithHardBindingFails) {
+  GenerateManifestStoreRequest request = CreateRequest();
+  request.mutable_hard_binding();
+  absl::StatusOr<std::string> image = CreatePngImage();
+  ABSL_ASSERT_OK(image);
+  EXPECT_THAT(WriteCopy(*writer_, std::move(request), *image),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("must not include `hard_binding`")));
+}
+
+TEST_F(PngWriterTest, WriteAsync) {
+  absl::StatusOr<std::string> image = CreatePngImage();
+  ABSL_ASSERT_OK(image);
+  riegeli::StringReader source(*image);
+  std::string result;
+  riegeli::StringWriter destination(&result);
+
+  absl::Notification done;
+  absl::Status write_status;
+  PngWriter::OutputMetadata output_metadata;
+  writer_->Write(CreateRequest(), &source, &destination, &output_metadata,
+                 AsyncContext(), [&](absl::Status status) {
+                   write_status = std::move(status);
+                   done.Notify();
+                 });
+  done.WaitForNotification();
+  ABSL_ASSERT_OK(write_status);
+  ASSERT_TRUE(source.Close());
+  ASSERT_TRUE(destination.Close());
+  EXPECT_TRUE(output_metadata.timestamp_included);
+
+  riegeli::StringReader result_reader(result);
+  EXPECT_THAT(PngExtractor().ExtractManifestStoreLocation(result_reader, {}),
+              IsOkAndHolds(Optional(AllOf(Field(&ByteRange::offset, Gt(0)),
+                                          Field(&ByteRange::length, Gt(0))))));
+}
+
+TEST_F(PngWriterTest, WriteWithCustomExclusionProcessor) {
+  int custom_processor_call_count = 0;
+  PngWriter writer(
+      generator_.get(),
+      {.exclusion_processor = [&custom_processor_call_count](
+                                  riegeli::Reader&, const PngChunk&,
+                                  const GenerateManifestStoreRequest&)
+           -> absl::StatusOr<std::vector<ByteRange>> {
+        ++custom_processor_call_count;
+        return std::vector<ByteRange>();
+      }});
+  absl::StatusOr<std::string> image = CreatePngImage();
+  ABSL_ASSERT_OK(image);
+
+  ABSL_ASSERT_OK(WriteCopy(writer, CreateRequest(), *image));
+  EXPECT_GT(custom_processor_call_count, 0);
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/formats/riff/BUILD b/formats/riff/BUILD
index df9c29a..e6778c4 100644
--- a/formats/riff/BUILD
+++ b/formats/riff/BUILD
@@ -179,3 +179,92 @@
         "@abseil-cpp//absl/status:statusor",
     ],
 )
+
+cc_library(
+    name = "hasher_box",
+    srcs = ["hasher_box.cc"],
+    hdrs = ["hasher_box.h"],
+    deps = [
+        ":extractor",
+        "//bindings:input_hasher",
+        "//crypto:algorithms",
+        "//formats:asset_box",
+        "//proto:boxes_hash_assertion_cc_proto",
+        "@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 = "hasher_box_test",
+    srcs = ["hasher_box_test.cc"],
+    deps = [
+        ":create_riff",
+        ":hasher_box",
+        "//crypto:algorithms",
+        "//proto:boxes_hash_assertion_cc_proto",
+        "//testing:proto_test_utils",
+        "@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 = "writer",
+    srcs = ["writer.cc"],
+    hdrs = ["writer.h"],
+    deps = [
+        ":constants",
+        ":reader",
+        "//bindings:input_hasher",
+        "//crypto:algorithms",
+        "//formats:hasher_data",
+        "//generator",
+        "//proto:generator_cc_proto",
+        "//utils:byte_instruction",
+        "@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/types:span",
+        "@riegeli//riegeli/base:byte_fill",
+        "@riegeli//riegeli/bytes:reader",
+        "@riegeli//riegeli/bytes:string_writer",
+        "@riegeli//riegeli/bytes:writer",
+        "@riegeli//riegeli/endian:endian_writing",
+    ],
+)
+
+cc_test(
+    name = "writer_test",
+    srcs = ["writer_test.cc"],
+    deps = [
+        ":create_riff",
+        ":extractor",
+        ":writer",
+        "//formats:byte_range",
+        "//generator",
+        "//proto:generator_cc_proto",
+        "//proto:manifest_cc_proto",
+        "//proto:validation_status_cc_proto",
+        "//testing:nonprod_generator",
+        "//testing:nonprod_validator",
+        "//testing:proto_test_utils",
+        "//validator:asset_validator",
+        "@abseil-cpp//absl/log:absl_check",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@abseil-cpp//absl/strings",
+        "@googletest//:gtest_main",
+        "@riegeli//riegeli/bytes:string_reader",
+        "@riegeli//riegeli/bytes:string_writer",
+    ],
+)
diff --git a/formats/riff/extractor_test.cc b/formats/riff/extractor_test.cc
index ff469c2..7102147 100644
--- a/formats/riff/extractor_test.cc
+++ b/formats/riff/extractor_test.cc
@@ -38,7 +38,6 @@
 namespace credentio {
 namespace {
 
-using ::absl_testing::IsOk;
 using ::absl_testing::IsOkAndHolds;
 using ::absl_testing::StatusIs;
 using ::credentio_riff::Chunk;
@@ -190,7 +189,7 @@
 
   const auto& expected_result = test_case.result;
   if (expected_result.ok()) {
-    ASSERT_THAT(result, IsOk());
+    ABSL_ASSERT_OK(result);
     EXPECT_EQ(*result, expected_result->manifest_store);
   } else {
     EXPECT_THAT(result.status(),
@@ -210,7 +209,7 @@
 
   const auto& expected_result = test_case.result;
   if (expected_result.ok()) {
-    ASSERT_THAT(result, IsOk());
+    ABSL_ASSERT_OK(result);
     EXPECT_EQ(*result, expected_result->manifest_store);
   } else {
     EXPECT_THAT(result.status(),
@@ -228,7 +227,7 @@
 
   const auto& expected_result = test_case.result;
   if (expected_result.ok()) {
-    ASSERT_THAT(result, IsOk());
+    ABSL_ASSERT_OK(result);
     EXPECT_EQ(*result,
               expected_result->asset_byte_info.manifest_store_location);
   } else {
@@ -247,7 +246,7 @@
 
   const auto& expected_result = test_case.result;
   if (expected_result.ok()) {
-    ASSERT_THAT(result, IsOk());
+    ABSL_ASSERT_OK(result);
     EXPECT_THAT(*result, expected_result->asset_byte_info.boxes);
   } else {
     EXPECT_THAT(result.status(),
@@ -267,7 +266,7 @@
 
   const auto& expected_result = test_case.result;
   if (expected_result.ok()) {
-    ASSERT_THAT(result, IsOk());
+    ABSL_ASSERT_OK(result);
     EXPECT_EQ(*result,
               expected_result->asset_byte_info.manifest_store_location);
   } else {
@@ -287,7 +286,7 @@
 
   const auto& expected_result = test_case.result;
   if (expected_result.ok()) {
-    ASSERT_THAT(result, IsOk());
+    ABSL_ASSERT_OK(result);
     EXPECT_THAT(*result, expected_result->asset_byte_info.boxes);
   } else {
     EXPECT_THAT(result.status(),
@@ -328,7 +327,7 @@
   riegeli::StringReader<> input(contents);
 
   auto boxes_or = RiffExtractor().ExtractBoxes(input, {.requires_c2pa = false});
-  ASSERT_THAT(boxes_or.status(), ::absl_testing::IsOk());
+  ABSL_ASSERT_OK(boxes_or.status());
   std::vector<AssetBox> boxes = *boxes_or;
 
   std::vector<std::string> box_names;
@@ -372,7 +371,7 @@
   riegeli::StringReader<> input(contents);
 
   auto boxes_or = RiffExtractor().ExtractBoxes(input, {.requires_c2pa = false});
-  ASSERT_THAT(boxes_or.status(), ::absl_testing::IsOk());
+  ABSL_ASSERT_OK(boxes_or.status());
   std::vector<AssetBox> boxes = *boxes_or;
 
   std::vector<std::string> box_names;
diff --git a/formats/riff/hasher_box.cc b/formats/riff/hasher_box.cc
new file mode 100644
index 0000000..f6727c4
--- /dev/null
+++ b/formats/riff/hasher_box.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 "formats/riff/hasher_box.h"
+
+#include <cstddef>
+#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_cat.h"
+#include "absl/strings/substitute.h"
+#include "bindings/input_hasher.h"
+#include "crypto/algorithms.h"
+#include "formats/asset_box.h"
+#include "formats/riff/extractor.h"
+#include "proto/boxes_hash_assertion.pb.h"
+#include "riegeli/bytes/reader.h"
+
+namespace credentio {
+namespace {
+
+// Creator of a single BoxHash entry for a set of boxes.
+// This isn't RIFF-specific; could move to a common location.
+class BoxHasher {
+ public:
+  static absl::StatusOr<BoxHasher> Create(HashAlgorithm alg,
+                                          BoxesHashAssertion* assertion) {
+    ABSL_ASSIGN_OR_RETURN(std::unique_ptr<InputHasher> hasher,
+                          InputHasher::Create(alg));
+    return BoxHasher(std::move(hasher), assertion->add_boxes());
+  }
+
+  absl::Status HashBox(riegeli::Reader& input, const AssetBox& box) {
+    entry_->add_names(box.identifier);
+    ABSL_RETURN_IF_ERROR(
+        hasher_->Update(input, box.byte_range.offset, box.byte_range.length));
+    return absl::OkStatus();
+  }
+
+  void Finish() { entry_->set_hash(hasher_->Digest()); }
+
+ private:
+  BoxHasher(std::unique_ptr<InputHasher> hasher, BoxHash* entry)
+      : hasher_(std::move(hasher)), entry_(entry) {}
+  std::unique_ptr<InputHasher> hasher_;
+  BoxHash* entry_;
+};
+
+void AddC2paEntry(BoxesHashAssertion& assertion) {
+  BoxHash* entry = assertion.add_boxes();
+  entry->add_names("C2PA");
+  entry->set_hash(std::string(1, '\0'));
+}
+
+}  // namespace
+
+absl::StatusOr<BoxesHashAssertion> DigestRiffBoxHash(riegeli::Reader& input,
+                                                     HashAlgorithm alg) {
+  BoxesHashAssertion assertion;
+  assertion.set_alg(absl::StrCat(alg));
+  ABSL_ASSIGN_OR_RETURN(
+      std::vector<AssetBox> boxes,
+      RiffExtractor().ExtractBoxes(input, {.requires_c2pa = false}));
+  // Produce hashes for the boxes prior to the (existing or expected) C2PA box.
+  std::optional<BoxHasher> hasher;
+  size_t b = 0;
+  for (; b < boxes.size(); ++b) {
+    const auto& box = boxes[b];
+    if (b == 0) {
+      if (box.identifier != "RIFF") {
+        return absl::InvalidArgumentError(absl::Substitute(
+            "First box has ID '$0', expected 'RIFF'", box.identifier));
+      }
+    } else {
+      if (box.identifier == "RIFF") {
+        // Second RIFF box -- insert placeholder C2PA box first.
+        break;
+      }
+    }
+    if (box.identifier == "C2PA") {
+      ++b;
+      break;
+    }
+    if (!hasher) {
+      ABSL_ASSIGN_OR_RETURN(hasher, BoxHasher::Create(alg, &assertion));
+    }
+    ABSL_RETURN_IF_ERROR(hasher->HashBox(input, box));
+  }
+  if (hasher) {
+    hasher->Finish();
+    hasher = std::nullopt;
+  }
+  if (b == 0) {
+    return absl::InvalidArgumentError("RIFF chunk not found");
+  }
+
+  AddC2paEntry(assertion);
+
+  // C2PA chunk should be the last box in the first RIFF chunk, so the next
+  // chunk, if any, should be another top-level RIFF chunk.
+  if (b < boxes.size() && boxes[b].identifier != "RIFF") {
+    return absl::InvalidArgumentError(absl::Substitute(
+        "Expected RIFF chunk, found '$0'", boxes[b].identifier));
+  }
+
+  // Produce hashes for any remaining boxes (after the C2PA chunk).
+  for (; b < boxes.size(); ++b) {
+    const auto& box = boxes[b];
+    if (box.identifier == "C2PA") {
+      return absl::InvalidArgumentError("Unexpected C2PA chunk found");
+    }
+    if (!hasher) {
+      ABSL_ASSIGN_OR_RETURN(hasher, BoxHasher::Create(alg, &assertion));
+    }
+    ABSL_RETURN_IF_ERROR(hasher->HashBox(input, box));
+  }
+  if (hasher) {
+    hasher->Finish();
+    hasher = std::nullopt;
+  }
+  return assertion;
+}
+
+}  // namespace credentio
diff --git a/formats/riff/hasher_box.h b/formats/riff/hasher_box.h
new file mode 100644
index 0000000..0c55aa9
--- /dev/null
+++ b/formats/riff/hasher_box.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_FORMATS_RIFF_HASHER_BOX_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_RIFF_HASHER_BOX_H_
+
+#include "absl/status/statusor.h"
+#include "crypto/algorithms.h"
+#include "proto/boxes_hash_assertion.pb.h"
+#include "riegeli/bytes/reader.h"
+
+namespace credentio {
+// Digests the given RIFF asset and returns a BoxesHashAssertion that can be
+// used to generate a C2PA manifest store. If no C2PA chunk is present, it is
+// assumed that a C2PA chunk will be inserted at the end of the first
+// RIFF chunk.
+absl::StatusOr<BoxesHashAssertion> DigestRiffBoxHash(riegeli::Reader& input,
+                                                     HashAlgorithm alg);
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_RIFF_HASHER_BOX_H_
diff --git a/formats/riff/hasher_box_test.cc b/formats/riff/hasher_box_test.cc
new file mode 100644
index 0000000..9b9a290
--- /dev/null
+++ b/formats/riff/hasher_box_test.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 "formats/riff/hasher_box.h"
+
+#include <string>
+
+#include "absl/status/status.h"
+#include "absl/status/status_matchers.h"
+#include "absl/strings/str_cat.h"
+#include "crypto/algorithms.h"
+#include "formats/riff/create_riff.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "proto/boxes_hash_assertion.pb.h"
+#include "riegeli/bytes/string_reader.h"
+#include "testing/proto_test_utils.h"
+
+namespace credentio {
+namespace {
+
+using ::absl_testing::IsOkAndHolds;
+using ::absl_testing::StatusIs;
+using ::credentio_riff::Chunk;
+using ::credentio_riff::RiffChunk;
+using ::credentio_testing::EqualsProto;
+using ::credentio_testing::ParseTextProtoOrDie;
+using ::testing::HasSubstr;
+
+TEST(DigestRiffBoxHashTest, NoC2pa) {
+  std::string contents = RiffChunk("WEBP", Chunk("VP8 ", "aaaaaaaa"));
+  riegeli::StringReader<> input(contents);
+  BoxesHashAssertion expected = ParseTextProtoOrDie<BoxesHashAssertion>(R"pb(
+    alg: "sha256"
+    boxes {
+      names: "RIFF"
+      names: "VP8 "
+      hash: "\227\216\222\217z)\256\345\271\257\273\013*;\264^e\t\2507I\340\022A\333\212\203%\210\rtc"
+    }
+    # C2PA placeholder is inserted at the end of the RIFF chunk.
+    boxes { names: "C2PA" hash: "\000" }
+  )pb");
+  EXPECT_THAT(DigestRiffBoxHash(input, HashAlgorithm::kSha256),
+              IsOkAndHolds(EqualsProto(expected)));
+}
+
+TEST(DigestRiffBoxHashTest, WithC2pa) {
+  std::string contents = RiffChunk(
+      "WEBP",
+      absl::StrCat(Chunk("VP8 ", "aaaaaaaa"), Chunk("C2PA", "manifest store")));
+  riegeli::StringReader<> input(contents);
+  BoxesHashAssertion expected = ParseTextProtoOrDie<BoxesHashAssertion>(R"pb(
+    alg: "sha256"
+    boxes {
+      names: "RIFF"
+      names: "VP8 "
+      hash: "V\216\345\371\376\247\347Dl\221\234W\264%\016\233\023d\342i\205\004\3021@\035\273\234\337\271)\343"
+    }
+    boxes { names: "C2PA" hash: "\000" }
+  )pb");
+  EXPECT_THAT(DigestRiffBoxHash(input, HashAlgorithm::kSha256),
+              IsOkAndHolds(EqualsProto(expected)));
+}
+
+TEST(DigestRiffBoxHashTest, EmptyRiff) {
+  riegeli::StringReader<> input("");
+  EXPECT_THAT(DigestRiffBoxHash(input, HashAlgorithm::kSha256),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("RIFF chunk not found")));
+}
+
+TEST(DigestRiffBoxHashTest, FirstChunkNotRiff) {
+  std::string contents = Chunk("ABCD", "aaaaaaaa");
+  riegeli::StringReader<> input(contents);
+  EXPECT_THAT(DigestRiffBoxHash(input, HashAlgorithm::kSha256),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("RIFF chunk not found")));
+}
+
+TEST(DigestRiffBoxHashTest, MultipleRiffChunks) {
+  std::string contents =
+      absl::StrCat(RiffChunk("WEBP", absl::StrCat(Chunk("ABCD", "aaaaaaaa"),
+                                                  Chunk("EFGH", "bbbbbbbb"))),
+                   RiffChunk("BLAH", Chunk("IJKL", "cccccccccccc")));
+  riegeli::StringReader<> input(contents);
+  BoxesHashAssertion expected = ParseTextProtoOrDie<BoxesHashAssertion>(R"pb(
+    alg: "sha256"
+    boxes {
+      names: "RIFF"
+      names: "ABCD"
+      names: "EFGH"
+      hash: "\323\224Q\237\376a@\373\230_f\354\204\376\255}\016\366\2724\377\200\304\260\002\373\264\332S\300\337\375"
+    }
+    boxes { names: "C2PA" hash: "\000" }
+    boxes {
+      names: "RIFF"
+      names: "IJKL"
+      hash: "\375\032\251\2252\227\265\201_\351\240M\236\307a8\303\245,\037w\362\271\276\024j{HJ\323q\314"
+    }
+  )pb");
+  EXPECT_THAT(DigestRiffBoxHash(input, HashAlgorithm::kSha256),
+              IsOkAndHolds(EqualsProto(expected)));
+}
+
+TEST(DigestRiffBoxHashTest, C2paInSecondRiffChunk) {
+  std::string contents = absl::StrCat(
+      RiffChunk("WEBP", absl::StrCat(Chunk("ABCD", "aaaaaaaa"),
+                                     Chunk("EFGH", "bbbbbbbb"))),
+      RiffChunk("BLAH", Chunk("C2PA",
+                              "C2PA Manifest Store that should have been in "
+                              "the first RIFF chunk")));
+  riegeli::StringReader<> input(contents);
+  EXPECT_THAT(DigestRiffBoxHash(input, HashAlgorithm::kSha256),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("Unexpected C2PA chunk found")));
+}
+
+TEST(DigestRiffBoxHashTest, C2paInMiddleOfRiffChunk) {
+  std::string contents =
+      RiffChunk("WEBP", absl::StrCat(Chunk("ABCD", "aaaaaaaa"),
+                                     Chunk("C2PA", "manifest store"),
+                                     Chunk("EFGH", "bbbbbbbb")));
+  riegeli::StringReader<> input(contents);
+  EXPECT_THAT(DigestRiffBoxHash(input, HashAlgorithm::kSha256),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("Expected RIFF chunk, found 'EFGH'")));
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/formats/riff/reader_test.cc b/formats/riff/reader_test.cc
index 28bc933..6afca16 100644
--- a/formats/riff/reader_test.cc
+++ b/formats/riff/reader_test.cc
@@ -82,7 +82,7 @@
                            credentio_riff::Chunk("MNOP", "fourth chunk")));
   riegeli::StringReader<> input(contents);
   auto chunks_or = GetChunks(input);
-  ASSERT_THAT(chunks_or.status(), IsOk());
+  ABSL_ASSERT_OK(chunks_or.status());
   auto chunks = *chunks_or;
   ASSERT_THAT(chunks, ElementsAre(
                           RiffChunk{
@@ -269,7 +269,7 @@
 
   riegeli::StringReader<> input(contents);
   auto chunks_or = GetChunks(input);
-  ASSERT_THAT(chunks_or.status(), IsOk());
+  ABSL_ASSERT_OK(chunks_or.status());
   auto chunks = *chunks_or;
   ASSERT_THAT(chunks, ElementsAre(
                           RiffChunk{
diff --git a/formats/riff/writer.cc b/formats/riff/writer.cc
new file mode 100644
index 0000000..5973965
--- /dev/null
+++ b/formats/riff/writer.cc
@@ -0,0 +1,271 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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/writer.h"
+
+#include <cstdint>
+#include <limits>
+#include <memory>
+#include <string>
+#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/str_cat.h"
+#include "absl/types/span.h"
+#include "bindings/input_hasher.h"
+#include "crypto/algorithms.h"
+#include "formats/hasher_data.h"
+#include "formats/riff/constants.h"
+#include "formats/riff/reader.h"
+#include "proto/generator.pb.h"
+#include "riegeli/base/byte_fill.h"
+#include "riegeli/bytes/reader.h"
+#include "riegeli/bytes/string_writer.h"
+#include "riegeli/bytes/writer.h"
+#include "riegeli/endian/endian_writing.h"
+#include "utils/byte_instruction.h"
+
+namespace credentio {
+namespace {
+
+absl::StatusOr<uint64_t> FindC2paInsertionPoint(riegeli::Reader& input) {
+  uint64_t c2pa_insertion_point = 0;
+
+  bool first = true;
+  ABSL_RETURN_IF_ERROR(IterateOverRiffChunks(
+      input,
+      [&c2pa_insertion_point,
+       &first](const RiffChunk& chunk) -> absl::StatusOr<bool> {
+        if (first) {
+          // Verify that the input starts with RIFF chunk.
+          if (chunk.id != kRiffChunkIdRiff) {
+            return absl::InvalidArgumentError(
+                "Input does not start with RIFF chunk");
+          }
+          first = false;
+          c2pa_insertion_point = chunk.offset + chunk.length;
+          return true;
+        }
+        if (chunk.id == kRiffChunkIdC2pa) {
+          return absl::AlreadyExistsError("C2PA chunk already exists");
+        }
+        if (chunk.offset >= c2pa_insertion_point) {
+          return false;  // Stop iterating, we finished the first RIFF chunk
+        }
+        return true;
+      }));
+  if (first) {
+    return absl::InvalidArgumentError("Input is empty");
+  }
+  return c2pa_insertion_point;
+}
+
+absl::Status AddRiffSizeInstruction(
+    uint32_t riff_size, std::vector<ByteInstruction>& byte_instructions) {
+  byte_instructions.push_back({
+      .operation = ByteInstruction::Operation::kReplace,
+      .offset = 4,
+      .bytes = "",
+  });
+  riegeli::StringWriter size_writer(&byte_instructions.back().bytes);
+  if (!riegeli::WriteLittleEndian<uint32_t>(riff_size, size_writer)) {
+    return absl::InternalError("failed to create size writer");
+  }
+  if (!size_writer.Close()) {
+    return absl::InternalError("failed to write updated RIFF size");
+  }
+  return absl::OkStatus();
+}
+
+absl::Status AddC2paManifestInstructionPlaceholder(
+    uint64_t c2pa_insertion_point, uint64_t manifest_store_size,
+    uint64_t pad_size, std::vector<ByteInstruction>& byte_instructions) {
+  byte_instructions.push_back({
+      .operation = ByteInstruction::Operation::kInsert,
+      .offset = c2pa_insertion_point,
+      .bytes = "",
+  });
+  riegeli::StringWriter c2pa_writer(&byte_instructions.back().bytes);
+  if (!c2pa_writer.Write(kRiffChunkIdC2pa)) {
+    return absl::InternalError("failed to write C2PA chunk ID");
+  }
+  if (!riegeli::WriteLittleEndian<uint32_t>(manifest_store_size + pad_size,
+                                            c2pa_writer)) {
+    return absl::InternalError("failed to write C2PA chunk size");
+  }
+  if (pad_size > 0) {
+    if (!c2pa_writer.Write(riegeli::ByteFill(pad_size, '\0'))) {
+      return absl::InternalError("failed to write padding");
+    }
+  }
+  if (!c2pa_writer.Close()) {
+    return absl::InternalError("failed to close c2pa writer");
+  }
+  return absl::OkStatus();
+}
+
+absl::StatusOr<std::string> ComputeHash(
+    riegeli::Reader& source, uint64_t file_size,
+    absl::Span<const ByteInstruction> instructions) {
+  if (!source.Seek(0) || source.pos() != 0) {
+    return absl::InternalError("failed to seek to start of source");
+  }
+
+  ABSL_ASSIGN_OR_RETURN(std::unique_ptr<InputHasher> hasher,
+                        InputHasher::Create(HashAlgorithm::kSha256));
+  // Hash the RIFF header.
+  ABSL_RETURN_IF_ERROR(hasher->Update(source, 0, instructions[0].offset));
+  // Hash the updated RIFF size.
+  hasher->Update(instructions[0].bytes);
+  // Hash up to the C2PA chunk insertion point.
+  ABSL_RETURN_IF_ERROR(hasher->Update(source, 8, instructions[1].offset - 8));
+
+  // Hash the C2PA chunk placeholder.
+  hasher->Update(instructions[1].bytes);
+
+  // Hash the remainder of the file.
+  ABSL_RETURN_IF_ERROR(hasher->Update(source, instructions[1].offset,
+                                      file_size - instructions[1].offset));
+  return hasher->Digest();
+}
+
+}  // namespace
+
+absl::StatusOr<std::vector<ByteInstruction>> RiffWriter::WriteInternal(
+    GenerateManifestStoreRequest request, riegeli::Reader* absl_nonnull source,
+    OutputMetadata* absl_nullable output_metadata) const {
+  if (request.has_hard_binding()) {
+    return absl::InvalidArgumentError(
+        "request must not include `hard_binding` (binding is computed by "
+        "RiffWriter)");
+  }
+  uint64_t file_size = source->Size().value_or(0);
+  if (file_size == 0) {
+    return absl::InvalidArgumentError("source is empty");
+  }
+
+  // The two-phase approach is required for RIFF: The box hash can't be computed
+  // correctly until the manifest store size is known, because the manifest size
+  // affects the top-level RIFF chunk size.
+
+  // Phase 1: Compute preliminary hard binding, produce a manifest store
+  // template, and embed a zero-filled placeholder in the asset.
+  ABSL_ASSIGN_OR_RETURN(*request.mutable_hard_binding()->mutable_data_hash(),
+                        DataHashTemplate(HashAlgorithm::kSha256));
+  ABSL_ASSIGN_OR_RETURN(ManifestStoreTemplate manifest_store_template,
+                        generator_.GenerateManifestStoreTemplate(request));
+  constexpr int64_t kMaxManifestStoreSizeBytes = 100 * 1024 * 1024;  // 100 MiB
+  if (manifest_store_template.manifest_store_size() <= 0 ||
+      manifest_store_template.manifest_store_size() >
+          kMaxManifestStoreSizeBytes) {
+    return absl::InvalidArgumentError(
+        absl::StrCat("invalid manifest store template size: ",
+                     manifest_store_template.manifest_store_size()));
+  }
+
+  // The insertion point is the end of the first RIFF chunk, or an error if the
+  // input is not a RIFF file or if a C2PA chunk already exists.
+  ABSL_ASSIGN_OR_RETURN(uint64_t c2pa_insertion_point,
+                        FindC2paInsertionPoint(*source));
+
+  uint64_t manifest_store_size = manifest_store_template.manifest_store_size();
+  uint64_t pad_size = (manifest_store_size % 2 == 1) ? 1 : 0;
+
+  uint64_t updated_riff_size =
+      c2pa_insertion_point + manifest_store_size + pad_size;
+  if (updated_riff_size > std::numeric_limits<uint32_t>::max()) {
+    return absl::InvalidArgumentError("chunk size overflow");
+  }
+
+  std::vector<ByteInstruction> byte_instructions;
+  ABSL_RETURN_IF_ERROR(
+      AddRiffSizeInstruction(updated_riff_size, byte_instructions));
+  ABSL_RETURN_IF_ERROR(AddC2paManifestInstructionPlaceholder(
+      c2pa_insertion_point, manifest_store_size, pad_size, byte_instructions));
+
+  ABSL_ASSIGN_OR_RETURN(std::string hash,
+                        ComputeHash(*source, file_size, byte_instructions));
+
+  FinalizeManifestStoreRequest finalize_request;
+  *finalize_request.mutable_manifest_store_template() =
+      std::move(manifest_store_template);
+  *finalize_request.mutable_hard_binding() = std::move(request.hard_binding());
+  finalize_request.mutable_hard_binding()
+      ->mutable_data_hash()
+      ->mutable_exclusions(0)
+      ->set_start(c2pa_insertion_point + 8);
+  finalize_request.mutable_hard_binding()
+      ->mutable_data_hash()
+      ->mutable_exclusions(0)
+      ->set_length(manifest_store_size);
+  finalize_request.mutable_hard_binding()->mutable_data_hash()->set_hash(
+      std::move(hash));
+
+  ABSL_ASSIGN_OR_RETURN(auto response,
+                        generator_.FinalizeManifestStore(finalize_request));
+  if (output_metadata != nullptr) {
+    output_metadata->timestamp_included = response.is_timestamped();
+  }
+  if (response.manifest_store().size() !=
+      finalize_request.manifest_store_template().manifest_store_size()) {
+    return absl::InternalError("manifest store size does not match template");
+  }
+
+  // Update the C2PA chunk with the manifest store and padding.
+  byte_instructions.back().bytes = absl::StrCat(
+      byte_instructions.back().bytes.substr(0, 8), response.manifest_store(),
+      pad_size > 0 ? std::string(pad_size, '\0') : "");
+
+  return std::move(byte_instructions);
+}
+
+absl::Status RiffWriter::Write(
+    GenerateManifestStoreRequest request, riegeli::Reader* absl_nonnull source,
+    riegeli::Writer* absl_nonnull destination,
+    OutputMetadata* absl_nullable output_metadata) const {
+  ABSL_ASSIGN_OR_RETURN(
+      std::vector<ByteInstruction> byte_instructions,
+      WriteInternal(std::move(request), source, output_metadata));
+  return ApplyByteInstructions(source, byte_instructions, destination);
+}
+
+absl::Status RiffWriter::Write(
+    GenerateManifestStoreRequest request, riegeli::Writer* absl_nonnull asset,
+    OutputMetadata* absl_nullable output_metadata) const {
+  if (!asset->SupportsReadMode()) {
+    return absl::InvalidArgumentError("asset does not support read mode");
+  }
+
+  riegeli::Reader* source = asset->ReadMode(0);
+  if (source == nullptr) {
+    return asset->ok() ? absl::InternalError("failed to create source reader")
+                       : asset->status();
+  }
+  if (!source->ok()) {
+    return source->status();
+  }
+
+  ABSL_ASSIGN_OR_RETURN(
+      std::vector<ByteInstruction> byte_instructions,
+      WriteInternal(std::move(request), source, output_metadata));
+
+  return ApplyByteInstructions(byte_instructions, asset);
+}
+
+}  // namespace credentio
diff --git a/formats/riff/writer.h b/formats/riff/writer.h
new file mode 100644
index 0000000..747a54e
--- /dev/null
+++ b/formats/riff/writer.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_FORMATS_RIFF_WRITER_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_RIFF_WRITER_H_
+
+#include <vector>
+
+#include "absl/base/nullability.h"
+#include "absl/log/die_if_null.h"
+#include "absl/status/status.h"
+#include "absl/status/statusor.h"
+#include "generator/generator.h"
+#include "proto/generator.pb.h"
+#include "riegeli/bytes/reader.h"
+#include "riegeli/bytes/writer.h"
+#include "utils/byte_instruction.h"
+
+namespace credentio {
+
+// Writer of C2PA manifest stores into RIFF assets.
+class RiffWriter {
+ public:
+  // Information about the C2PA manifest store embedded in the output asset.
+  struct OutputMetadata {
+    // Whether the C2PA active manifest embedded in the output asset has a
+    // timestamp.
+    bool timestamp_included = false;
+  };
+
+  // Creates a RIFF writer.
+  //
+  // The generator must outlive the writer.
+  explicit RiffWriter(const ManifestStoreGenerator* absl_nonnull generator)
+      : generator_(*ABSL_DIE_IF_NULL(generator)) {}
+
+  // Generates a C2PA manifest store and embeds it into a copy of the given
+  // asset. The riegeli::Reader and riegeli::Writer cannot be pointed to the
+  // same underlying data. The request must not include `hard_binding`.
+  // Fails if C2PA metadata already exists.
+  absl::Status Write(
+      GenerateManifestStoreRequest request,
+      riegeli::Reader* absl_nonnull source,
+      riegeli::Writer* absl_nonnull destination,
+      OutputMetadata* absl_nullable output_metadata = nullptr) const;
+
+  absl::Status Write(
+      GenerateManifestStoreRequest request, riegeli::Writer* absl_nonnull asset,
+      OutputMetadata* absl_nullable output_metadata = nullptr) const;
+
+ private:
+  const ManifestStoreGenerator& generator_;
+
+  absl::StatusOr<std::vector<ByteInstruction>> WriteInternal(
+      GenerateManifestStoreRequest request,
+      riegeli::Reader* absl_nonnull source,
+      OutputMetadata* absl_nullable output_metadata) const;
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_RIFF_WRITER_H_
diff --git a/formats/riff/writer_test.cc b/formats/riff/writer_test.cc
new file mode 100644
index 0000000..3b94c24
--- /dev/null
+++ b/formats/riff/writer_test.cc
@@ -0,0 +1,351 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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/writer.h"
+
+#include <cstdint>
+#include <memory>
+#include <string>
+#include <utility>
+
+#include "absl/log/absl_check.h"
+#include "absl/status/status.h"
+#include "absl/status/status_matchers.h"
+#include "absl/strings/str_cat.h"
+#include "formats/byte_range.h"
+#include "formats/riff/create_riff.h"
+#include "formats/riff/extractor.h"
+#include "generator/generator.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "proto/generator.pb.h"
+#include "proto/manifest.pb.h"
+#include "proto/validation_status.pb.h"
+#include "riegeli/bytes/string_reader.h"
+#include "riegeli/bytes/string_writer.h"
+#include "testing/nonprod_generator.h"
+#include "testing/nonprod_validator.h"
+#include "testing/proto_test_utils.h"
+#include "validator/asset_validator.h"
+
+namespace credentio {
+namespace {
+
+using ::absl_testing::IsOkAndHolds;
+using ::absl_testing::StatusIs;
+using ::credentio_testing::ParseTextProtoOrDie;
+using ::credentio_testing::PartiallyEqualsProto;
+using ::testing::AllOf;
+using ::testing::Contains;
+using ::testing::Field;
+using ::testing::Gt;
+using ::testing::HasSubstr;
+using ::testing::IsEmpty;
+using ::testing::Not;
+using ::testing::Optional;
+
+std::string CreateWebPImage() {
+  return credentio_riff::RiffChunk("WEBP",
+                                   credentio_riff::Chunk("VP8 ", "image_data"));
+}
+
+std::string CreateAviVideo() {
+  return credentio_riff::RiffChunk(
+      "AVI ",
+      absl::StrCat(
+          credentio_riff::ListChunk(
+              "hdrl", credentio_riff::Chunk("avih", std::string(56, '\0'))),
+          credentio_riff::ListChunk(
+              "movi", credentio_riff::Chunk("00dc", "fake video frame data"))));
+}
+
+class RiffWriterTest : public ::testing::Test {
+ protected:
+  void SetUp() override {
+    auto generator = CreateNonProdManifestStoreGenerator({});
+    ABSL_CHECK_OK(generator);
+    generator_ = *std::move(generator);
+    writer_ = std::make_unique<RiffWriter>(generator_.get());
+
+    auto validator = CreateNonProdValidator({});
+    ABSL_CHECK_OK(validator);
+    validator_ = *std::move(validator);
+  }
+
+  std::unique_ptr<ManifestStoreGenerator> generator_;
+  std::unique_ptr<RiffWriter> writer_;
+  std::unique_ptr<AssetValidator> validator_;
+};
+
+TEST_F(RiffWriterTest, Write) {
+  GenerateManifestStoreRequest request;
+  request.mutable_actions()
+      ->mutable_created()
+      ->mutable_action_metadata()
+      ->set_description("test only");
+
+  std::string image = CreateWebPImage();
+  riegeli::StringReader source(image);
+  ABSL_ASSERT_OK(source.status());
+  std::string result;
+  riegeli::StringWriter destination(&result);
+  ABSL_ASSERT_OK(destination.status());
+  RiffWriter::OutputMetadata output_metadata;
+  ABSL_ASSERT_OK(
+      writer_->Write(request, &source, &destination, &output_metadata));
+  ASSERT_TRUE(source.Close());
+  ASSERT_TRUE(destination.Close());
+  EXPECT_TRUE(output_metadata.timestamp_included);
+
+  riegeli::StringReader result_reader(result);
+  ABSL_ASSERT_OK(result_reader.status());
+
+  EXPECT_THAT(RiffExtractor().ExtractManifestStoreLocation(result_reader, {}),
+              IsOkAndHolds(Optional(AllOf(Field(&ByteRange::offset, Gt(0)),
+                                          Field(&ByteRange::length, Gt(0))))));
+
+  ASSERT_TRUE(result_reader.Seek(0));
+  auto validation_result = validator_->Validate(result_reader, "image/webp");
+  ABSL_ASSERT_OK(validation_result);
+  EXPECT_THAT(
+      (*validation_result)->proto().active_manifest().validation().failures(),
+      IsEmpty());
+  EXPECT_THAT(
+      (*validation_result)
+          ->proto()
+          .active_manifest()
+          .validation()
+          .informationals(),
+      Not(Contains(PartiallyEqualsProto(ParseTextProtoOrDie<ValidationStatus>(
+          R"pb(
+            code: "assertion.dataHash.additionalExclusionsPresent"
+          )pb")))));
+}
+
+TEST_F(RiffWriterTest, WriteInPlace) {
+  GenerateManifestStoreRequest request;
+  request.mutable_actions()
+      ->mutable_created()
+      ->mutable_action_metadata()
+      ->set_description("test only");
+
+  std::string image = CreateWebPImage();
+  riegeli::StringWriter<> asset(
+      &image, riegeli::StringWriterBase::Options().set_append(true));
+  ABSL_ASSERT_OK(asset.status());
+
+  RiffWriter::OutputMetadata output_metadata;
+  ABSL_ASSERT_OK(writer_->Write(request, &asset, &output_metadata));
+  ASSERT_TRUE(asset.Close());
+  EXPECT_TRUE(output_metadata.timestamp_included);
+
+  riegeli::StringReader<> result_reader(image);
+  ABSL_ASSERT_OK(result_reader.status());
+
+  EXPECT_THAT(RiffExtractor().ExtractManifestStoreLocation(result_reader, {}),
+              IsOkAndHolds(Optional(AllOf(Field(&ByteRange::offset, Gt(0)),
+                                          Field(&ByteRange::length, Gt(0))))));
+
+  ASSERT_TRUE(result_reader.Seek(0));
+  auto validation_result = validator_->Validate(result_reader, "image/webp");
+  ABSL_ASSERT_OK(validation_result);
+  EXPECT_THAT(
+      (*validation_result)->proto().active_manifest().validation().failures(),
+      IsEmpty());
+  EXPECT_THAT(
+      (*validation_result)
+          ->proto()
+          .active_manifest()
+          .validation()
+          .informationals(),
+      Not(Contains(PartiallyEqualsProto(ParseTextProtoOrDie<ValidationStatus>(
+          R"pb(
+            code: "assertion.dataHash.additionalExclusionsPresent"
+          )pb")))));
+}
+
+TEST_F(RiffWriterTest, WriteAvi) {
+  GenerateManifestStoreRequest request;
+  request.mutable_actions()
+      ->mutable_created()
+      ->mutable_action_metadata()
+      ->set_description("test avi");
+
+  std::string video = CreateAviVideo();
+  riegeli::StringReader source(video);
+  ABSL_ASSERT_OK(source.status());
+  std::string result;
+  riegeli::StringWriter destination(&result);
+  ABSL_ASSERT_OK(destination.status());
+  RiffWriter::OutputMetadata output_metadata;
+  ABSL_ASSERT_OK(
+      writer_->Write(request, &source, &destination, &output_metadata));
+  ASSERT_TRUE(source.Close());
+  ASSERT_TRUE(destination.Close());
+  EXPECT_TRUE(output_metadata.timestamp_included);
+
+  riegeli::StringReader result_reader(result);
+  ABSL_ASSERT_OK(result_reader.status());
+
+  EXPECT_THAT(RiffExtractor().ExtractManifestStoreLocation(result_reader, {}),
+              IsOkAndHolds(Optional(AllOf(Field(&ByteRange::offset, Gt(0)),
+                                          Field(&ByteRange::length, Gt(0))))));
+
+  ASSERT_TRUE(result_reader.Seek(0));
+  auto validation_result =
+      validator_->Validate(result_reader, "video/x-msvideo");
+  ABSL_ASSERT_OK(validation_result);
+  EXPECT_THAT(
+      (*validation_result)->proto().active_manifest().validation().failures(),
+      IsEmpty());
+  EXPECT_THAT(
+      (*validation_result)
+          ->proto()
+          .active_manifest()
+          .validation()
+          .informationals(),
+      Not(Contains(PartiallyEqualsProto(ParseTextProtoOrDie<ValidationStatus>(
+          R"pb(
+            code: "assertion.dataHash.additionalExclusionsPresent"
+          )pb")))));
+
+  ASSERT_TRUE(result_reader.Seek(0));
+  auto validation_result_no_hint =
+      validator_->Validate(result_reader, /*media_type=*/{});
+  ABSL_ASSERT_OK(validation_result_no_hint);
+  EXPECT_THAT((*validation_result_no_hint)
+                  ->proto()
+                  .active_manifest()
+                  .validation()
+                  .failures(),
+              IsEmpty());
+}
+
+TEST_F(RiffWriterTest, WriteWithOutputMetadataNoTimestamp) {
+  auto generator =
+      CreateNonProdManifestStoreGenerator({.disable_timestamping = true});
+  ABSL_ASSERT_OK(generator);
+  auto writer = std::make_unique<RiffWriter>(generator->get());
+  GenerateManifestStoreRequest request;
+  request.mutable_actions()
+      ->mutable_created()
+      ->mutable_action_metadata()
+      ->set_description("test only");
+  std::string image = CreateWebPImage();
+  riegeli::StringReader source(image);
+  ABSL_ASSERT_OK(source.status());
+  std::string result;
+  riegeli::StringWriter destination(&result);
+  ABSL_ASSERT_OK(destination.status());
+  RiffWriter::OutputMetadata output_metadata;
+  ABSL_ASSERT_OK(
+      writer->Write(request, &source, &destination, &output_metadata));
+  ASSERT_TRUE(source.Close());
+  ASSERT_TRUE(destination.Close());
+  EXPECT_FALSE(output_metadata.timestamp_included);
+}
+
+TEST_F(RiffWriterTest, WriteTwiceFails) {
+  GenerateManifestStoreRequest request;
+  request.mutable_actions()
+      ->mutable_created()
+      ->mutable_action_metadata()
+      ->set_description("test only");
+
+  std::string image = CreateWebPImage();
+  riegeli::StringReader source1(image);
+  ABSL_ASSERT_OK(source1.status());
+  std::string result1;
+  riegeli::StringWriter destination1(&result1);
+  ABSL_ASSERT_OK(destination1.status());
+  ABSL_ASSERT_OK(writer_->Write(request, &source1, &destination1));
+  ASSERT_TRUE(source1.Close());
+  ASSERT_TRUE(destination1.Close());
+
+  riegeli::StringReader source2(result1);
+  ABSL_ASSERT_OK(source2.status());
+  std::string result2;
+  riegeli::StringWriter destination2(&result2);
+  ABSL_ASSERT_OK(destination2.status());
+  EXPECT_THAT(
+      writer_->Write(request, &source2, &destination2),
+      StatusIs(absl::StatusCode::kAlreadyExists, HasSubstr("already exists")));
+}
+
+TEST_F(RiffWriterTest, WriteWithHardBindingFails) {
+  GenerateManifestStoreRequest request;
+  request.mutable_actions()
+      ->mutable_created()
+      ->mutable_action_metadata()
+      ->set_description("test only");
+  request.mutable_hard_binding();
+  std::string image = CreateWebPImage();
+  riegeli::StringReader source(image);
+  ABSL_ASSERT_OK(source.status());
+  std::string result;
+  riegeli::StringWriter destination(&result);
+  ABSL_ASSERT_OK(destination.status());
+  EXPECT_THAT(writer_->Write(request, &source, &destination),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("must not include `hard_binding`")));
+}
+
+TEST_F(RiffWriterTest, DemonstratesUnboundedMemoryBufferingDoS) {
+  GenerateManifestStoreRequest request;
+  request.mutable_actions()
+      ->mutable_created()
+      ->mutable_action_metadata()
+      ->set_description("demonstrate unbounded buffering DoS");
+
+  constexpr uint32_t kJunkPayloadSize = 10 * 1024 * 1024;  // 10 MiB
+  constexpr uint32_t kTotalRiffSize = 4 + 8 + kJunkPayloadSize;
+
+  std::string large_riff_data;
+  large_riff_data.reserve(8 + kTotalRiffSize);
+
+  // RIFF Header: "RIFF" <total_riff_size> "WEBP"
+  large_riff_data.append("RIFF", 4);
+  large_riff_data.push_back(static_cast<char>(kTotalRiffSize & 0xFF));
+  large_riff_data.push_back(static_cast<char>((kTotalRiffSize >> 8) & 0xFF));
+  large_riff_data.push_back(static_cast<char>((kTotalRiffSize >> 16) & 0xFF));
+  large_riff_data.push_back(static_cast<char>((kTotalRiffSize >> 24) & 0xFF));
+  large_riff_data.append("WEBP", 4);
+
+  // JUNK Subchunk: "JUNK" <junk_payload_size> <junk_payload>
+  large_riff_data.append("JUNK", 4);
+  large_riff_data.push_back(static_cast<char>(kJunkPayloadSize & 0xFF));
+  large_riff_data.push_back(static_cast<char>((kJunkPayloadSize >> 8) & 0xFF));
+  large_riff_data.push_back(static_cast<char>((kJunkPayloadSize >> 16) & 0xFF));
+  large_riff_data.push_back(static_cast<char>((kJunkPayloadSize >> 24) & 0xFF));
+  large_riff_data.append(kJunkPayloadSize, 'A');
+
+  riegeli::StringReader source(large_riff_data);
+  ABSL_ASSERT_OK(source.status());
+
+  std::string result;
+  riegeli::StringWriter destination(&result);
+  ABSL_ASSERT_OK(destination.status());
+
+  ABSL_ASSERT_OK(writer_->Write(request, &source, &destination));
+  ASSERT_TRUE(source.Close());
+  ASSERT_TRUE(destination.Close());
+
+  // Verify that the output was successfully generated and contains the 10MB
+  // media payload
+  EXPECT_GT(result.size(), kJunkPayloadSize);
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/formats/tiff/BUILD b/formats/tiff/BUILD
index ddc69bc..17cdcee 100644
--- a/formats/tiff/BUILD
+++ b/formats/tiff/BUILD
@@ -24,6 +24,46 @@
     deps = ["@abseil-cpp//absl/strings:string_view"],
 )
 
+cc_test(
+    name = "constants_test",
+    srcs = ["constants_test.cc"],
+    deps = [
+        ":constants",
+        "@googletest//:gtest_main",
+    ],
+)
+
+cc_library(
+    name = "hasher",
+    srcs = ["hasher.cc"],
+    hdrs = ["hasher.h"],
+    deps = [
+        "//bindings:input_hasher",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/status:statusor",
+        "@riegeli//riegeli/bytes:reader",
+        "@riegeli//riegeli/bytes:writer",
+        "@riegeli//riegeli/endian:endian_writing",
+    ],
+)
+
+cc_test(
+    name = "hasher_test",
+    srcs = ["hasher_test.cc"],
+    deps = [
+        ":hasher",
+        "//bindings:input_hasher",
+        "//crypto:algorithms",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@abseil-cpp//absl/strings",
+        "@googletest//:gtest_main",
+        "@riegeli//riegeli/bytes:string_reader",
+        "@riegeli//riegeli/bytes:string_writer",
+    ],
+)
+
 cc_library(
     name = "reader",
     srcs = ["reader.cc"],
@@ -137,3 +177,59 @@
         "@abseil-cpp//absl/status:statusor",
     ],
 )
+
+cc_library(
+    name = "writer",
+    srcs = ["writer.cc"],
+    hdrs = ["writer.h"],
+    deps = [
+        ":constants",
+        ":hasher",
+        ":reader",
+        "//bindings:input_hasher",
+        "//crypto:algorithms",
+        "//formats:byte_range",
+        "//generator",
+        "//proto:data_hash_assertion_cc_proto",
+        "//proto:generator_cc_proto",
+        "//utils:byte_instruction",
+        "@abseil-cpp//absl/base:nullability",
+        "@abseil-cpp//absl/container:flat_hash_set",
+        "@abseil-cpp//absl/functional:any_invocable",
+        "@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/types:span",
+        "@protobuf//:protobuf_lite",
+        "@riegeli//riegeli/bytes:reader",
+        "@riegeli//riegeli/bytes:string_writer",
+        "@riegeli//riegeli/bytes:writer",
+        "@riegeli//riegeli/endian:endian_writing",
+    ],
+)
+
+cc_test(
+    name = "writer_test",
+    srcs = ["writer_test.cc"],
+    deps = [
+        ":constants",
+        ":extractor",
+        ":reader",
+        ":writer",
+        "//formats:byte_range",
+        "//generator",
+        "//proto:generator_cc_proto",
+        "//testing:nonprod_generator",
+        "@abseil-cpp//absl/container:flat_hash_set",
+        "@abseil-cpp//absl/log:absl_check",
+        "@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",
+    ],
+)
diff --git a/formats/tiff/constants_test.cc b/formats/tiff/constants_test.cc
new file mode 100644
index 0000000..d2d1e29
--- /dev/null
+++ b/formats/tiff/constants_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 "formats/tiff/constants.h"
+
+#include "gtest/gtest.h"
+
+namespace credentio {
+namespace {
+
+TEST(ConstantsTest, SizeOfTiffType) {
+  EXPECT_EQ(SizeOfTiffType(kTiffTypeByte), 1);
+  EXPECT_EQ(SizeOfTiffType(kTiffTypeAscii), 1);
+  EXPECT_EQ(SizeOfTiffType(kTiffTypeShort), 2);
+  EXPECT_EQ(SizeOfTiffType(kTiffTypeLong), 4);
+  EXPECT_EQ(SizeOfTiffType(kTiffTypeRational), 8);
+  EXPECT_EQ(SizeOfTiffType(kTiffTypeSByte), 1);
+  EXPECT_EQ(SizeOfTiffType(kTiffTypeUndefined), 1);
+  EXPECT_EQ(SizeOfTiffType(kTiffTypeSShort), 2);
+  EXPECT_EQ(SizeOfTiffType(kTiffTypeSLong), 4);
+  EXPECT_EQ(SizeOfTiffType(kTiffTypeSRational), 8);
+  EXPECT_EQ(SizeOfTiffType(kTiffTypeFloat), 4);
+  EXPECT_EQ(SizeOfTiffType(kTiffTypeDouble), 8);
+  EXPECT_EQ(SizeOfTiffType(0xFFFF), 1);  // Default case
+}
+
+TEST(ConstantsTest, ByteSizeOfImageFileDirectory) {
+  // count (2) + next offset (4) = 6
+  EXPECT_EQ(ByteSizeOfImageFileDirectory(0), 6);
+  // 6 + 1 * 12 = 18
+  EXPECT_EQ(ByteSizeOfImageFileDirectory(1), 18);
+  // 6 + 10 * 12 = 126
+  EXPECT_EQ(ByteSizeOfImageFileDirectory(10), 126);
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/formats/tiff/extractor_test.cc b/formats/tiff/extractor_test.cc
index 6c580e8..1bc8012 100644
--- a/formats/tiff/extractor_test.cc
+++ b/formats/tiff/extractor_test.cc
@@ -38,24 +38,9 @@
 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 {
@@ -109,7 +94,7 @@
 
 class ExtractorTest : public testing::TestWithParam<TestCase> {
  public:
-  void SetUp() override { ASSERT_THAT(GetParam().contents.status(), IsOk()); }
+  void SetUp() override { ABSL_ASSERT_OK(GetParam().contents.status()); }
 };
 
 INSTANTIATE_TEST_SUITE_P(
@@ -664,14 +649,15 @@
 
 TEST_P(ExtractorTest, ExtractManifestStore) {
   const TestCase& test_case = GetParam();
-  ASSERT_OK_AND_ASSIGN(std::string contents, test_case.contents);
-  riegeli::StringReader<> input(contents);
+  const absl::StatusOr<std::string>& contents = test_case.contents;
+  ABSL_ASSERT_OK(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());
+    ABSL_ASSERT_OK(result);
     EXPECT_THAT(*result, Eq(expected_result->manifest_store));
   } else {
     EXPECT_THAT(result,
@@ -682,14 +668,15 @@
 
 TEST_P(ExtractorTest, ExtractManifestStoreLocation) {
   const TestCase& test_case = GetParam();
-  ASSERT_OK_AND_ASSIGN(std::string contents, test_case.contents);
-  riegeli::StringReader<> input(contents);
+  const absl::StatusOr<std::string>& contents = test_case.contents;
+  ABSL_ASSERT_OK(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());
+    ABSL_ASSERT_OK(result);
     EXPECT_THAT(*result,
                 Eq(expected_result->asset_byte_info.manifest_store_location));
   } else {
@@ -700,8 +687,9 @@
 }
 
 TEST_P(ExtractorTest, ExtractBoxes) {
-  ASSERT_OK_AND_ASSIGN(std::string contents, GetParam().contents);
-  riegeli::StringReader<> input(contents);
+  const absl::StatusOr<std::string>& contents = GetParam().contents;
+  ABSL_ASSERT_OK(contents);
+  riegeli::StringReader<> input(*contents);
   absl::StatusOr<std::vector<AssetBox>> result =
       TiffExtractor().ExtractBoxes(input, {});
 
diff --git a/formats/tiff/hasher.cc b/formats/tiff/hasher.cc
new file mode 100644
index 0000000..999fa07
--- /dev/null
+++ b/formats/tiff/hasher.cc
@@ -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.
+//
+
+#include "formats/tiff/hasher.h"
+
+#include <algorithm>
+#include <cstdint>
+#include <string>
+
+#include "absl/status/status.h"
+#include "absl/status/status_macros.h"
+#include "absl/status/statusor.h"
+#include "bindings/input_hasher.h"
+#include "riegeli/bytes/reader.h"
+#include "riegeli/bytes/writer.h"
+#include "riegeli/endian/endian_writing.h"
+
+namespace credentio {
+
+namespace {
+// It's fine to use a fixed kMaxChunkBytes here, because the embedder will work
+// with any size greater than 8 bytes. (Larger sizes are more efficient.)
+constexpr uint64_t kMaxChunkBytes = 1048576;  // 1 MiB
+
+template <typename T>
+absl::StatusOr<std::string> UintToString(T value, bool is_big_endian) {
+  std::string buffer(sizeof(T), '\0');
+  if constexpr (sizeof(T) == 2 || sizeof(T) == 4) {
+    if (is_big_endian) {
+      riegeli::WriteBigEndian<T>(value, buffer.data());
+    } else {
+      riegeli::WriteLittleEndian<T>(value, buffer.data());
+    }
+  } else {
+    return absl::InvalidArgumentError("Unsupported type size");
+  }
+  return buffer;
+}
+
+template <typename T>
+absl::Status HashWrite(T value, bool is_big_endian, InputHasher& hasher,
+                       riegeli::Writer& writer) {
+  ABSL_ASSIGN_OR_RETURN(std::string buffer, UintToString(value, is_big_endian));
+  hasher.Update(buffer);
+  if (!writer.Write(buffer)) {
+    return absl::DataLossError("failed to write to writer");
+  }
+  return absl::OkStatus();
+}
+
+template <typename T>
+absl::Status Write(T value, bool is_big_endian, riegeli::Writer& writer) {
+  ABSL_ASSIGN_OR_RETURN(std::string buffer, UintToString(value, is_big_endian));
+  if (!writer.Write(buffer)) {
+    return absl::DataLossError("failed to write to writer");
+  }
+  return absl::OkStatus();
+}
+
+}  // namespace
+
+absl::Status ReadHash(riegeli::Reader& reader, int64_t length,
+                      InputHasher& hasher) {
+  uint64_t remaining_length_to_copy = length;
+  while (remaining_length_to_copy > 0) {
+    std::string chunk;
+    auto chunk_size = std::min(remaining_length_to_copy, kMaxChunkBytes);
+    if (!reader.Read(chunk_size, chunk)) {
+      return absl::DataLossError("Failed to read from source TIFF");
+    }
+    hasher.Update(chunk);
+    remaining_length_to_copy -= chunk_size;
+  }
+  return absl::OkStatus();
+}
+
+absl::Status ReadHashWrite(riegeli::Reader& reader, int64_t length,
+                           InputHasher& hasher, riegeli::Writer& writer) {
+  uint64_t remaining_length_to_copy = length;
+  while (remaining_length_to_copy > 0) {
+    std::string chunk;
+    auto chunk_size = std::min(remaining_length_to_copy, kMaxChunkBytes);
+    if (!reader.Read(chunk_size, chunk)) {
+      return absl::DataLossError("Failed to read from source TIFF");
+    }
+    hasher.Update(chunk);
+    if (!writer.Write(chunk)) {
+      return absl::DataLossError("Failed to write to destination TIFF");
+    }
+    remaining_length_to_copy -= chunk_size;
+  }
+  return absl::OkStatus();
+}
+
+absl::Status HashWrite16(uint16_t value, bool is_big_endian,
+                         InputHasher& hasher, riegeli::Writer& writer) {
+  return HashWrite<uint16_t>(value, is_big_endian, hasher, writer);
+}
+absl::Status HashWrite32(uint32_t value, bool is_big_endian,
+                         InputHasher& hasher, riegeli::Writer& writer) {
+  return HashWrite<uint32_t>(value, is_big_endian, hasher, writer);
+}
+
+absl::Status Write32(uint32_t value, bool is_big_endian,
+                     riegeli::Writer& writer) {
+  return Write<uint32_t>(value, is_big_endian, writer);
+}
+
+}  // namespace credentio
diff --git a/formats/tiff/hasher.h b/formats/tiff/hasher.h
new file mode 100644
index 0000000..f2e8566
--- /dev/null
+++ b/formats/tiff/hasher.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_TIFF_HASHER_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_TIFF_HASHER_H_
+
+#include <cstdint>
+
+#include "absl/status/status.h"
+#include "bindings/input_hasher.h"
+#include "riegeli/bytes/reader.h"
+#include "riegeli/bytes/writer.h"
+
+namespace credentio {
+
+// Reads the given length from the reader and hashes the bytes. Returns an error
+// if the read fails.
+absl::Status ReadHash(riegeli::Reader& reader, int64_t length,
+                      InputHasher& hasher);
+
+// Copies the given length from the reader to the writer, hashing the bytes as
+// they are copied. This is a helper function for reading a chunk of data and
+// hashing it, then writing it to the destination. Returns an error if the read
+// or write fails.
+absl::Status ReadHashWrite(riegeli::Reader& reader, int64_t length,
+                           InputHasher& hasher, riegeli::Writer& writer);
+
+// Writes the given value to the writer, hashing the bytes as they are written.
+// This is a helper function for writing a chunk of data and hashing it, then
+// writing it to the destination. Returns an error if the write fails.
+absl::Status HashWrite16(uint16_t value, bool is_big_endian,
+                         InputHasher& hasher, riegeli::Writer& writer);
+absl::Status HashWrite32(uint32_t value, bool is_big_endian,
+                         InputHasher& hasher, riegeli::Writer& writer);
+
+// Writes the given value to the writer. Returns an error if the write fails.
+absl::Status Write32(uint32_t value, bool is_big_endian,
+                     riegeli::Writer& writer);
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_TIFF_HASHER_H_
diff --git a/formats/tiff/hasher_test.cc b/formats/tiff/hasher_test.cc
new file mode 100644
index 0000000..2643c25
--- /dev/null
+++ b/formats/tiff/hasher_test.cc
@@ -0,0 +1,226 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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/hasher.h"
+
+#include <string>
+
+#include "absl/status/status.h"
+#include "absl/status/status_matchers.h"
+#include "absl/strings/escaping.h"
+#include "bindings/input_hasher.h"
+#include "crypto/algorithms.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;
+
+TEST(WriteTest, LittleEndianWrite) {
+  std::string output;
+  riegeli::StringWriter writer(&output);
+
+  EXPECT_THAT(Write32(1, /*is_big_endian=*/false, writer), IsOk());
+
+  writer.Close();
+
+  EXPECT_EQ(output, std::string("\x01\0\0\0", 4));
+}
+
+TEST(WriteTest, BigEndianWrite) {
+  std::string output;
+  riegeli::StringWriter writer(&output);
+
+  EXPECT_THAT(Write32(1, /*is_big_endian=*/true, writer), IsOk());
+
+  writer.Close();
+
+  EXPECT_EQ(output, std::string("\0\0\0\x01", 4));
+}
+
+TEST(WriteTest, Fails) {
+  std::string output;
+  riegeli::StringWriter writer(&output);
+  writer.Close();
+
+  auto hasher = InputHasher::Create(HashAlgorithm::kSha256);
+  ABSL_ASSERT_OK(hasher);
+
+  EXPECT_THAT(
+      Write32(1, /*is_big_endian=*/true, writer),
+      StatusIs(absl::StatusCode::kDataLoss, HasSubstr("failed to write")));
+}
+
+TEST(HashWriteTest, LittleEndianWrite16) {
+  std::string output;
+  riegeli::StringWriter writer(&output);
+
+  auto hasher = InputHasher::Create(HashAlgorithm::kSha256);
+  ABSL_ASSERT_OK(hasher);
+
+  EXPECT_THAT(HashWrite16(1, /*is_big_endian=*/false, **hasher, writer),
+              IsOk());
+
+  writer.Close();
+
+  EXPECT_EQ(output, std::string("\x01\0", 2));
+  EXPECT_EQ(absl::Base64Escape((*hasher)->Digest()),
+            "R9xUDJTOtwSiOHXBEnPha7C4qHrthN6RHyEzVoEV8lQ=");
+}
+
+TEST(HashWriteTest, BigEndianWrite16) {
+  std::string output;
+  riegeli::StringWriter writer(&output);
+
+  auto hasher = InputHasher::Create(HashAlgorithm::kSha256);
+  ABSL_ASSERT_OK(hasher);
+
+  EXPECT_THAT(HashWrite16(1, /*is_big_endian=*/true, **hasher, writer), IsOk());
+
+  writer.Close();
+
+  EXPECT_EQ(output, std::string("\0\x01", 2));
+  EXPECT_EQ(absl::Base64Escape((*hasher)->Digest()),
+            "tBP0fRPuL+bIRbLuFBr4HehY307FSaWLeXC7lmRbyNI=");
+}
+
+TEST(HashWriteTest, LittleEndianWrite32) {
+  std::string output;
+  riegeli::StringWriter writer(&output);
+
+  auto hasher = InputHasher::Create(HashAlgorithm::kSha256);
+  ABSL_ASSERT_OK(hasher);
+
+  EXPECT_THAT(HashWrite32(1, /*is_big_endian=*/false, **hasher, writer),
+              IsOk());
+
+  writer.Close();
+
+  EXPECT_EQ(output, std::string("\x01\0\0\0", 4));
+  EXPECT_EQ(absl::Base64Escape((*hasher)->Digest()),
+            "Z6vdchAk8P9OCz9ML8E7xbrULQt4UdRW2I0gPRWqpFA=");
+}
+
+TEST(HashWriteTest, BigEndianWrite32) {
+  std::string output;
+  riegeli::StringWriter writer(&output);
+
+  auto hasher = InputHasher::Create(HashAlgorithm::kSha256);
+  ABSL_ASSERT_OK(hasher);
+
+  EXPECT_THAT(HashWrite32(1, /*is_big_endian=*/true, **hasher, writer), IsOk());
+
+  writer.Close();
+
+  EXPECT_EQ(output, std::string("\0\0\0\x01", 4));
+  EXPECT_EQ(absl::Base64Escape((*hasher)->Digest()),
+            "tAcRqIxwOXVvuKc4J+q+LA/loDRsp+ChBK3A/HZPUo0=");
+}
+
+TEST(HashWriteTest, Fails) {
+  std::string output;
+  riegeli::StringWriter writer(&output);
+  writer.Close();
+
+  auto hasher = InputHasher::Create(HashAlgorithm::kSha256);
+  ABSL_ASSERT_OK(hasher);
+
+  EXPECT_THAT(
+      HashWrite32(1, /*is_big_endian=*/true, **hasher, writer),
+      StatusIs(absl::StatusCode::kDataLoss, HasSubstr("failed to write")));
+}
+
+TEST(ReadHashWriteTest, Success) {
+  std::string input = "test_string";
+  riegeli::StringReader reader(input);
+  std::string output;
+  riegeli::StringWriter writer(&output);
+
+  auto hasher = InputHasher::Create(HashAlgorithm::kSha256);
+  ABSL_ASSERT_OK(hasher);
+
+  EXPECT_THAT(ReadHashWrite(reader, 4, **hasher, writer), IsOk());
+
+  writer.Close();
+
+  EXPECT_EQ(output, "test");
+  EXPECT_EQ(absl::Base64Escape((*hasher)->Digest()),
+            "n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg=");
+}
+
+TEST(ReadHashTest, Success) {
+  std::string input = "test_string";
+  riegeli::StringReader reader(input);
+
+  auto hasher = InputHasher::Create(HashAlgorithm::kSha256);
+  ABSL_ASSERT_OK(hasher);
+
+  EXPECT_THAT(ReadHash(reader, 4, **hasher), IsOk());
+
+  EXPECT_EQ(absl::Base64Escape((*hasher)->Digest()),
+            "n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg=");
+}
+
+TEST(ReadHashTest, FailsWithClosedReader) {
+  std::string input = "test_string";
+  riegeli::StringReader reader(input);
+
+  auto hasher = InputHasher::Create(HashAlgorithm::kSha256);
+  ABSL_ASSERT_OK(hasher);
+
+  reader.Close();
+  EXPECT_THAT(
+      ReadHash(reader, 4, **hasher),
+      StatusIs(absl::StatusCode::kDataLoss, HasSubstr("Failed to read")));
+}
+
+TEST(ReadHashWriteTest, FailsWithClosedReader) {
+  std::string input = "test_string";
+  riegeli::StringReader reader(input);
+  std::string output;
+  riegeli::StringWriter writer(&output);
+
+  auto hasher = InputHasher::Create(HashAlgorithm::kSha256);
+  ABSL_ASSERT_OK(hasher);
+
+  reader.Close();
+  EXPECT_THAT(
+      ReadHashWrite(reader, 4, **hasher, writer),
+      StatusIs(absl::StatusCode::kDataLoss, HasSubstr("Failed to read")));
+}
+
+TEST(ReadHashWriteTest, FailsWithClosedWriter) {
+  std::string input = "test_string";
+  riegeli::StringReader reader(input);
+  std::string output;
+  riegeli::StringWriter writer(&output);
+
+  auto hasher = InputHasher::Create(HashAlgorithm::kSha256);
+  ABSL_ASSERT_OK(hasher);
+
+  writer.Close();
+  EXPECT_THAT(
+      ReadHashWrite(reader, 4, **hasher, writer),
+      StatusIs(absl::StatusCode::kDataLoss, HasSubstr("Failed to write")));
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/formats/tiff/writer.cc b/formats/tiff/writer.cc
new file mode 100644
index 0000000..b59d26d
--- /dev/null
+++ b/formats/tiff/writer.cc
@@ -0,0 +1,514 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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/writer.h"
+
+#include <algorithm>
+#include <cstdint>
+#include <limits>
+#include <memory>
+#include <optional>
+#include <string>
+#include <utility>
+#include <vector>
+
+#include "absl/base/nullability.h"
+#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 "absl/types/span.h"
+#include "bindings/input_hasher.h"
+#include "crypto/algorithms.h"
+#include "formats/byte_range.h"
+#include "formats/tiff/constants.h"
+#include "formats/tiff/hasher.h"
+#include "formats/tiff/reader.h"
+#include "google/protobuf/repeated_ptr_field.h"
+#include "proto/data_hash_assertion.pb.h"
+#include "proto/generator.pb.h"
+#include "riegeli/bytes/reader.h"
+#include "riegeli/bytes/string_writer.h"
+#include "riegeli/bytes/writer.h"
+#include "riegeli/endian/endian_writing.h"
+#include "utils/byte_instruction.h"
+
+namespace credentio {
+namespace {
+
+// The maximum number of entries per IFD in a TIFF file.
+constexpr uint16_t kTiffMaxEntriesPerIfd = 60000;
+
+template <typename T>
+absl::Status WriteEndian(T value, TiffEndianness endianness,
+                         riegeli::Writer& writer) {
+  const bool success = endianness == TiffEndianness::kBigEndian
+                           ? riegeli::WriteBigEndian<T>(value, writer)
+                           : riegeli::WriteLittleEndian<T>(value, writer);
+
+  return success ? absl::OkStatus()
+                 : writer.StatusOrAnnotate(
+                       absl::InternalError("Failed to write value"));
+}
+
+struct ArtifactInformation {
+  TiffImageFileDirectory ifd_to_add_to;
+  TiffEndianness endianness;
+};
+
+// Scans the TIFF file for the first IFD that can be used to store the C2PA
+// data. If no suitable IFD is found, a new IFD will be created at the end of
+// the file. Returns an error if a C2PA entry already exists.
+absl::StatusOr<ArtifactInformation> ScanForArtifactInformation(
+    riegeli::Reader& source, uint32_t file_size,
+    const GenerateManifestStoreRequest& request,
+    const TiffWriter::ExclusionProcessor& exclusion_processor,
+    DataHashAssertion* absl_nonnull assertion) {
+  ArtifactInformation result;
+
+  absl::flat_hash_set<uint32_t> visited_offsets;
+  ABSL_RETURN_IF_ERROR(IterateOverImageFileDirectories(
+      source, /*known_endianness=*/std::nullopt, visited_offsets,
+      [&result, &request, &exclusion_processor, assertion, &source,
+       &visited_offsets](const TiffImageFileDirectory& ifd,
+                         TiffEndianness endianness) -> absl::StatusOr<bool> {
+        for (const TiffImageFileDirectoryEntry& entry : ifd.entries) {
+          if (entry.tag == kTiffTagC2pa) {
+            return absl::AlreadyExistsError("C2PA entry already exists");
+          }
+          if (exclusion_processor != nullptr) {
+            ABSL_ASSIGN_OR_RETURN(
+                std::vector<ByteRange> exclusions,
+                exclusion_processor(source, entry, endianness, visited_offsets,
+                                    request));
+            for (const ByteRange& exclusion_range : exclusions) {
+              auto* exclusion = assertion->add_exclusions();
+              exclusion->set_start(exclusion_range.offset);
+              exclusion->set_length(exclusion_range.length);
+            }
+          }
+        }
+
+        result.endianness = endianness;
+        result.ifd_to_add_to = ifd;
+        return true;
+      }));
+
+  if (result.ifd_to_add_to.offset_of_pointer != 4 ||
+      result.ifd_to_add_to.entries.size() >= kTiffMaxEntriesPerIfd) {
+    // Need to insert a new IFD at the end of the file
+    result.ifd_to_add_to = TiffImageFileDirectory{
+        .offset_of_pointer = static_cast<uint32_t>(source.pos()),
+        .offset = file_size,
+    };
+  }
+
+  return result;
+}
+
+template <typename T>
+absl::Status AppendNumberToString(T value, TiffEndianness endianness,
+                                  std::string& output) {
+  riegeli::StringWriter writer(
+      &output, riegeli::StringWriterBase::Options().set_append(true));
+
+  const bool success = endianness == TiffEndianness::kBigEndian
+                           ? riegeli::WriteBigEndian<T>(value, writer)
+                           : riegeli::WriteLittleEndian<T>(value, writer);
+  if (!success) {
+    return writer.StatusOrAnnotate(
+        absl::InternalError("Failed to write value"));
+  }
+  if (!writer.Close()) {
+    return writer.StatusOrAnnotate(
+        absl::InternalError("Failed to close writer"));
+  }
+  return absl::OkStatus();
+}
+
+// Adjusts the pointer to the IFD to point to the new location. If the IFD is
+// being moved, zero out the original location.
+absl::StatusOr<std::vector<ByteInstruction>>
+CreateByteInstructionsForIfdInsertion(
+    const TiffImageFileDirectory& ifd_to_add_to, TiffEndianness endianness,
+    uint32_t file_size) {
+  std::vector<ByteInstruction> byte_instructions;
+
+  // Add a byte instruction to update pointer to the IFD.
+  byte_instructions.push_back(ByteInstruction{
+      .operation = ByteInstruction::Operation::kReplace,
+      .offset = ifd_to_add_to.offset_of_pointer,
+  });
+  ABSL_RETURN_IF_ERROR(AppendNumberToString<uint32_t>(
+      file_size, endianness, byte_instructions.back().bytes));
+
+  if (ifd_to_add_to.offset_of_pointer == 4) {
+    // C2PA data will be written to the first (and only) IFD, it will be moved
+    // to the end of the file, zero out the original location.
+    byte_instructions.push_back(ByteInstruction{
+        .operation = ByteInstruction::Operation::kReplace,
+        .offset = ifd_to_add_to.offset,
+        .bytes = std::string(
+            ByteSizeOfImageFileDirectory(ifd_to_add_to.entries.size()), '\0'),
+    });
+  }
+  return std::move(byte_instructions);
+}
+
+void SanitizeExclusions(
+    google::protobuf::RepeatedPtrField<DataRange>* exclusions) {
+  if (exclusions->empty()) return;
+  std::sort(exclusions->pointer_begin(), exclusions->pointer_end(),
+            [](const DataRange* a, const DataRange* b) {
+              if (a->start() != b->start()) return a->start() < b->start();
+              return a->length() < b->length();
+            });
+
+  google::protobuf::RepeatedPtrField<DataRange> merged;
+  DataRange current = (*exclusions)[0];
+  for (int i = 1; i < exclusions->size(); ++i) {
+    const DataRange& next = (*exclusions)[i];
+    uint64_t current_end = current.start() + current.length();
+    uint64_t next_end = next.start() + next.length();
+    if (next.start() <= current_end) {
+      if (next_end > current_end) {
+        current.set_length(next_end - current.start());
+      }
+    } else {
+      *merged.Add() = current;
+      current = next;
+    }
+  }
+  *merged.Add() = current;
+  exclusions->Swap(&merged);
+}
+
+absl::Status HashAroundExclusion(const DataRange& exclusion,
+                                 riegeli::Reader& source, InputHasher& hasher) {
+  if (exclusion.start() < source.pos()) {
+    return absl::InvalidArgumentError(
+        "Exclusion start is before current source position");
+  }
+  // Hash up to the start of the exclusion.
+  uint64_t hash_length = exclusion.start() - source.pos();
+  ABSL_RETURN_IF_ERROR(ReadHash(source, hash_length, hasher));
+
+  // Skip the excluded data
+  if (!source.Skip(exclusion.length())) {
+    return absl::InternalError("failed to skip data");
+  }
+
+  return absl::OkStatus();
+}
+
+absl::Status HashInstructionAroundExclusion(const DataRange& exclusion,
+                                            uint64_t instruction_offset,
+                                            absl::string_view instruction_bytes,
+                                            InputHasher& hasher) {
+  if (exclusion.start() < instruction_offset ||
+      exclusion.start() - instruction_offset > instruction_bytes.size()) {
+    return absl::InvalidArgumentError(
+        "Exclusion range out of bounds of byte instruction");
+  }
+
+  // hash until the exclusion offset
+  absl::string_view data_to_hash =
+      instruction_bytes.substr(0, exclusion.start() - instruction_offset);
+  hasher.Update(data_to_hash);
+
+  if (exclusion.length() >
+      instruction_bytes.size() - (exclusion.start() - instruction_offset)) {
+    return absl::InvalidArgumentError(
+        "Exclusion data out of bounds of byte instruction");
+  }
+
+  return absl::OkStatus();
+}
+
+absl::Status Hash(riegeli::Reader& source, uint64_t length_with_ifd,
+                  absl::Span<const ByteInstruction> byte_instructions,
+                  const DataHashAssertion& assertion, InputHasher& hasher) {
+  auto exclusion_iter = assertion.exclusions().begin();
+
+  for (const ByteInstruction& ins : byte_instructions) {
+    while (exclusion_iter->start() < ins.offset) {
+      ABSL_RETURN_IF_ERROR(
+          HashAroundExclusion(*exclusion_iter, source, hasher));
+      ++exclusion_iter;
+      if (exclusion_iter == assertion.exclusions().end()) {
+        return absl::InternalError("ran out of exclusions");
+      }
+    }
+    // Next exclusion is after the start of the instruction, hash up to the
+    // instruction offset.
+    if (ins.offset < source.pos()) {
+      return absl::InvalidArgumentError(
+          "Byte instruction offset is before current source position");
+    }
+    ABSL_RETURN_IF_ERROR(ReadHash(source, ins.offset - source.pos(), hasher));
+
+    uint64_t ins_processed = 0;
+    while (exclusion_iter->start() < ins.offset + ins.bytes.size()) {
+      ABSL_RETURN_IF_ERROR(HashInstructionAroundExclusion(
+          *exclusion_iter, ins.offset + ins_processed,
+          absl::string_view(ins.bytes).substr(ins_processed), hasher));
+
+      ins_processed =
+          (exclusion_iter->start() + exclusion_iter->length()) - ins.offset;
+
+      ++exclusion_iter;
+      if (exclusion_iter == assertion.exclusions().end()) {
+        return absl::InternalError("ran out of exclusions");
+      }
+    }
+
+    // Process remaining instruction bytes
+    if (ins_processed < ins.bytes.size()) {
+      absl::string_view data_to_hash =
+          absl::string_view(ins.bytes).substr(ins_processed);
+      hasher.Update(data_to_hash);
+    }
+
+    if (ins.operation == ByteInstruction::Operation::kReplace) {
+      if (!source.Skip(ins.bytes.size())) {
+        return absl::InternalError("failed to skip data");
+      }
+    }
+  }
+
+  return absl::OkStatus();
+}
+
+absl::Status WriteImageFileDirectory(
+    absl::Span<const TiffImageFileDirectoryEntry> entries,
+    TiffEndianness endianness, riegeli::Writer& writer) {
+  // Entry Count
+  ABSL_RETURN_IF_ERROR(
+      WriteEndian<uint16_t>(entries.size(), endianness, writer));
+  // Entries
+  for (const auto& entry : entries) {
+    ABSL_RETURN_IF_ERROR(WriteEndian<uint16_t>(entry.tag, endianness, writer));
+    ABSL_RETURN_IF_ERROR(WriteEndian<uint16_t>(entry.type, endianness, writer));
+    ABSL_RETURN_IF_ERROR(
+        WriteEndian<uint32_t>(entry.count, endianness, writer));
+    ABSL_RETURN_IF_ERROR(
+        WriteEndian<uint32_t>(entry.value_or_offset, endianness, writer));
+  }
+  // Next IFD Offset
+  ABSL_RETURN_IF_ERROR(WriteEndian<uint32_t>(0, endianness, writer));
+  return absl::OkStatus();
+}
+
+absl::Status WriteImageFileDirectory(
+    absl::Span<const TiffImageFileDirectoryEntry> entries,
+    TiffEndianness endianness, std::string& destination) {
+  riegeli::StringWriter<> writer(&destination);
+  ABSL_RETURN_IF_ERROR(WriteImageFileDirectory(entries, endianness, writer));
+  if (!writer.Close()) {
+    return writer.status();
+  }
+  return absl::OkStatus();
+}
+
+}  // namespace
+
+absl::StatusOr<std::vector<ByteInstruction>> TiffWriter::WriteInternal(
+    GenerateManifestStoreRequest request, riegeli::Reader* absl_nonnull source,
+    OutputMetadata* absl_nullable output_metadata) const {
+  if (request.has_hard_binding()) {
+    return absl::InvalidArgumentError(
+        "request must not include `hard_binding` (binding is computed by "
+        "TiffWriter)");
+  }
+  if (!source->SupportsSize()) {
+    return absl::InvalidArgumentError("source must support Size()");
+  }
+  if (!source->Size().has_value()) {
+    return absl::InternalError("source->Size() returned no value");
+  }
+
+  uint64_t source_size = *source->Size();
+
+  auto hash_algorithm = HashAlgorithm::kSha256;
+
+  auto* assertion = request.mutable_hard_binding()->mutable_data_hash();
+  assertion->set_alg(absl::StrCat(hash_algorithm));
+  assertion->set_hash(std::string(32, '\0'));
+
+  ABSL_ASSIGN_OR_RETURN(
+      ArtifactInformation artifact_information,
+      ScanForArtifactInformation(*source, source_size, request,
+                                 options_.exclusion_processor, assertion));
+
+  ABSL_ASSIGN_OR_RETURN(std::vector<ByteInstruction> byte_instructions,
+                        CreateByteInstructionsForIfdInsertion(
+                            artifact_information.ifd_to_add_to,
+                            artifact_information.endianness, source_size));
+
+  uint32_t file_size_after_ifd =
+      source_size + ByteSizeOfImageFileDirectory(
+                        artifact_information.ifd_to_add_to.entries.size() + 1);
+
+  artifact_information.ifd_to_add_to.entries.push_back(
+      TiffImageFileDirectoryEntry{
+          .tag = kTiffTagC2pa,
+          .type = kTiffTypeUndefined,
+          .count = 1,
+          .value_or_offset = file_size_after_ifd,
+      });
+
+  std::sort(artifact_information.ifd_to_add_to.entries.begin(),
+            artifact_information.ifd_to_add_to.entries.end(),
+            [](const TiffImageFileDirectoryEntry& a,
+               const TiffImageFileDirectoryEntry& b) { return a.tag < b.tag; });
+
+  {
+    // Holder for the C2PA IFD entry count.
+    auto* count_exclusion = assertion->add_exclusions();
+    // IFD count, IFD Entry tag, and IFD Entry type are 3 uint16_t's
+
+    uint16_t c2pa_entry_index = 0;
+    for (const auto& entry : artifact_information.ifd_to_add_to.entries) {
+      if (entry.tag == kTiffTagC2pa) {
+        break;
+      }
+      ++c2pa_entry_index;
+    }
+
+    count_exclusion->set_start(source_size +       // Existing file size
+                               sizeof(uint16_t) +  // final IFD count
+                               // Number of entries before C2PA entry
+                               (c2pa_entry_index * kTiffIfdEntrySize) +
+                               sizeof(uint16_t) +   // C2PA entry tag
+                               sizeof(uint16_t));   // C2PA entry type
+    count_exclusion->set_length(sizeof(uint32_t));  // C2PA entry count
+
+    // Holder for the Manifest Store.
+    auto* length_exclusion = assertion->add_exclusions();
+    // Will be located after the IFD containing the C2PA data.
+    length_exclusion->set_start(file_size_after_ifd);
+    length_exclusion->set_length(1);
+  }
+
+  SanitizeExclusions(assertion->mutable_exclusions());
+
+  ABSL_ASSIGN_OR_RETURN(auto template_response,
+                        generator_.GenerateManifestStoreTemplate(request));
+
+  if ((std::numeric_limits<uint32_t>::max() - file_size_after_ifd -
+       template_response.manifest_store_size()) < 0) {
+    return absl::InvalidArgumentError(
+        "Manifest store size is too large (not enough space for TIFF "
+        "structure)");
+  }
+
+  // Update the length exclusion to the actual length of the manifest store.
+  assertion->mutable_exclusions()->rbegin()->set_length(
+      template_response.manifest_store_size());
+  for (auto& entry : artifact_information.ifd_to_add_to.entries) {
+    if (entry.tag == kTiffTagC2pa) {
+      entry.count = template_response.manifest_store_size();
+      break;
+    }
+  }
+
+  {
+    ByteInstruction last_ifd{
+        .operation = ByteInstruction::Operation::kInsert,
+        .offset = source_size,
+    };
+    ABSL_RETURN_IF_ERROR(WriteImageFileDirectory(
+        artifact_information.ifd_to_add_to.entries,
+        artifact_information.endianness, last_ifd.bytes));
+    byte_instructions.push_back(last_ifd);
+  }
+
+  std::sort(byte_instructions.begin(), byte_instructions.end(),
+            [](const ByteInstruction& a, const ByteInstruction& b) {
+              return a.offset < b.offset;
+            });
+
+  source->Seek(0);
+
+  std::unique_ptr<credentio::InputHasher> hasher;
+  ABSL_ASSIGN_OR_RETURN(hasher, InputHasher::Create(hash_algorithm));
+
+  ABSL_RETURN_IF_ERROR(Hash(*source, file_size_after_ifd, byte_instructions,
+                            *assertion, *hasher));
+
+  FinalizeManifestStoreRequest finalize_request;
+  *finalize_request.mutable_manifest_store_template() =
+      std::move(template_response);
+  *finalize_request.mutable_hard_binding()->mutable_data_hash() =
+      request.hard_binding().data_hash();
+  finalize_request.mutable_hard_binding()->mutable_data_hash()->set_hash(
+      hasher->Digest());
+
+  ABSL_ASSIGN_OR_RETURN(auto finalize_response,
+                        generator_.FinalizeManifestStore(finalize_request));
+
+  byte_instructions.push_back(
+      ByteInstruction{.operation = ByteInstruction::Operation::kInsert,
+                      .offset = static_cast<uint64_t>(file_size_after_ifd),
+                      .bytes = finalize_response.manifest_store()});
+
+  std::sort(byte_instructions.begin(), byte_instructions.end(),
+            [](const ByteInstruction& a, const ByteInstruction& b) {
+              return a.offset < b.offset;
+            });
+
+  if (output_metadata != nullptr) {
+    output_metadata->timestamp_included = finalize_response.is_timestamped();
+  }
+
+  return byte_instructions;
+}
+
+absl::Status TiffWriter::Write(
+    GenerateManifestStoreRequest request, riegeli::Reader* absl_nonnull source,
+    riegeli::Writer* absl_nonnull destination,
+    OutputMetadata* absl_nullable output_metadata) const {
+  ABSL_ASSIGN_OR_RETURN(
+      auto byte_instructions,
+      WriteInternal(std::move(request), source, output_metadata));
+
+  return ApplyByteInstructions(source, byte_instructions, destination);
+}
+
+absl::Status TiffWriter::Write(
+    GenerateManifestStoreRequest request, riegeli::Writer* absl_nonnull asset,
+    OutputMetadata* absl_nullable output_metadata) const {
+  if (!asset->SupportsRandomAccess()) {
+    return absl::InvalidArgumentError("asset must support RandomAccess()");
+  }
+  if (!asset->SupportsReadMode()) {
+    return absl::InvalidArgumentError("asset must support ReadMode()");
+  }
+
+  riegeli::Reader* source = asset->ReadMode(0);
+  if (source == nullptr) {
+    return absl::InternalError("Failed to switch asset to ReadMode");
+  }
+
+  ABSL_ASSIGN_OR_RETURN(
+      auto byte_instructions,
+      WriteInternal(std::move(request), source, output_metadata));
+
+  return ApplyByteInstructions(byte_instructions, asset);
+}
+
+}  // namespace credentio
diff --git a/formats/tiff/writer.h b/formats/tiff/writer.h
new file mode 100644
index 0000000..6bb76ff
--- /dev/null
+++ b/formats/tiff/writer.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_TIFF_WRITER_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_TIFF_WRITER_H_
+
+#include <cstdint>
+#include <utility>
+#include <vector>
+
+#include "absl/base/nullability.h"
+#include "absl/container/flat_hash_set.h"
+#include "absl/functional/any_invocable.h"
+#include "absl/log/die_if_null.h"
+#include "absl/status/status.h"
+#include "absl/status/statusor.h"
+#include "formats/byte_range.h"
+#include "formats/tiff/reader.h"
+#include "generator/generator.h"
+#include "proto/generator.pb.h"
+#include "riegeli/bytes/reader.h"
+#include "riegeli/bytes/writer.h"
+#include "utils/byte_instruction.h"
+
+namespace credentio {
+
+// Writer of C2PA manifest stores into TIFF images.
+class TiffWriter {
+ public:
+  struct OutputMetadata {
+    bool timestamp_included = false;
+  };
+
+  // A function that determines byte ranges associated with a given IFD entry to
+  // be excluded from hashing.
+  using ExclusionProcessor =
+      absl::AnyInvocable<absl::StatusOr<std::vector<ByteRange>>(
+          riegeli::Reader&, const TiffImageFileDirectoryEntry&, TiffEndianness,
+          absl::flat_hash_set<uint32_t>&, const GenerateManifestStoreRequest&)
+                             const>;
+
+  struct Options {
+    ExclusionProcessor exclusion_processor = nullptr;
+  };
+
+  // Creates a TIFF writer.
+  //
+  // The generator must outlive the writer.
+  explicit TiffWriter(const ManifestStoreGenerator* absl_nonnull generator)
+      : TiffWriter(generator, Options{}) {}
+
+  TiffWriter(const ManifestStoreGenerator* absl_nonnull generator,
+             Options options)
+      : generator_(*ABSL_DIE_IF_NULL(generator)),
+        options_(std::move(options)) {}
+
+  // Generates a C2PA manifest store and embeds it into a copy of the given
+  // asset. The riegeli::Reader and riegeli::Writer cannot be pointed to the
+  // same underlying data. The request must not include `hard_binding`.
+  // Fails if C2PA metadata already exists.
+  absl::Status Write(
+      GenerateManifestStoreRequest request,
+      riegeli::Reader* absl_nonnull source,
+      riegeli::Writer* absl_nonnull destination,
+      OutputMetadata* absl_nullable output_metadata = nullptr) const;
+
+  // Generates and embeds C2PA manifest in place into the given asset.
+  absl::Status Write(
+      GenerateManifestStoreRequest request, riegeli::Writer* absl_nonnull asset,
+      OutputMetadata* absl_nullable output_metadata = nullptr) const;
+
+ private:
+  absl::StatusOr<std::vector<ByteInstruction>> WriteInternal(
+      GenerateManifestStoreRequest request,
+      riegeli::Reader* absl_nonnull source,
+      OutputMetadata* absl_nullable output_metadata) const;
+
+  const ManifestStoreGenerator& generator_;
+  Options options_;
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_TIFF_WRITER_H_
diff --git a/formats/tiff/writer_test.cc b/formats/tiff/writer_test.cc
new file mode 100644
index 0000000..8fc46e5
--- /dev/null
+++ b/formats/tiff/writer_test.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 "formats/tiff/writer.h"
+
+#include <cstdint>
+#include <memory>
+#include <optional>
+#include <string>
+#include <utility>
+
+#include "absl/container/flat_hash_set.h"
+#include "absl/log/absl_check.h"
+#include "absl/status/status.h"
+#include "absl/status/status_matchers.h"
+#include "absl/status/statusor.h"
+#include "formats/byte_range.h"
+#include "formats/tiff/constants.h"
+#include "formats/tiff/extractor.h"
+#include "formats/tiff/reader.h"
+#include "generator/generator.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "proto/generator.pb.h"
+#include "riegeli/bytes/string_reader.h"
+#include "riegeli/bytes/string_writer.h"
+#include "riegeli/endian/endian_writing.h"
+#include "testing/nonprod_generator.h"
+
+namespace credentio {
+namespace {
+
+using ::absl_testing::IsOkAndHolds;
+using ::absl_testing::StatusIs;
+using ::testing::AllOf;
+using ::testing::Field;
+using ::testing::Gt;
+using ::testing::HasSubstr;
+using ::testing::Optional;
+
+std::string CreateMinimalTiff(
+    TiffEndianness endianness = TiffEndianness::kBigEndian) {
+  std::string buffer;
+  riegeli::StringWriter writer(&buffer);
+  if (endianness == TiffEndianness::kBigEndian) {
+    riegeli::WriteBigEndian<uint32_t>(kTiffBigEndianHeader, writer);
+    riegeli::WriteBigEndian<uint32_t>(8, writer);  // Offset to first IFD
+    riegeli::WriteBigEndian<uint16_t>(2, writer);  // count
+    riegeli::WriteBigEndian<uint16_t>(0x0100, writer);
+    riegeli::WriteBigEndian<uint16_t>(kTiffTypeShort, writer);
+    riegeli::WriteBigEndian<uint32_t>(1, writer);
+    riegeli::WriteBigEndian<uint32_t>(10, writer);
+    riegeli::WriteBigEndian<uint16_t>(0xffff, writer);
+    riegeli::WriteBigEndian<uint16_t>(kTiffTypeShort, writer);
+    riegeli::WriteBigEndian<uint32_t>(1, writer);
+    riegeli::WriteBigEndian<uint32_t>(20, writer);
+    riegeli::WriteBigEndian<uint32_t>(0, writer);  // next IFD offset
+  } else {
+    riegeli::WriteBigEndian<uint32_t>(kTiffLittleEndianHeader, writer);
+    riegeli::WriteLittleEndian<uint32_t>(8, writer);  // Offset to first IFD
+    riegeli::WriteLittleEndian<uint16_t>(2, writer);  // count
+    riegeli::WriteLittleEndian<uint16_t>(0x0100, writer);
+    riegeli::WriteLittleEndian<uint16_t>(kTiffTypeShort, writer);
+    riegeli::WriteLittleEndian<uint32_t>(1, writer);
+    riegeli::WriteLittleEndian<uint32_t>(10, writer);
+    riegeli::WriteLittleEndian<uint16_t>(0xffff, writer);
+    riegeli::WriteLittleEndian<uint16_t>(kTiffTypeShort, writer);
+    riegeli::WriteLittleEndian<uint32_t>(1, writer);
+    riegeli::WriteLittleEndian<uint32_t>(20, writer);
+    riegeli::WriteLittleEndian<uint32_t>(0, writer);  // next IFD offset
+  }
+  writer.Close();
+  return buffer;
+}
+
+class TiffWriterTest : public ::testing::Test {
+ protected:
+  void SetUp() override {
+    auto generator =
+        CreateNonProdManifestStoreGenerator({.disable_timestamping = true});
+    ABSL_CHECK_OK(generator);
+    generator_ = *std::move(generator);
+    writer_ = std::make_unique<TiffWriter>(generator_.get());
+  }
+
+  std::unique_ptr<ManifestStoreGenerator> generator_;
+  std::unique_ptr<TiffWriter> writer_;
+};
+
+TEST_F(TiffWriterTest, WriteMaintainsSortedTags) {
+  std::string buffer = CreateMinimalTiff();
+
+  GenerateManifestStoreRequest request;
+  request.mutable_actions()
+      ->mutable_created()
+      ->mutable_action_metadata()
+      ->set_description("test only");
+
+  riegeli::StringReader source(buffer);
+  std::string result;
+  riegeli::StringWriter destination(&result);
+  ABSL_ASSERT_OK(writer_->Write(request, &source, &destination));
+  ASSERT_TRUE(source.Close());
+  ASSERT_TRUE(destination.Close());
+
+  riegeli::StringReader result_reader(result);
+  EXPECT_THAT(TiffExtractor().ExtractManifestStoreLocation(result_reader, {}),
+              IsOkAndHolds(Optional(AllOf(Field(&ByteRange::offset, Gt(0)),
+                                          Field(&ByteRange::length, Gt(0))))));
+
+  // Validate that output IFD has the C2PA tag, and all tags are properly
+  // sorted.
+  ASSERT_TRUE(result_reader.Seek(0));
+  absl::flat_hash_set<uint32_t> visited_offsets;
+  bool validated = false;
+  ABSL_ASSERT_OK(IterateOverImageFileDirectories(
+      result_reader, /*known_endianness=*/std::nullopt, visited_offsets,
+      [&](const TiffImageFileDirectory& ifd,
+          TiffEndianness endianness) -> absl::StatusOr<bool> {
+        validated = true;
+        EXPECT_EQ(ifd.entries.size(), 3);
+        EXPECT_EQ(ifd.entries[0].tag, 0x0100);
+        EXPECT_EQ(ifd.entries[1].tag, kTiffTagC2pa);
+        EXPECT_EQ(ifd.entries[2].tag, 0xffff);
+        return true;
+      }));
+  EXPECT_TRUE(validated);
+}
+
+TEST_F(TiffWriterTest, WriteLittleEndianSucceeds) {
+  std::string buffer = CreateMinimalTiff(TiffEndianness::kLittleEndian);
+
+  GenerateManifestStoreRequest request;
+  request.mutable_actions()
+      ->mutable_created()
+      ->mutable_action_metadata()
+      ->set_description("test only");
+
+  riegeli::StringReader source(buffer);
+  std::string result;
+  riegeli::StringWriter destination(&result);
+  ABSL_ASSERT_OK(writer_->Write(request, &source, &destination));
+  ASSERT_TRUE(source.Close());
+  ASSERT_TRUE(destination.Close());
+
+  riegeli::StringReader result_reader(result);
+  EXPECT_THAT(TiffExtractor().ExtractManifestStoreLocation(result_reader, {}),
+              IsOkAndHolds(Optional(AllOf(Field(&ByteRange::offset, Gt(0)),
+                                          Field(&ByteRange::length, Gt(0))))));
+}
+
+TEST_F(TiffWriterTest, WriteTwiceFails) {
+  GenerateManifestStoreRequest request;
+  request.mutable_actions()
+      ->mutable_created()
+      ->mutable_action_metadata()
+      ->set_description("test only");
+
+  std::string image = CreateMinimalTiff();
+  riegeli::StringReader source(image);
+  std::string result1;
+  riegeli::StringWriter destination1(&result1);
+  ABSL_ASSERT_OK(writer_->Write(request, &source, &destination1));
+  ASSERT_TRUE(source.Close());
+  ASSERT_TRUE(destination1.Close());
+
+  riegeli::StringReader source2(result1);
+  std::string result2;
+  riegeli::StringWriter destination2(&result2);
+  EXPECT_THAT(
+      writer_->Write(request, &source2, &destination2),
+      StatusIs(absl::StatusCode::kAlreadyExists, HasSubstr("already exists")));
+}
+
+TEST_F(TiffWriterTest, WriteWithHardBindingFails) {
+  GenerateManifestStoreRequest request;
+  request.mutable_actions()
+      ->mutable_created()
+      ->mutable_action_metadata()
+      ->set_description("test only");
+  request.mutable_hard_binding();
+  std::string image = CreateMinimalTiff();
+  riegeli::StringReader source(image);
+  std::string result;
+  riegeli::StringWriter destination(&result);
+  EXPECT_THAT(writer_->Write(request, &source, &destination),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("must not include `hard_binding`")));
+}
+
+TEST_F(TiffWriterTest, WriteInPlaceSucceeds) {
+  GenerateManifestStoreRequest request;
+  request.mutable_actions()
+      ->mutable_created()
+      ->mutable_action_metadata()
+      ->set_description("test only");
+  std::string image = CreateMinimalTiff();
+
+  riegeli::StringWriter<std::string*> asset(
+      &image, riegeli::StringWriterBase::Options().set_append(true));
+  ABSL_ASSERT_OK(asset.status());
+
+  ABSL_ASSERT_OK(writer_->Write(request, &asset));
+  ASSERT_TRUE(asset.Close());
+
+  riegeli::StringReader result_reader(image);
+  EXPECT_THAT(TiffExtractor().ExtractManifestStoreLocation(result_reader, {}),
+              IsOkAndHolds(Optional(AllOf(Field(&ByteRange::offset, Gt(0)),
+                                          Field(&ByteRange::length, Gt(0))))));
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/formats/zip/BUILD b/formats/zip/BUILD
index 10b9ff8..1a0dbf8 100644
--- a/formats/zip/BUILD
+++ b/formats/zip/BUILD
@@ -107,6 +107,7 @@
         "@abseil-cpp//absl/strings",
         "@abseil-cpp//absl/types:span",
         "@googletest//:gtest_main",
+        "@riegeli//riegeli/bytes:reader",
         "@riegeli//riegeli/bytes:string_reader",
         "@riegeli//riegeli/bytes:string_writer",
         "@riegeli//riegeli/endian:endian_writing",
@@ -149,3 +150,236 @@
         "@abseil-cpp//absl/status:statusor",
     ],
 )
+
+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",
+        "@abseil-cpp//absl/strings:string_view",
+        "@googletest//:gtest_main",
+    ],
+)
+
+cc_library(
+    name = "hasher_data",
+    srcs = ["hasher_data.cc"],
+    hdrs = ["hasher_data.h"],
+    deps = [
+        ":constants",
+        ":reader",
+        "//bindings:input_hasher",
+        "//crypto:algorithms",
+        "//proto:collection_data_hash_assertion_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",
+        "@abseil-cpp//absl/types:span",
+        "@riegeli//riegeli/bytes:reader",
+    ],
+)
+
+cc_test(
+    name = "hasher_data_test",
+    srcs = ["hasher_data_test.cc"],
+    data = [
+        "//formats/zip/testing:assets",
+    ],
+    deps = [
+        ":hasher_data",
+        ":reader",
+        "//bindings:collection_data_hash_hard_binding_validator",
+        "//constants:status_codes",
+        "//crypto:algorithms",
+        "//proto:collection_data_hash_assertion_cc_proto",
+        "//testing:proto_test_utils",
+        "//testing:test_file_utils",
+        "//testing:test_validation_tracker",
+        "//utils:status_tracker",
+        "//validator:tracker",
+        "@abseil-cpp//absl/status",
+        "@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",
+    ],
+)
+
+cc_library(
+    name = "zlib_util",
+    srcs = ["zlib_util.cc"],
+    hdrs = ["zlib_util.h"],
+    deps = [
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings:string_view",
+        "@riegeli//riegeli/base:initializer",
+        "@riegeli//riegeli/bytes:string_reader",
+        "@riegeli//riegeli/bytes:string_writer",
+        "@riegeli//riegeli/zlib:zlib_reader",
+        "@riegeli//riegeli/zlib:zlib_writer",
+    ],
+)
+
+cc_test(
+    name = "zlib_util_test",
+    srcs = ["zlib_util_test.cc"],
+    deps = [
+        ":zlib_util",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@googletest//:gtest_main",
+    ],
+)
+
+cc_library(
+    name = "content_type_stream_util",
+    srcs = ["content_type_stream_util.cc"],
+    hdrs = ["content_type_stream_util.h"],
+    deps = [
+        ":constants",
+        ":crc",
+        ":reader",
+        ":zlib_util",
+        "@abseil-cpp//absl/base:nullability",
+        "@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/base:types",
+        "@riegeli//riegeli/bytes:reader",
+        "@riegeli//riegeli/bytes:writer",
+        "@riegeli//riegeli/endian:endian_reading",
+        "@riegeli//riegeli/endian:endian_writing",
+    ],
+)
+
+cc_test(
+    name = "content_type_stream_util_test",
+    srcs = ["content_type_stream_util_test.cc"],
+    deps = [
+        ":constants",
+        ":content_type_stream_util",
+        ":reader",
+        "@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_library(
+    name = "embedder",
+    srcs = ["embedder.cc"],
+    hdrs = ["embedder.h"],
+    deps = [
+        ":constants",
+        ":content_type_stream_util",
+        ":crc",
+        ":reader",
+        "@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",
+        "@abseil-cpp//absl/types:span",
+        "@riegeli//riegeli/base:byte_fill",
+        "@riegeli//riegeli/base:types",
+        "@riegeli//riegeli/bytes:reader",
+        "@riegeli//riegeli/bytes:writer",
+        "@riegeli//riegeli/endian:endian_writing",
+    ],
+)
+
+cc_test(
+    name = "embedder_test",
+    srcs = ["embedder_test.cc"],
+    data = [
+        "//formats/zip/testing:assets",
+    ],
+    deps = [
+        ":constants",
+        ":embedder",
+        ":extractor",
+        ":reader",
+        ":zlib_util",
+        "//formats:byte_range",
+        "//testing:test_file_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:reader",
+        "@riegeli//riegeli/bytes:string_reader",
+        "@riegeli//riegeli/bytes:string_writer",
+    ],
+)
+
+cc_library(
+    name = "writer",
+    srcs = ["writer.cc"],
+    hdrs = ["writer.h"],
+    deps = [
+        ":embedder",
+        ":hasher_data",
+        ":reader",
+        "//crypto:algorithms",
+        "//generator",
+        "//proto:generator_cc_proto",
+        "@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/strings",
+        "@abseil-cpp//absl/strings:string_view",
+        "@riegeli//riegeli/bytes:reader",
+        "@riegeli//riegeli/bytes:writer",
+    ],
+)
+
+cc_test(
+    name = "writer_test",
+    srcs = ["writer_test.cc"],
+    data = [
+        "//formats/zip/testing:assets",
+    ],
+    deps = [
+        ":extractor",
+        ":writer",
+        "//formats:byte_range",
+        "//generator",
+        "//proto:generator_cc_proto",
+        "//proto:manifest_cc_proto",
+        "//proto:validation_status_cc_proto",
+        "//testing:nonprod_generator",
+        "//testing:nonprod_validator",
+        "//testing:test_file_utils",
+        "//validator:asset_validator",
+        "//validator:result",
+        "@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",
+    ],
+)
diff --git a/formats/zip/content_type_stream_util.cc b/formats/zip/content_type_stream_util.cc
new file mode 100644
index 0000000..c9fba27
--- /dev/null
+++ b/formats/zip/content_type_stream_util.cc
@@ -0,0 +1,150 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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/content_type_stream_util.h"
+
+#include <cstddef>
+#include <cstdint>
+#include <optional>
+#include <string>
+
+#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 "formats/zip/constants.h"
+#include "formats/zip/crc.h"
+#include "formats/zip/reader.h"
+#include "formats/zip/zlib_util.h"
+#include "riegeli/base/types.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 {
+
+constexpr uint16_t kDeflateCompressionMethod = 8;
+
+// Checks if the file is compressed using DEFLATE algorithm.
+absl::StatusOr<bool> IsFileDeflated(riegeli::Reader* absl_nonnull source,
+                                    const ZipReader::FileEntry& entry) {
+  // Seek to the compression method field in the local file header.
+  source->Seek(entry.local_file_header_offset + 8);
+  ABSL_RETURN_IF_ERROR(source->status());
+  uint16_t compression_method;
+  riegeli::ReadLittleEndian<uint16_t>(*source, compression_method);
+  ABSL_RETURN_IF_ERROR(source->status());
+  return compression_method == kDeflateCompressionMethod;
+}
+
+}  // namespace
+
+absl::StatusOr<ContentTypesStream> ReadContentTypesStream(
+    riegeli::Reader* absl_nonnull source,
+    const ZipReader::FileEntry& content_types_file_entry) {
+  const uint64_t length = content_types_file_entry.file_range.length;
+  // Content Types file is typically very small. Cap it at 10 MB.
+  constexpr uint64_t kMaxContentTypesSize = 10 * 1024 * 1024;
+  if (length > kMaxContentTypesSize) {
+    return absl::InvalidArgumentError(
+        "Content types stream size exceeds maximum limit.");
+  }
+  std::optional<riegeli::Position> source_size = source->Size();
+  if (source_size.has_value() && length > *source_size) {
+    return absl::InvalidArgumentError(
+        "Content types stream length exceeds source size.");
+  }
+  source->Seek(content_types_file_entry.file_range.offset);
+  ABSL_RETURN_IF_ERROR(source->status());
+  std::string content;
+  source->Read(length, content);
+  ABSL_RETURN_IF_ERROR(source->status());
+  ABSL_ASSIGN_OR_RETURN(bool is_deflated,
+                        IsFileDeflated(source, content_types_file_entry));
+  if (is_deflated) {
+    ABSL_ASSIGN_OR_RETURN(content, Uncompress(content));
+  }
+  return ContentTypesStream{.content = content, .is_deflated = is_deflated};
+}
+
+absl::Status UpdateContentTypesStream(ContentTypesStream& stream,
+                                      absl::string_view content_type,
+                                      absl::string_view part_name) {
+  std::string override_element =
+      absl::Substitute(R"(<Override ContentType="$0" PartName="/$1"/>)",
+                       content_type, part_name);
+
+  // If the override already exists, we do nothing.
+  if (stream.content.find(override_element) != std::string::npos) {
+    return absl::OkStatus();
+  }
+
+  size_t pos = stream.content.rfind("</Types>");
+  if (pos == std::string::npos) {
+    return absl::InvalidArgumentError(
+        "Invalid content types stream: missing </Types>");
+  }
+  stream.content.insert(pos, override_element);
+  return absl::OkStatus();
+}
+
+absl::StatusOr<ContentTypesStreamMetadata> WriteContentTypesStream(
+    const ContentTypesStream& stream, bool write_data_descriptor,
+    riegeli::Writer* absl_nonnull destination) {
+  // Write the updated content types stream to the destination.
+  std::string content_types_stream_payload;
+  if (stream.is_deflated) {
+    ABSL_ASSIGN_OR_RETURN(content_types_stream_payload,
+                          Compress(stream.content));
+  } else {
+    content_types_stream_payload = stream.content;
+  }
+  destination->Write(content_types_stream_payload);
+  ABSL_RETURN_IF_ERROR(destination->status());
+
+  ContentTypesStreamMetadata metadata = {
+      .crc32 = ZipFileCrc(stream.content),
+      .compressed_size =
+          static_cast<uint32_t>(content_types_stream_payload.size()),
+      .uncompressed_size = static_cast<uint32_t>(stream.content.size())};
+
+  if (write_data_descriptor) {
+    // Data descriptor signature (4 bytes)
+    destination->Write(kZipDataDescriptorSignature);
+    ABSL_RETURN_IF_ERROR(destination->status());
+
+    // CRC-32 (4 bytes, little-endian)
+    riegeli::WriteLittleEndian<uint32_t>(metadata.crc32, *destination);
+    ABSL_RETURN_IF_ERROR(destination->status());
+
+    // Compressed size (4 bytes, little-endian)
+    riegeli::WriteLittleEndian<uint32_t>(metadata.compressed_size,
+                                         *destination);
+    ABSL_RETURN_IF_ERROR(destination->status());
+
+    // Uncompressed size (4 bytes, little-endian)
+    riegeli::WriteLittleEndian<uint32_t>(metadata.uncompressed_size,
+                                         *destination);
+    ABSL_RETURN_IF_ERROR(destination->status());
+  }
+
+  return metadata;
+}
+
+}  // namespace credentio
diff --git a/formats/zip/content_type_stream_util.h b/formats/zip/content_type_stream_util.h
new file mode 100644
index 0000000..6162f6b
--- /dev/null
+++ b/formats/zip/content_type_stream_util.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_FORMATS_ZIP_CONTENT_TYPE_STREAM_UTIL_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_ZIP_CONTENT_TYPE_STREAM_UTIL_H_
+
+#include <cstdint>
+#include <string>
+
+#include "absl/base/nullability.h"
+#include "absl/status/status.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "formats/zip/reader.h"
+#include "riegeli/bytes/reader.h"
+#include "riegeli/bytes/writer.h"
+
+namespace credentio {
+
+// The content types stream, including its original content and whether it was
+// deflated.
+struct ContentTypesStream {
+  std::string content;
+  bool is_deflated;
+};
+
+// Metadata for a ContentTypesStream written to a ZIP archive.
+struct ContentTypesStreamMetadata {
+  uint32_t crc32;
+  uint32_t compressed_size;
+  uint32_t uncompressed_size;
+};
+
+// Reads the content types stream from the source, and inflates it if necessary.
+absl::StatusOr<ContentTypesStream> ReadContentTypesStream(
+    riegeli::Reader* absl_nonnull source,
+    const ZipReader::FileEntry& content_types_file_entry);
+
+// Updates the given ContentTypesStream in place by inserting an <Override>
+// element for the given content_type and part_name before the closing </Types>
+// tag.
+absl::Status UpdateContentTypesStream(ContentTypesStream& stream,
+                                      absl::string_view content_type,
+                                      absl::string_view part_name);
+
+// Writes the given content types stream payload to the destination, along with
+// the data descriptor signature, CRC32, compressed size, and uncompressed size
+// if write_data_descriptor is true. Returns the metadata of the stream.
+absl::StatusOr<ContentTypesStreamMetadata> WriteContentTypesStream(
+    const ContentTypesStream& stream, bool write_data_descriptor,
+    riegeli::Writer* absl_nonnull destination);
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_ZIP_CONTENT_TYPE_STREAM_UTIL_H_
diff --git a/formats/zip/content_type_stream_util_test.cc b/formats/zip/content_type_stream_util_test.cc
new file mode 100644
index 0000000..f24835d
--- /dev/null
+++ b/formats/zip/content_type_stream_util_test.cc
@@ -0,0 +1,150 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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/content_type_stream_util.h"
+
+#include <cstddef>
+#include <string>
+
+#include "absl/status/status.h"
+#include "absl/status/status_matchers.h"
+#include "absl/strings/string_view.h"
+#include "formats/zip/constants.h"
+#include "formats/zip/reader.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::StatusIs;
+using ::testing::HasSubstr;
+
+TEST(ContentTypeStreamUtilTest, UpdateContentTypesStreamAddsNewOverride) {
+  ContentTypesStream stream = {
+      .content = R"(<?xml version="1.0"?><Types></Types>)",
+      .is_deflated = false};
+  ABSL_ASSERT_OK(UpdateContentTypesStream(stream, kZipC2paManifestMimeType,
+                                          kZipManifestFileName));
+
+  EXPECT_THAT(
+      stream.content,
+      HasSubstr(
+          R"(<Override ContentType="application/c2pa" PartName="/META-INF/content_credential.c2pa"/>)"));
+}
+
+TEST(ContentTypeStreamUtilTest, UpdateContentTypesStreamSkipsExistingOverride) {
+  ContentTypesStream stream = {
+      .content =
+          R"(<?xml version="1.0"?><Types><Override ContentType="application/c2pa" PartName="/META-INF/content_credential.c2pa"/></Types>)",
+      .is_deflated = false};
+  ABSL_ASSERT_OK(UpdateContentTypesStream(stream, kZipC2paManifestMimeType,
+                                          kZipManifestFileName));
+
+  EXPECT_EQ(
+      stream.content,
+      R"(<?xml version="1.0"?><Types><Override ContentType="application/c2pa" PartName="/META-INF/content_credential.c2pa"/></Types>)");
+}
+
+TEST(ContentTypeStreamUtilTest,
+     UpdateContentTypesStreamFailsWithoutClosingTag) {
+  ContentTypesStream stream = {
+      .content = R"(<?xml version="1.0"?><Types>Some random data)",
+      .is_deflated = false};
+
+  EXPECT_THAT(UpdateContentTypesStream(stream, kZipC2paManifestMimeType,
+                                       kZipManifestFileName),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("missing </Types>")));
+}
+
+TEST(ContentTypeStreamUtilTest, WriteContentTypesStreamSucceeds) {
+  ContentTypesStream stream = {
+      .content = R"(<?xml version="1.0"?><Types></Types>)",
+      .is_deflated = false};
+
+  std::string output;
+  riegeli::StringWriter destination(&output);
+
+  auto metadata = WriteContentTypesStream(
+      stream, /*write_data_descriptor=*/true, &destination);
+  ABSL_ASSERT_OK(metadata);
+  ASSERT_TRUE(destination.Close());
+
+  // Expected written content size: stream size + 4 (signature) + 4 (crc32) + 4
+  // (compressed_size) + 4 (uncompressed_size)
+  size_t expected_size = stream.content.size() + 16;
+  EXPECT_EQ(output.size(), expected_size);
+  EXPECT_EQ(metadata->uncompressed_size, stream.content.size());
+  EXPECT_EQ(metadata->compressed_size, stream.content.size());
+}
+
+TEST(ContentTypeStreamUtilTest, WriteContentTypesStreamWithDeflateSucceeds) {
+  ContentTypesStream stream = {
+      .content = R"(<?xml version="1.0"?><Types></Types>)",
+      .is_deflated = true};
+
+  std::string output;
+  riegeli::StringWriter destination(&output);
+
+  auto metadata = WriteContentTypesStream(
+      stream, /*write_data_descriptor=*/true, &destination);
+  ABSL_ASSERT_OK(metadata);
+  ASSERT_TRUE(destination.Close());
+
+  // Cannot determine exact length because deflate output size varies, but
+  // it should be > 16.
+  EXPECT_GT(output.size(), 16);
+  EXPECT_EQ(metadata->uncompressed_size, stream.content.size());
+}
+
+TEST(ContentTypeStreamUtilTest,
+     WriteContentTypesStreamWithoutDataDescriptorSucceeds) {
+  ContentTypesStream stream = {
+      .content = R"(<?xml version="1.0"?><Types></Types>)",
+      .is_deflated = false};
+
+  std::string output;
+  riegeli::StringWriter destination(&output);
+
+  auto metadata = WriteContentTypesStream(
+      stream, /*write_data_descriptor=*/false, &destination);
+  ABSL_ASSERT_OK(metadata);
+  ASSERT_TRUE(destination.Close());
+
+  // Expected written content size: stream size only
+  EXPECT_EQ(output.size(), stream.content.size());
+  EXPECT_EQ(metadata->uncompressed_size, stream.content.size());
+  EXPECT_EQ(metadata->compressed_size, stream.content.size());
+}
+
+TEST(ContentTypeStreamUtilTest, ReadContentTypesStreamLengthErrorWithMaxLimit) {
+  std::string input_data = "some data";
+  riegeli::StringReader<> reader(input_data);
+  ZipReader::FileEntry entry = {
+      .file_name = "[Content_Types].xml",
+      .file_range = {.offset = 0, .length = 0x7FFFFFFF},
+  };
+  auto result = ReadContentTypesStream(&reader, entry);
+  EXPECT_THAT(
+      result.status(),
+      StatusIs(absl::StatusCode::kInvalidArgument,
+               HasSubstr("Content types stream size exceeds maximum limit")));
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/formats/zip/crc.cc b/formats/zip/crc.cc
new file mode 100644
index 0000000..127893b
--- /dev/null
+++ b/formats/zip/crc.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/zip/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;
+}
+
+constexpr std::array<uint32_t, 256> kCrcTable = MakeCrcTable();
+constexpr uint32_t kCrcMask = 0xffffffff;
+
+}  // namespace
+
+uint32_t ZipFileCrc(absl::string_view data) {
+  uint32_t crc = kCrcMask;
+  for (char byte : data) {
+    crc = kCrcTable[(crc ^ static_cast<uint8_t>(byte)) & 0xff] ^ (crc >> 8);
+  }
+  return crc ^ kCrcMask;
+}
+
+}  // namespace credentio
diff --git a/formats/zip/crc.h b/formats/zip/crc.h
new file mode 100644
index 0000000..645861b
--- /dev/null
+++ b/formats/zip/crc.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_CRC_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_ZIP_CRC_H_
+
+#include <cstdint>
+
+#include "absl/strings/string_view.h"
+
+namespace credentio {
+
+// Computes the CRC32 of the given data within a ZIP archive.
+uint32_t ZipFileCrc(absl::string_view data);
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_ZIP_CRC_H_
diff --git a/formats/zip/crc_test.cc b/formats/zip/crc_test.cc
new file mode 100644
index 0000000..5ca402a
--- /dev/null
+++ b/formats/zip/crc_test.cc
@@ -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.
+//
+
+#include "formats/zip/crc.h"
+
+#include "absl/strings/string_view.h"
+#include "gtest/gtest.h"
+
+namespace credentio {
+namespace {
+
+TEST(ZipFileCrcTest, ComputesCrc32Correctly) {
+  EXPECT_EQ(ZipFileCrc("hello world"), 0x0d4a1185);
+  EXPECT_EQ(ZipFileCrc("c2pa"), 0x3ab29e37);
+  EXPECT_EQ(ZipFileCrc("zip"), 0x421d9546);
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/formats/zip/embedder.cc b/formats/zip/embedder.cc
new file mode 100644
index 0000000..8f7d9d6
--- /dev/null
+++ b/formats/zip/embedder.cc
@@ -0,0 +1,482 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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/embedder.h"
+
+#include <cstdint>
+#include <optional>
+#include <string>
+#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 "absl/types/span.h"
+#include "formats/zip/constants.h"
+#include "formats/zip/content_type_stream_util.h"
+#include "formats/zip/crc.h"
+#include "formats/zip/reader.h"
+#include "riegeli/base/byte_fill.h"
+#include "riegeli/base/types.h"
+#include "riegeli/bytes/reader.h"
+#include "riegeli/bytes/writer.h"
+#include "riegeli/endian/endian_writing.h"
+
+namespace credentio {
+namespace {
+
+// A file entry that has been copied from the source to the destination.
+struct FileCopied {
+  ZipReader::FileEntry entry;
+  riegeli::Position offset_in_destination;
+};
+
+// Copies a file entry from the source to the destination.
+absl::Status CopyFileEntry(riegeli::Reader* absl_nonnull source,
+                           const ZipReader::FileEntry& entry,
+                           riegeli::Writer* absl_nonnull destination) {
+  int64_t file_end = entry.file_range.offset + entry.file_range.length;
+  int64_t data_descriptor_length = 0;
+  if (entry.has_data_descriptor) {
+    source->Seek(file_end);
+    ABSL_RETURN_IF_ERROR(source->status());
+    std::string buffer;
+    source->Read(kZipDataDescriptorSignature.size(), buffer);
+    ABSL_RETURN_IF_ERROR(source->status());
+    if (buffer == kZipDataDescriptorSignature) {
+      data_descriptor_length = kDataDescriptorWithSignatureSize;
+    } else {
+      data_descriptor_length = kDataDescriptorSize;
+    }
+  }
+  int64_t length =
+      file_end + data_descriptor_length - entry.local_file_header_offset;
+  source->Seek(entry.local_file_header_offset);
+  ABSL_RETURN_IF_ERROR(source->status());
+  source->Copy(length, *destination);
+  ABSL_RETURN_IF_ERROR(source->status());
+  ABSL_RETURN_IF_ERROR(destination->status());
+
+  return absl::OkStatus();
+}
+
+// Reads the content types stream from the source, writes the updated stream to
+// the destination, including the local file header and the data descriptor (if
+// used). Returns a `ContentTypesStreamMetadata` containing the updated metadata
+// of the content types stream.
+absl::StatusOr<ContentTypesStreamMetadata> WriteUpdatedContentTypesStream(
+    riegeli::Reader* absl_nonnull source,
+    const ZipReader::FileEntry& content_types_file_entry,
+    riegeli::Writer* absl_nonnull destination) {
+  if (content_types_file_entry.has_data_descriptor) {
+    // Copy the original local file header since they don't need to be updated.
+    source->Seek(content_types_file_entry.local_file_header_offset);
+    ABSL_RETURN_IF_ERROR(source->status());
+    source->Copy(kLocalFileHeaderFixedSize + kOoxmlContentTypesFileName.size() +
+                     content_types_file_entry.extra_field_length,
+                 *destination);
+    ABSL_RETURN_IF_ERROR(source->status());
+    ABSL_RETURN_IF_ERROR(destination->status());
+
+    // Read and update the original content types stream from the source.
+    ABSL_ASSIGN_OR_RETURN(
+        ContentTypesStream content_types_stream,
+        ReadContentTypesStream(source, content_types_file_entry));
+    ABSL_RETURN_IF_ERROR(UpdateContentTypesStream(
+        content_types_stream, kZipC2paManifestMimeType, kZipManifestFileName));
+    return WriteContentTypesStream(content_types_stream,
+                                   content_types_file_entry.has_data_descriptor,
+                                   destination);
+  } else {
+    return absl::UnimplementedError(
+        "Content types stream without a data descriptor is not supported.");
+  }
+}
+
+// Writes the central directory header for the updated content types stream to
+// the destination.
+absl::Status WriteUpdatedContentTypesStreamCentralDirectoryHeader(
+    riegeli::Reader* absl_nonnull source,
+    const ZipReader::FileEntry& original_file_entry,
+    const ContentTypesStreamMetadata& content_types_stream_metadata,
+    int64_t updated_local_file_header_offset,
+    riegeli::Writer* absl_nonnull destination) {
+  // Copy the following fields from the original central directory header:
+  //   - Central directory file header signature (4 bytes)
+  //   - Version made by (2 bytes)
+  //   - Version needed to extract (2 bytes)
+  //   - General purpose bit flag (2 bytes)
+  //   - Compression method (2 bytes)
+  //   - File modification time (2 bytes)
+  //   - File modification date (2 bytes)
+  source->Seek(original_file_entry.central_directory_header_offset);
+  ABSL_RETURN_IF_ERROR(source->status());
+  source->Copy(16, *destination);
+  ABSL_RETURN_IF_ERROR(source->status());
+  ABSL_RETURN_IF_ERROR(destination->status());
+
+  // CRC-32 (4 bytes, little-endian)
+  riegeli::WriteLittleEndian<uint32_t>(content_types_stream_metadata.crc32,
+                                       *destination);
+  ABSL_RETURN_IF_ERROR(destination->status());
+
+  // Compressed size (4 bytes, little-endian)
+  riegeli::WriteLittleEndian<uint32_t>(
+      content_types_stream_metadata.compressed_size, *destination);
+  ABSL_RETURN_IF_ERROR(destination->status());
+
+  // Uncompressed size (4 bytes, little-endian)
+  riegeli::WriteLittleEndian<uint32_t>(
+      content_types_stream_metadata.uncompressed_size, *destination);
+  ABSL_RETURN_IF_ERROR(destination->status());
+
+  // Copy the remaining fields from the original central directory header:
+  //   - File name length (2 bytes)
+  //   - Extra field length (2 bytes)
+  //   - File comment length (2 bytes)
+  //   - Disk number start (2 bytes)
+  //   - Internal file attributes (2 bytes)
+  //   - External file attributes (4 bytes)
+  source->Seek(original_file_entry.central_directory_header_offset + 28);
+  ABSL_RETURN_IF_ERROR(source->status());
+  source->Copy(14, *destination);
+  ABSL_RETURN_IF_ERROR(source->status());
+  ABSL_RETURN_IF_ERROR(destination->status());
+
+  // Local file header offset (4 bytes, little-endian)
+  riegeli::WriteLittleEndian<uint32_t>(updated_local_file_header_offset,
+                                       *destination);
+  ABSL_RETURN_IF_ERROR(destination->status());
+
+  // File name
+  destination->Write(kOoxmlContentTypesFileName);
+  ABSL_RETURN_IF_ERROR(destination->status());
+
+  // Extra field and File comment data
+  int64_t extra_and_comment_length = original_file_entry.extra_field_length +
+                                     original_file_entry.file_comment_length;
+  if (extra_and_comment_length > 0) {
+    source->Seek(original_file_entry.central_directory_header_offset +
+                 kCentralDirectoryHeaderFixedSize +
+                 original_file_entry.file_name.size());
+    ABSL_RETURN_IF_ERROR(source->status());
+    source->Copy(extra_and_comment_length, *destination);
+    ABSL_RETURN_IF_ERROR(source->status());
+    ABSL_RETURN_IF_ERROR(destination->status());
+  }
+
+  return absl::OkStatus();
+}
+
+// Writes the local file header for the manifest store to the destination.
+absl::Status WriteManifestLocalFileHeader(
+    int64_t manifest_store_size, riegeli::Writer* absl_nonnull destination) {
+  // Local file header signature (4 bytes)
+  destination->Write(kZipLocalFileHeaderSignature);
+  ABSL_RETURN_IF_ERROR(destination->status());
+
+  // Version needed to extract (2 bytes)
+  riegeli::WriteLittleEndian<uint16_t>(10, *destination);
+  ABSL_RETURN_IF_ERROR(destination->status());
+
+  // Zero-filled fields:
+  //   - General purpose bit flag (2 bytes)
+  //   - Compression method (2 bytes, 0 means no compression)
+  //   - File modification time (2 bytes)
+  //   - File modification date (2 bytes)
+  //   - CRC-32 (4 bytes, placeholder)
+  destination->Write(riegeli::ByteFill(12));
+  ABSL_RETURN_IF_ERROR(destination->status());
+
+  // Compressed size (4 bytes, little-endian)
+  riegeli::WriteLittleEndian<uint32_t>(manifest_store_size, *destination);
+  ABSL_RETURN_IF_ERROR(destination->status());
+
+  // Uncompressed size (4 bytes, little-endian)
+  riegeli::WriteLittleEndian<uint32_t>(manifest_store_size, *destination);
+  ABSL_RETURN_IF_ERROR(destination->status());
+
+  // File name length (2 bytes, little-endian)
+  riegeli::WriteLittleEndian<uint16_t>(kZipManifestFileName.size(),
+                                       *destination);
+  ABSL_RETURN_IF_ERROR(destination->status());
+
+  // Zero-filled extra field length (2 bytes)
+  destination->Write(riegeli::ByteFill(2));
+  ABSL_RETURN_IF_ERROR(destination->status());
+
+  // File name
+  destination->Write(kZipManifestFileName);
+  ABSL_RETURN_IF_ERROR(destination->status());
+
+  return absl::OkStatus();
+}
+
+// Writes the central directory header for the manifest store to the
+// destination.
+absl::Status WriteManifestCentralDirectoryHeader(
+    uint32_t manifest_store_size,
+    uint32_t manifest_store_local_file_header_offset,
+    riegeli::Writer* absl_nonnull destination) {
+  // Central directory file header signature (4 bytes)
+  destination->Write(kZipCentralDirectorySignature);
+  ABSL_RETURN_IF_ERROR(destination->status());
+
+  // Version made by (2 bytes)
+  riegeli::WriteLittleEndian<uint16_t>(20, *destination);  // Version 2.0
+  ABSL_RETURN_IF_ERROR(destination->status());
+
+  // Version needed to extract (2 bytes)
+  riegeli::WriteLittleEndian<uint16_t>(10, *destination);  // Version 1.0
+  ABSL_RETURN_IF_ERROR(destination->status());
+
+  // Zero-filled fields:
+  //   - General purpose bit flag (2 bytes)
+  //   - Compression method (2 bytes, 0 means no compression)
+  //   - File modification time (2 bytes)
+  //   - File modification date (2 bytes)
+  //   - CRC-32 (4 bytes, placeholder)
+  destination->Write(riegeli::ByteFill(12));
+  ABSL_RETURN_IF_ERROR(destination->status());
+
+  // Compressed size (4 bytes, little-endian)
+  riegeli::WriteLittleEndian<uint32_t>(manifest_store_size, *destination);
+  ABSL_RETURN_IF_ERROR(destination->status());
+
+  // Uncompressed size (4 bytes, little-endian)
+  riegeli::WriteLittleEndian<uint32_t>(manifest_store_size, *destination);
+  ABSL_RETURN_IF_ERROR(destination->status());
+
+  // File name length (2 bytes, little-endian)
+  riegeli::WriteLittleEndian<uint16_t>(kZipManifestFileName.size(),
+                                       *destination);
+  ABSL_RETURN_IF_ERROR(destination->status());
+
+  // Zero-filled fields:
+  //   - Extra field length (2 bytes)
+  //   - File comment length (2 bytes)
+  //   - Disk number start (2 bytes)
+  //   - Internal file attributes (2 bytes)
+  //   - External file attributes (4 bytes)
+  destination->Write(riegeli::ByteFill(12));
+  ABSL_RETURN_IF_ERROR(destination->status());
+
+  // Local file header offset (4 bytes, little-endian)
+  riegeli::WriteLittleEndian<uint32_t>(manifest_store_local_file_header_offset,
+                                       *destination);
+  ABSL_RETURN_IF_ERROR(destination->status());
+
+  // File name
+  destination->Write(kZipManifestFileName);
+  ABSL_RETURN_IF_ERROR(destination->status());
+
+  return absl::OkStatus();
+}
+
+// Writes the central directory header for a copied file entry to the
+// destination.
+absl::Status WriteCentralDirectoryHeader(
+    riegeli::Reader* absl_nonnull source, const FileCopied& file_copied,
+    riegeli::Writer* absl_nonnull destination) {
+  source->Seek(file_copied.entry.central_directory_header_offset);
+  ABSL_RETURN_IF_ERROR(source->status());
+
+  int64_t total_cd_length = kCentralDirectoryHeaderFixedSize +
+                            file_copied.entry.file_name.size() +
+                            file_copied.entry.extra_field_length +
+                            file_copied.entry.file_comment_length;
+
+  if (file_copied.offset_in_destination ==
+      file_copied.entry.local_file_header_offset) {
+    source->Copy(total_cd_length, *destination);
+    ABSL_RETURN_IF_ERROR(source->status());
+    ABSL_RETURN_IF_ERROR(destination->status());
+  } else {
+    source->Copy(kCentralDirectoryHeaderFixedSize - 4, *destination);
+    ABSL_RETURN_IF_ERROR(source->status());
+    ABSL_RETURN_IF_ERROR(destination->status());
+
+    riegeli::WriteLittleEndian<uint32_t>(file_copied.offset_in_destination,
+                                         *destination);
+    ABSL_RETURN_IF_ERROR(destination->status());
+
+    source->Skip(4);
+    ABSL_RETURN_IF_ERROR(source->status());
+
+    int64_t remaining_length = file_copied.entry.file_name.size() +
+                               file_copied.entry.extra_field_length +
+                               file_copied.entry.file_comment_length;
+    source->Copy(remaining_length, *destination);
+    ABSL_RETURN_IF_ERROR(source->status());
+    ABSL_RETURN_IF_ERROR(destination->status());
+  }
+  return absl::OkStatus();
+}
+
+// Updates the End of Central Directory (EOCD) record with the new central
+// directory offset, length and entry count.
+absl::Status UpdateEndOfCentralDirectory(
+    riegeli::Reader* absl_nonnull source, uint32_t central_directory_offset,
+    uint32_t central_directory_length, uint16_t entry_count,
+    riegeli::Writer* absl_nonnull destination) {
+  // The first 8 bytes of the EOCD don't need to be updated.
+  auto source_size = source->Size();
+  if (!source_size.has_value()) return source->status();
+  source->Seek(*source_size - kEocdRecordSize);
+  ABSL_RETURN_IF_ERROR(source->status());
+  source->Copy(8, *destination);
+  ABSL_RETURN_IF_ERROR(source->status());
+  ABSL_RETURN_IF_ERROR(destination->status());
+
+  // Total number of entries in the central directory on this disk (2 bytes,
+  // little-endian)
+  riegeli::WriteLittleEndian<uint16_t>(entry_count, *destination);
+  ABSL_RETURN_IF_ERROR(destination->status());
+
+  // Total number of entries in the central directory (2 bytes, little-endian)
+  riegeli::WriteLittleEndian<uint16_t>(entry_count, *destination);
+  ABSL_RETURN_IF_ERROR(destination->status());
+
+  // Size of the central directory (4 bytes, little-endian)
+  riegeli::WriteLittleEndian<uint32_t>(central_directory_length, *destination);
+  ABSL_RETURN_IF_ERROR(destination->status());
+
+  // Offset of the start of the central directory (4 bytes, little-endian)
+  riegeli::WriteLittleEndian<uint32_t>(central_directory_offset, *destination);
+  ABSL_RETURN_IF_ERROR(destination->status());
+
+  // Zero-filled comment length (2 bytes)
+  destination->Write(riegeli::ByteFill(2));
+  ABSL_RETURN_IF_ERROR(destination->status());
+
+  return absl::OkStatus();
+}
+
+}  // namespace
+
+absl::StatusOr<ZipEmbedder::CopyAndAllocateResult> ZipEmbedder::CopyAndAllocate(
+    riegeli::Reader* absl_nonnull source,
+    absl::Span<const ZipReader::FileEntry> file_entries,
+    int64_t manifest_store_size,
+    riegeli::Writer* absl_nonnull destination) const {
+  ABSL_ASSIGN_OR_RETURN(std::vector<ZipReader::FileEntry> sorted_entries,
+                        ZipReader::SortFileEntries(file_entries));
+
+  // Step 1: Copy all the files in the ZIP archive from the source to the
+  // destination, except the content types stream.
+  std::vector<FileCopied> files_copied;
+  std::optional<ZipReader::FileEntry> content_types_file_entry;
+  for (const auto& entry : sorted_entries) {
+    if (entry.file_name == kOoxmlContentTypesFileName) {
+      content_types_file_entry = entry;
+      continue;
+    }
+    if (entry.file_name == kZipManifestFileName) {
+      return absl::AlreadyExistsError("Manifest store already exists.");
+    }
+    files_copied.push_back(
+        {.entry = entry, .offset_in_destination = destination->pos()});
+    ABSL_RETURN_IF_ERROR(CopyFileEntry(source, entry, destination));
+  }
+
+  // Step 2: Write the updated content types stream to the destination.
+  if (!content_types_file_entry.has_value()) {
+    return absl::NotFoundError("Content types stream not found.");
+  }
+  auto content_types_stream_output_offset = destination->pos();
+  ABSL_ASSIGN_OR_RETURN(ContentTypesStreamMetadata content_types_metadata,
+                        WriteUpdatedContentTypesStream(
+                            source, *content_types_file_entry, destination));
+
+  // Step 3: Write the zero-filled manifest store to the destination.
+  auto manifest_store_local_file_header_offset = destination->pos();
+  ABSL_RETURN_IF_ERROR(
+      WriteManifestLocalFileHeader(manifest_store_size, destination));
+  auto manifest_store_offset = destination->pos();
+  destination->Write(riegeli::ByteFill(manifest_store_size));
+  ABSL_RETURN_IF_ERROR(destination->status());
+
+  // Step 4: Write the central directory headers.
+  auto central_directory_offset = destination->pos();
+  for (const auto& file : files_copied) {
+    ABSL_RETURN_IF_ERROR(
+        WriteCentralDirectoryHeader(source, file, destination));
+  }
+  ABSL_RETURN_IF_ERROR(WriteUpdatedContentTypesStreamCentralDirectoryHeader(
+      source, *content_types_file_entry, content_types_metadata,
+      content_types_stream_output_offset, destination));
+  auto manifest_store_central_directory_header_offset = destination->pos();
+  ABSL_RETURN_IF_ERROR(WriteManifestCentralDirectoryHeader(
+      manifest_store_size, manifest_store_local_file_header_offset,
+      destination));
+
+  // Step 5: Update the End of Central Directory (EOCD).
+  ABSL_RETURN_IF_ERROR(UpdateEndOfCentralDirectory(
+      source, central_directory_offset,
+      /*central_directory_length=*/destination->pos() -
+          central_directory_offset,
+      files_copied.size() + 2, destination));
+
+  destination->Flush();
+  ABSL_RETURN_IF_ERROR(destination->status());
+
+  return CopyAndAllocateResult{
+      .manifest_store_offset = manifest_store_offset,
+      .manifest_store_central_directory_header_offset =
+          manifest_store_central_directory_header_offset,
+      .central_directory_offset = central_directory_offset,
+  };
+}
+
+absl::Status ZipEmbedder::Embed(
+    absl::string_view manifest_store,
+    const CopyAndAllocateResult& copy_and_allocate_result,
+    riegeli::Writer* absl_nonnull destination) const {
+  if (!destination->SupportsRandomAccess()) {
+    return absl::InvalidArgumentError(
+        "ZipEmbedder::Embed requires a riegeli::Writer that supports random "
+        "access.");
+  }
+  uint32_t crc32 = ZipFileCrc(manifest_store);
+
+  int64_t local_file_header_crc_offset =
+      copy_and_allocate_result.manifest_store_offset -
+      kZipManifestFileName.size() - 16;
+  int64_t central_directory_header_crc_offset =
+      copy_and_allocate_result.manifest_store_central_directory_header_offset +
+      16;
+
+  // Replace the zero-filled manifest store placeholder with the actual manifest
+  // store. Also update the CRC-32 fields in both the local file header and the
+  // central directory header with the actual CRC-32 of the manifest store.
+  destination->Seek(local_file_header_crc_offset);
+  ABSL_RETURN_IF_ERROR(destination->status());
+  riegeli::WriteLittleEndian<uint32_t>(crc32, *destination);
+  ABSL_RETURN_IF_ERROR(destination->status());
+
+  destination->Seek(copy_and_allocate_result.manifest_store_offset);
+  ABSL_RETURN_IF_ERROR(destination->status());
+  destination->Write(manifest_store);
+  ABSL_RETURN_IF_ERROR(destination->status());
+
+  destination->Seek(central_directory_header_crc_offset);
+  ABSL_RETURN_IF_ERROR(destination->status());
+  riegeli::WriteLittleEndian<uint32_t>(crc32, *destination);
+  return destination->status();
+}
+
+}  // namespace credentio
diff --git a/formats/zip/embedder.h b/formats/zip/embedder.h
new file mode 100644
index 0000000..5d32bdd
--- /dev/null
+++ b/formats/zip/embedder.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_FORMATS_ZIP_EMBEDDER_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_ZIP_EMBEDDER_H_
+
+#include <cstdint>
+
+#include "absl/base/nullability.h"
+#include "absl/status/status.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "absl/types/span.h"
+#include "formats/zip/reader.h"
+#include "riegeli/base/types.h"
+#include "riegeli/bytes/reader.h"
+#include "riegeli/bytes/writer.h"
+
+namespace credentio {
+
+// A utility class to embed a C2PA manifest store into a ZIP archive.
+class ZipEmbedder {
+ public:
+  struct CopyAndAllocateResult {
+    // The offset of the manifest store in the output.
+    riegeli::Position manifest_store_offset;
+    // The offset of the manifest store's central directory header in the
+    // output.
+    riegeli::Position manifest_store_central_directory_header_offset;
+    // The offset of the central directory in the output.
+    riegeli::Position central_directory_offset;
+  };
+
+  explicit ZipEmbedder() = default;
+
+  // Copies the file entries from the source to the destination, and allocates
+  // space for the manifest store. Returns a `CopyAndAllocateResult` containing
+  // the offsets of the manifest store and its central directory header in the
+  // output.
+  absl::StatusOr<CopyAndAllocateResult> CopyAndAllocate(
+      riegeli::Reader* absl_nonnull source,
+      absl::Span<const ZipReader::FileEntry> file_entries,
+      int64_t manifest_store_size,
+      riegeli::Writer* absl_nonnull destination) const;
+
+  // Embeds the manifest store into the ZIP archive at the offsets specified in
+  // `copy_and_allocate_result`.
+  absl::Status Embed(absl::string_view manifest_store,
+                     const CopyAndAllocateResult& copy_and_allocate_result,
+                     riegeli::Writer* absl_nonnull destination) const;
+};
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_ZIP_EMBEDDER_H_
diff --git a/formats/zip/embedder_test.cc b/formats/zip/embedder_test.cc
new file mode 100644
index 0000000..3634cd9
--- /dev/null
+++ b/formats/zip/embedder_test.cc
@@ -0,0 +1,360 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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/embedder.h"
+
+#include <cstdint>
+#include <memory>
+#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/byte_range.h"
+#include "formats/zip/constants.h"
+#include "formats/zip/extractor.h"
+#include "formats/zip/reader.h"
+#include "formats/zip/zlib_util.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "riegeli/bytes/reader.h"
+#include "riegeli/bytes/string_reader.h"
+#include "riegeli/bytes/string_writer.h"
+#include "testing/test_file_utils.h"
+
+namespace credentio {
+namespace {
+
+using ::absl_testing::IsOk;
+using ::absl_testing::IsOkAndHolds;
+using ::absl_testing::StatusIs;
+using ::testing::HasSubstr;
+
+absl::StatusOr<std::vector<ZipReader::FileEntry>> GetFileEntries(
+    riegeli::Reader& input) {
+  ABSL_ASSIGN_OR_RETURN(auto reader, ZipReader::Create(&input));
+  std::vector<ZipReader::FileEntry> file_entries;
+  while (reader->HasNext()) {
+    ABSL_ASSIGN_OR_RETURN(ZipReader::FileEntry entry, reader->Next());
+    file_entries.push_back(entry);
+  }
+  return file_entries;
+}
+
+absl::StatusOr<std::string> GetContentTypesStream(riegeli::Reader& input) {
+  ABSL_ASSIGN_OR_RETURN(auto reader, ZipReader::Create(&input));
+  while (reader->HasNext()) {
+    ABSL_ASSIGN_OR_RETURN(ZipReader::FileEntry entry, reader->Next());
+    if (entry.file_name == kOoxmlContentTypesFileName) {
+      if (!input.Seek(entry.file_range.offset)) {
+        return input.StatusOrAnnotate(absl::DataLossError("failed to seek"));
+      }
+      std::string content;
+      if (!input.Read(entry.file_range.length, content)) {
+        return input.StatusOrAnnotate(absl::DataLossError("failed to read"));
+      }
+      ABSL_ASSIGN_OR_RETURN(std::string uncompressed, Uncompress(content));
+      return uncompressed;
+    }
+  }
+  return absl::NotFoundError("Content types stream not found.");
+}
+
+class ZipEmbedderTest : public testing::Test {
+ protected:
+  void InitWithZipFile(absl::string_view file_name,
+                       int64_t manifest_store_size) {
+    auto source = credentio_testing::GetFileReader(file_name);
+    ABSL_ASSERT_OK(source);
+    source_ = *std::move(source);
+    auto file_entries = GetFileEntries(*source_);
+    ABSL_ASSERT_OK(file_entries);
+    file_entries_ = *std::move(file_entries);
+
+    destination_ = std::make_unique<riegeli::StringWriter<>>(&output_s_);
+    manifest_store_size_ = manifest_store_size;
+  }
+
+  std::string zip_file_contents_;
+  std::unique_ptr<riegeli::Reader> source_;
+  std::vector<ZipReader::FileEntry> file_entries_;
+  std::string output_s_;
+  std::unique_ptr<riegeli::StringWriter<>> destination_;
+  int64_t manifest_store_size_;
+};
+
+TEST_F(ZipEmbedderTest, CopyAndAllocateFailsIfManifestStoreAlreadyExists) {
+  InitWithZipFile(
+      "credentio/formats/zip/testing/"
+      "asset_fake_c2pa.zip",
+      /*manifest_store_size=*/100);
+
+  EXPECT_THAT(
+      ZipEmbedder().CopyAndAllocate(source_.get(), file_entries_,
+                                    manifest_store_size_, destination_.get()),
+      StatusIs(absl::StatusCode::kAlreadyExists,
+               HasSubstr("Manifest store already exists.")));
+}
+
+TEST_F(ZipEmbedderTest, CopyAndAllocateFailsIfNoContentTypesStream) {
+  InitWithZipFile("credentio/formats/zip/testing/asset_no_c2pa.zip",
+                  /*manifest_store_size=*/100);
+
+  EXPECT_THAT(
+      ZipEmbedder().CopyAndAllocate(source_.get(), file_entries_,
+                                    manifest_store_size_, destination_.get()),
+      StatusIs(absl::StatusCode::kNotFound,
+               HasSubstr("Content types stream not found.")));
+}
+
+TEST_F(ZipEmbedderTest, CopyAndAllocate) {
+  InitWithZipFile("credentio/formats/zip/testing/no_c2pa_032_asset.pptx",
+                  /*manifest_store_size=*/100);
+
+  auto copy_and_allocate_result = ZipEmbedder().CopyAndAllocate(
+      source_.get(), file_entries_, manifest_store_size_, destination_.get());
+  ABSL_ASSERT_OK(copy_and_allocate_result);
+  ASSERT_TRUE(destination_->Close());
+
+  riegeli::StringReader<absl::string_view> output_reader(output_s_);
+
+  EXPECT_THAT(ZipExtractor().ExtractManifestStore(output_reader),
+              IsOkAndHolds(std::string(100, '\0')));
+  ASSERT_TRUE(output_reader.Seek(0));
+  EXPECT_THAT(ZipExtractor().ExtractManifestStoreLocation(output_reader, {}),
+              IsOkAndHolds(ByteRange{
+                  .offset = copy_and_allocate_result->manifest_store_offset,
+                  .length = 100,
+              }));
+  ASSERT_TRUE(output_reader.Seek(0));
+  auto content_types_stream = GetContentTypesStream(output_reader);
+  ABSL_ASSERT_OK(content_types_stream);
+  EXPECT_THAT(
+      *content_types_stream,
+      HasSubstr(
+          R"(<Override ContentType="application/c2pa" PartName="/META-INF/content_credential.c2pa"/>)"));
+}
+
+TEST_F(ZipEmbedderTest, Embed) {
+  std::string manifest_store = "c2pa manifest store";
+  InitWithZipFile("credentio/formats/zip/testing/no_c2pa_032_asset.pptx",
+                  manifest_store.size());
+
+  auto copy_and_allocate_result = ZipEmbedder().CopyAndAllocate(
+      source_.get(), file_entries_, manifest_store_size_, destination_.get());
+  ABSL_ASSERT_OK(copy_and_allocate_result);
+
+  EXPECT_THAT(ZipEmbedder().Embed(manifest_store, *copy_and_allocate_result,
+                                  destination_.get()),
+              IsOk());
+  ASSERT_TRUE(destination_->Close());
+
+  riegeli::StringReader output_reader(&output_s_);
+
+  EXPECT_THAT(ZipExtractor().ExtractManifestStore(output_reader),
+              IsOkAndHolds(manifest_store));
+}
+
+TEST_F(ZipEmbedderTest, CopyAndAllocateFailsWithDuplicateEntries) {
+  InitWithZipFile("credentio/formats/zip/testing/no_c2pa_032_asset.pptx", 100);
+  ASSERT_GE(file_entries_.size(), 1);
+
+  std::vector<ZipReader::FileEntry> duplicate_entries = file_entries_;
+  duplicate_entries.push_back(file_entries_[0]);
+
+  EXPECT_THAT(
+      ZipEmbedder().CopyAndAllocate(source_.get(), duplicate_entries,
+                                    manifest_store_size_, destination_.get()),
+      StatusIs(absl::StatusCode::kInvalidArgument,
+               HasSubstr("Overlapping or duplicate ZIP entries detected")));
+}
+
+TEST_F(ZipEmbedderTest, CopyAndAllocatePreservesExtraFieldAndCommentData) {
+  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 file1_name = "[Content_Types].xml";
+  std::string file1_data = "<Types></Types>";
+  std::string file1_extra = "EX1";
+  std::string file1_cmt = "CM1";
+
+  std::string file2_name = "doc.xml";
+  std::string file2_data = "<Document/>";
+  std::string file2_extra = "EXTRA2";
+  std::string file2_cmt = "CMT2";
+
+  // LFH 1
+  uint32_t lfh1_offset = zip.size();
+  zip.append("PK\x03\x04", 4);
+  write_le16(zip, 10);
+  write_le16(zip, 0x0008);  // Has data descriptor
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le32(zip, 0);
+  write_le32(zip, 0);
+  write_le32(zip, 0);
+  write_le16(zip, file1_name.size());
+  write_le16(zip, file1_extra.size());
+  zip.append(file1_name);
+  zip.append(file1_extra);
+  zip.append(file1_data);
+  // Data descriptor with signature
+  zip.append("PK\x07\x08", 4);
+  write_le32(zip, 0x12345678);
+  write_le32(zip, file1_data.size());
+  write_le32(zip, file1_data.size());
+
+  // LFH 2
+  uint32_t lfh2_offset = zip.size();
+  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, 0x87654321);
+  write_le32(zip, file2_data.size());
+  write_le32(zip, file2_data.size());
+  write_le16(zip, file2_name.size());
+  write_le16(zip, file2_extra.size());
+  zip.append(file2_name);
+  zip.append(file2_extra);
+  zip.append(file2_data);
+
+  // CD Start
+  uint32_t cd_offset = zip.size();
+  // CD 1
+  zip.append("PK\x01\x02", 4);
+  write_le16(zip, 20);
+  write_le16(zip, 10);
+  write_le16(zip, 0x0008);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le32(zip, 0x12345678);
+  write_le32(zip, file1_data.size());
+  write_le32(zip, file1_data.size());
+  write_le16(zip, file1_name.size());
+  write_le16(zip, file1_extra.size());
+  write_le16(zip, file1_cmt.size());
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le32(zip, 0);
+  write_le32(zip, lfh1_offset);
+  zip.append(file1_name);
+  zip.append(file1_extra);
+  zip.append(file1_cmt);
+
+  // CD 2
+  zip.append("PK\x01\x02", 4);
+  write_le16(zip, 20);
+  write_le16(zip, 10);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le32(zip, 0x87654321);
+  write_le32(zip, file2_data.size());
+  write_le32(zip, file2_data.size());
+  write_le16(zip, file2_name.size());
+  write_le16(zip, file2_extra.size());
+  write_le16(zip, file2_cmt.size());
+  write_le16(zip, 0);
+  write_le16(zip, 0);
+  write_le32(zip, 0);
+  write_le32(zip, lfh2_offset);
+  zip.append(file2_name);
+  zip.append(file2_extra);
+  zip.append(file2_cmt);
+
+  uint32_t cd_size = zip.size() - cd_offset;
+  // 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 source_reader(zip);
+  auto source_zip_reader = ZipReader::Create(&source_reader);
+  ABSL_ASSERT_OK(source_zip_reader);
+  std::vector<ZipReader::FileEntry> file_entries;
+  while ((*source_zip_reader)->HasNext()) {
+    auto entry = (*source_zip_reader)->Next();
+    ABSL_ASSERT_OK(entry);
+    file_entries.push_back(*entry);
+  }
+  ASSERT_EQ(file_entries.size(), 2);
+
+  std::string output_s;
+  riegeli::StringWriter<> destination(&output_s);
+
+  auto copy_and_allocate_result = ZipEmbedder().CopyAndAllocate(
+      &source_reader, file_entries, /*manifest_store_size=*/100, &destination);
+  ABSL_ASSERT_OK(copy_and_allocate_result);
+  (void)*copy_and_allocate_result;
+  ASSERT_TRUE(destination.Close());
+
+  riegeli::StringReader output_reader(output_s);
+  auto out_zip_reader = ZipReader::Create(&output_reader);
+  ABSL_ASSERT_OK(out_zip_reader);
+  std::vector<ZipReader::FileEntry> out_file_entries;
+  while ((*out_zip_reader)->HasNext()) {
+    auto entry = (*out_zip_reader)->Next();
+    ABSL_ASSERT_OK(entry);
+    out_file_entries.push_back(*entry);
+  }
+
+  // Expect 3 entries in output: doc.xml, [Content_Types].xml, and manifest
+  // store
+  EXPECT_EQ(out_file_entries.size(), 3);
+
+  // Check doc.xml retains extra_field_length and file_comment_length
+  const ZipReader::FileEntry* doc_entry = nullptr;
+  for (const auto& entry : out_file_entries) {
+    if (entry.file_name == file2_name) {
+      doc_entry = &entry;
+    }
+  }
+  ASSERT_NE(doc_entry, nullptr);
+  EXPECT_EQ(doc_entry->extra_field_length, file2_extra.size());
+  EXPECT_EQ(doc_entry->file_comment_length, file2_cmt.size());
+
+  // Check extra field data and comment data are present in output string
+  EXPECT_THAT(output_s, HasSubstr(file1_extra));
+  EXPECT_THAT(output_s, HasSubstr(file1_cmt));
+  EXPECT_THAT(output_s, HasSubstr(file2_extra));
+  EXPECT_THAT(output_s, HasSubstr(file2_cmt));
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/formats/zip/extractor_test.cc b/formats/zip/extractor_test.cc
index a2f6021..ee41064 100644
--- a/formats/zip/extractor_test.cc
+++ b/formats/zip/extractor_test.cc
@@ -18,6 +18,7 @@
 #include <cstddef>
 #include <cstdint>
 #include <cstdlib>
+#include <memory>
 #include <optional>
 #include <string>
 #include <utility>
@@ -33,31 +34,13 @@
 #include "formats/zip/constants.h"
 #include "gmock/gmock.h"
 #include "gtest/gtest.h"
+#include "riegeli/bytes/reader.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 {
 
@@ -67,6 +50,7 @@
 using ::testing::Eq;
 using ::testing::Field;
 using ::testing::HasSubstr;
+using ::testing::Optional;
 
 // A Zip file entry.
 struct ZipEntry {
@@ -184,25 +168,26 @@
 }
 
 TEST(ZipExtractorTest, ExtractManifestStore) {
-  ASSERT_OK_AND_ASSIGN(auto input,
-                       credentio_testing::GetFileReader(
-                           "c2pa/formats/zip/testing/asset_fake_c2pa.zip"));
+  absl::StatusOr<std::unique_ptr<riegeli::Reader>> input =
+      credentio_testing::GetFileReader(
+          "credentio/formats/zip/testing/asset_fake_c2pa.zip");
+  ABSL_ASSERT_OK(input);
 
-  EXPECT_THAT(ZipExtractor().ExtractManifestStore(*input),
+  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);
+  absl::StatusOr<std::string> zip_file_contents = CreateFakeZip({
+      {.name = "META-INF/content_credential.c2pa",
+       .content = "c2pa manifest 1"},
+      {.name = "META-INF/content_credential.c2pa",
+       .content = "c2pa manifest 2"},
+  });
+  ABSL_ASSERT_OK(zip_file_contents);
+  riegeli::StringReader<> input(*zip_file_contents);
 
   EXPECT_THAT(ZipExtractor().ExtractManifestStore(input),
               StatusIs(absl::StatusCode::kNotFound,
@@ -210,54 +195,56 @@
 }
 
 TEST(ZipExtractorTest, ExtractManifestStoreThrowsNotFoundErrorIfNoC2pa) {
-  ASSERT_OK_AND_ASSIGN(auto input,
-                       credentio_testing::GetFileReader(
-                           "c2pa/formats/zip/testing/asset_no_c2pa.zip"));
+  absl::StatusOr<std::unique_ptr<riegeli::Reader>> input =
+      credentio_testing::GetFileReader(
+          "credentio/formats/zip/testing/asset_no_c2pa.zip");
+  ABSL_ASSERT_OK(input);
 
-  EXPECT_THAT(ZipExtractor().ExtractManifestStore(*input),
+  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}));
+  absl::StatusOr<std::unique_ptr<riegeli::Reader>> input =
+      credentio_testing::GetFileReader(
+          "credentio/formats/zip/testing/asset_fake_c2pa.zip");
+  ABSL_ASSERT_OK(input);
 
-  EXPECT_THAT(manifest_store_location,
-              Optional(AllOf(Field(&ByteRange::offset, Eq(201)),
-                             Field(&ByteRange::length, Eq(14)))));
+  EXPECT_THAT(ZipExtractor().ExtractManifestStoreLocation(
+                  **input, {.requires_c2pa = true}),
+              IsOkAndHolds(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}));
+  absl::StatusOr<std::unique_ptr<riegeli::Reader>> input =
+      credentio_testing::GetFileReader(
+          "credentio/formats/zip/testing/asset_no_c2pa.zip");
+  ABSL_ASSERT_OK(input);
 
-  EXPECT_EQ(manifest_store_location, std::nullopt);
+  EXPECT_THAT(ZipExtractor().ExtractManifestStoreLocation(
+                  **input, {.requires_c2pa = false}),
+              IsOkAndHolds(Eq(std::nullopt)));
 }
 
 TEST(ZipExtractorTest,
      ExtractManifestStoreLocationReturnsErrorIfNoC2paButRequired) {
-  ASSERT_OK_AND_ASSIGN(auto input,
-                       credentio_testing::GetFileReader(
-                           "c2pa/formats/zip/testing/asset_no_c2pa.zip"));
+  absl::StatusOr<std::unique_ptr<riegeli::Reader>> input =
+      credentio_testing::GetFileReader(
+          "credentio/formats/zip/testing/asset_no_c2pa.zip");
+  ABSL_ASSERT_OK(input);
   EXPECT_THAT(ZipExtractor().ExtractManifestStoreLocation(
-                  *input, {.requires_c2pa = true}),
+                  **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);
+  absl::StatusOr<std::string> zip = CreateFakeZip({});
+  ABSL_ASSERT_OK(zip);
+  riegeli::StringReader<> input(*zip);
 
   EXPECT_THAT(ZipExtractor().ExtractBoxes(input, {}),
               StatusIs(absl::StatusCode::kUnimplemented,
@@ -273,12 +260,12 @@
 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);
+  absl::StatusOr<std::string> zip = CreateFakeZip({
+      {.name = "META-INF/content_credential.c2pa",
+       .content = std::move(large_content)},
+  });
+  ABSL_ASSERT_OK(zip);
+  riegeli::StringReader<> input(*zip);
 
   EXPECT_THAT(ZipExtractor().ExtractManifestStore(input),
               StatusIs(absl::StatusCode::kInvalidArgument,
diff --git a/formats/zip/hasher_data.cc b/formats/zip/hasher_data.cc
new file mode 100644
index 0000000..9e1eb53
--- /dev/null
+++ b/formats/zip/hasher_data.cc
@@ -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.
+//
+
+#include "formats/zip/hasher_data.h"
+
+#include <cstdint>
+#include <memory>
+#include <string>
+#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/str_cat.h"
+#include "absl/types/span.h"
+#include "bindings/input_hasher.h"
+#include "crypto/algorithms.h"
+#include "formats/zip/constants.h"
+#include "formats/zip/reader.h"
+#include "proto/collection_data_hash_assertion.pb.h"
+#include "riegeli/bytes/reader.h"
+
+namespace credentio {
+
+absl::StatusOr<CollectionDataHashAssertion> CollectionDataHashTemplate(
+    absl::Span<const ZipReader::FileEntry> file_entries, HashAlgorithm alg) {
+  CollectionDataHashAssertion assertion;
+  assertion.set_alg(absl::StrCat(alg));
+  for (const ZipReader::FileEntry& file_entry : file_entries) {
+    if (file_entry.file_name == kZipManifestFileName) {
+      continue;
+    }
+    UriHashedData* uri_hashed_data = assertion.add_uris();
+    uri_hashed_data->set_uri(file_entry.file_name);
+    uri_hashed_data->set_hash(std::string(HashSizeBytes(alg), 0));
+  }
+  assertion.set_zip_central_directory_hash(std::string(HashSizeBytes(alg), 0));
+
+  return assertion;
+}
+
+absl::StatusOr<CollectionDataHashAssertion> DigestCollectionDataHash(
+    riegeli::Reader* absl_nonnull source, HashAlgorithm alg,
+    absl::Span<const ZipReader::FileEntry> file_entries,
+    uint64_t central_directory_offset) {
+  ABSL_ASSIGN_OR_RETURN(std::vector<ZipReader::FileEntry> sorted_entries,
+                        ZipReader::SortFileEntries(file_entries));
+  if (!source->Size().has_value()) {
+    return absl::InternalError("source->Size() returned no value");
+  }
+  CollectionDataHashAssertion assertion;
+  assertion.set_alg(absl::StrCat(alg));
+  const ZipReader::FileEntry* manifest_file_entry = nullptr;
+  for (const ZipReader::FileEntry& file_entry : sorted_entries) {
+    if (file_entry.file_name == kZipManifestFileName) {
+      manifest_file_entry = &file_entry;
+      continue;
+    }
+    UriHashedData* uri_hashed_data = assertion.add_uris();
+    uri_hashed_data->set_uri(file_entry.file_name);
+
+    int64_t entry_end =
+        file_entry.file_range.offset + file_entry.file_range.length;
+    if (file_entry.has_data_descriptor) {
+      if (!source->Seek(entry_end) || source->pos() != entry_end) {
+        return absl::InternalError("Failed to seek to entry end.");
+      }
+      std::string buffer;
+      if (!source->Read(kZipDataDescriptorSignature.size(), buffer)) {
+        return absl::InternalError("Failed to read data descriptor signature.");
+      }
+      if (buffer == kZipDataDescriptorSignature) {
+        entry_end += kDataDescriptorWithSignatureSize;
+      } else {
+        entry_end += kDataDescriptorSize;
+      }
+    }
+    ABSL_ASSIGN_OR_RETURN(std::unique_ptr<InputHasher> hasher,
+                          InputHasher::Create(alg));
+    ABSL_RETURN_IF_ERROR(
+        hasher->Update(*source, file_entry.local_file_header_offset,
+                       entry_end - file_entry.local_file_header_offset));
+    uri_hashed_data->set_hash(hasher->Digest());
+  }
+
+  ABSL_ASSIGN_OR_RETURN(std::unique_ptr<InputHasher> hasher,
+                        InputHasher::Create(alg));
+  if (!source->Seek(central_directory_offset) ||
+      source->pos() != central_directory_offset) {
+    return absl::InternalError("Failed to seek to central directory offset.");
+  }
+
+  if (manifest_file_entry == nullptr) {
+    return absl::InternalError("No manifest file entry found.");
+  }
+  uint64_t crc32_offset =
+      manifest_file_entry->central_directory_header_offset + 16;
+  ABSL_RETURN_IF_ERROR(hasher->Update(*source, central_directory_offset,
+                                      crc32_offset - central_directory_offset));
+  uint64_t after_crc32_offset = crc32_offset + 4;
+  ABSL_RETURN_IF_ERROR(
+      hasher->Update(*source, after_crc32_offset,
+                     source->Size().value() - after_crc32_offset));
+  assertion.set_zip_central_directory_hash(hasher->Digest());
+
+  return assertion;
+}
+}  // namespace credentio
diff --git a/formats/zip/hasher_data.h b/formats/zip/hasher_data.h
new file mode 100644
index 0000000..87f8b86
--- /dev/null
+++ b/formats/zip/hasher_data.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_FORMATS_ZIP_HASHER_DATA_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_ZIP_HASHER_DATA_H_
+
+#include <cstdint>
+
+#include "absl/base/nullability.h"
+#include "absl/status/statusor.h"
+#include "absl/types/span.h"
+#include "crypto/algorithms.h"
+#include "formats/zip/reader.h"
+#include "proto/collection_data_hash_assertion.pb.h"
+#include "riegeli/bytes/reader.h"
+
+namespace credentio {
+
+// Produces a "template" CollectionDataHashAssertion. This assertion will have
+// the same contents as an assertion produced by DigestCollectionDataHash,
+// except for the specific hash value for each file in the ZIP archive.
+absl::StatusOr<CollectionDataHashAssertion> CollectionDataHashTemplate(
+    absl::Span<const ZipReader::FileEntry> file_entries, HashAlgorithm alg);
+
+// Creates a CollectionDataHashAssertion for the given asset.
+absl::StatusOr<CollectionDataHashAssertion> DigestCollectionDataHash(
+    riegeli::Reader* absl_nonnull source, HashAlgorithm alg,
+    absl::Span<const ZipReader::FileEntry> file_entries,
+    uint64_t central_directory_offset);
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_ZIP_HASHER_DATA_H_
diff --git a/formats/zip/hasher_data_test.cc b/formats/zip/hasher_data_test.cc
new file mode 100644
index 0000000..0e95bb0
--- /dev/null
+++ b/formats/zip/hasher_data_test.cc
@@ -0,0 +1,164 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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/hasher_data.h"
+
+#include <cstdint>
+#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/string_view.h"
+#include "absl/types/span.h"
+#include "bindings/collection_data_hash_hard_binding_validator.h"
+#include "constants/status_codes.h"
+#include "crypto/algorithms.h"
+#include "formats/zip/reader.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "proto/collection_data_hash_assertion.pb.h"
+#include "riegeli/bytes/reader.h"
+#include "testing/proto_test_utils.h"
+#include "testing/test_file_utils.h"
+#include "testing/test_validation_tracker.h"
+#include "utils/status_tracker.h"
+#include "validator/tracker.h"
+
+namespace credentio {
+namespace {
+
+using ::absl_testing::StatusIs;
+using ::credentio_testing::EqualsProto;
+using ::testing::HasSubstr;
+using ::testing::IsEmpty;
+using ::testing::SizeIs;
+
+// A StatusTracker that records to a ValidationTracker.
+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_;
+};
+
+class DigestCollectionDataHashTest : public testing::Test {
+ protected:
+  static void SetUpTestSuite() {
+    // This ZIP archive contains three files: "file_1.txt", "file_2.txt" and a
+    // fake manifest.
+    auto file_reader = credentio_testing::GetFileReader(
+        "credentio/formats/zip/testing/"
+        "asset_fake_c2pa.zip");
+    ABSL_ASSERT_OK(file_reader);
+    source_ = file_reader->release();
+    auto reader = ZipReader::Create(source_);
+    ABSL_ASSERT_OK(reader);
+    central_directory_offset_ = (*reader)->central_directory_offset();
+    file_entries_ = new std::vector<ZipReader::FileEntry>();
+    while ((*reader)->HasNext()) {
+      absl::StatusOr<ZipReader::FileEntry> entry = (*reader)->Next();
+      ABSL_ASSERT_OK(entry);
+      file_entries_->push_back(*entry);
+    }
+  }
+
+  static void TearDownTestSuite() {
+    delete file_entries_;
+    file_entries_ = nullptr;
+    delete source_;
+    source_ = nullptr;
+  }
+
+  inline static riegeli::Reader* source_ = nullptr;
+  inline static std::vector<ZipReader::FileEntry>* file_entries_ = nullptr;
+  inline static uint64_t central_directory_offset_ = 0;
+};
+
+TEST_F(DigestCollectionDataHashTest, TemplateMatchesFinal) {
+  absl::StatusOr<CollectionDataHashAssertion> template_assertion =
+      CollectionDataHashTemplate(*file_entries_, HashAlgorithm::kSha256);
+  ABSL_ASSERT_OK(template_assertion);
+
+  absl::StatusOr<CollectionDataHashAssertion> final_assertion =
+      DigestCollectionDataHash(source_, HashAlgorithm::kSha256, *file_entries_,
+                               central_directory_offset_);
+  ABSL_ASSERT_OK(final_assertion);
+  EXPECT_THAT(final_assertion->zip_central_directory_hash(),
+              SizeIs(template_assertion->zip_central_directory_hash().size()));
+  EXPECT_THAT(final_assertion->uris(), SizeIs(template_assertion->uris_size()));
+
+  // Copy values from final assertion to template assertion and compare.
+  for (int i = 0; i < final_assertion->uris_size(); ++i) {
+    template_assertion->mutable_uris(i)->set_hash(
+        final_assertion->uris(i).hash());
+  }
+  template_assertion->set_zip_central_directory_hash(
+      final_assertion->zip_central_directory_hash());
+
+  EXPECT_THAT(*final_assertion, EqualsProto(*template_assertion));
+}
+
+TEST_F(DigestCollectionDataHashTest, BindingValidates) {
+  absl::StatusOr<CollectionDataHashAssertion> final_assertion =
+      DigestCollectionDataHash(source_, HashAlgorithm::kSha256, *file_entries_,
+                               central_directory_offset_);
+  ABSL_ASSERT_OK(final_assertion);
+
+  ASSERT_TRUE(source_->Seek(0));
+
+  CollectionDataHashHardBindingValidator validator;
+  TestValidationTracker t;
+  RecordingStatusTracker tracker(t.tracker());
+  validator.Validate(*source_, *final_assertion, "test_uri", tracker);
+
+  EXPECT_THAT(
+      t.GetSuccesses(),
+      ContainsSuccess(SuccessStatusCode::kAssertionCollectionHashMatch));
+  EXPECT_THAT(t.GetFailures(), IsEmpty());
+  EXPECT_THAT(t.GetInformationals(), IsEmpty());
+}
+
+TEST_F(DigestCollectionDataHashTest,
+       DigestCollectionDataHashFailsWithDuplicateEntries) {
+  ASSERT_GE(file_entries_->size(), 1);
+  std::vector<ZipReader::FileEntry> duplicate_entries = *file_entries_;
+  duplicate_entries.push_back((*file_entries_)[0]);
+
+  EXPECT_THAT(
+      DigestCollectionDataHash(source_, HashAlgorithm::kSha256,
+                               duplicate_entries, central_directory_offset_),
+      StatusIs(absl::StatusCode::kInvalidArgument,
+               HasSubstr("Overlapping or duplicate ZIP entries detected")));
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/formats/zip/reader_test.cc b/formats/zip/reader_test.cc
index 2bbbfa5..63d726c 100644
--- a/formats/zip/reader_test.cc
+++ b/formats/zip/reader_test.cc
@@ -39,7 +39,6 @@
 namespace credentio {
 namespace {
 
-using ::absl_testing::IsOk;
 using ::absl_testing::StatusIs;
 using ::testing::AllOf;
 using ::testing::Contains;
@@ -169,7 +168,7 @@
   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());
+  ABSL_ASSERT_OK(reader_or);
   auto reader = std::move(*reader_or);
 
   EXPECT_FALSE(reader->HasNext());
@@ -180,7 +179,7 @@
       "formats/zip/testing/"
       "asset_unsupported_zip64.zip");
   riegeli::CFileReader<> input(zip);
-  ASSERT_THAT(input.status(), IsOk());
+  ABSL_ASSERT_OK(input.status());
 
   EXPECT_THAT(ZipReader::Create(&input),
               StatusIs(absl::StatusCode::kUnimplemented,
@@ -237,12 +236,12 @@
                            sizeof(kZipWithDataDescriptor));
   riegeli::StringReader<> input(zip_contents);
   auto reader_or = ZipReader::Create(&input);
-  ASSERT_THAT(reader_or, IsOk());
+  ABSL_ASSERT_OK(reader_or);
   auto reader = std::move(*reader_or);
 
   ASSERT_TRUE(reader->HasNext());
   auto entry_or = reader->Next();
-  ASSERT_THAT(entry_or, IsOk());
+  ABSL_ASSERT_OK(entry_or);
   auto entry = std::move(*entry_or);
 
   EXPECT_TRUE(entry.has_data_descriptor);
@@ -251,15 +250,15 @@
 TEST(ZipReaderTest, ReadsZipFile) {
   std::string zip = GetTestDataPath("formats/zip/testing/asset_no_c2pa.zip");
   riegeli::CFileReader<> input(zip);
-  ASSERT_THAT(input.status(), IsOk());
+  ABSL_ASSERT_OK(input.status());
   auto reader_or = ZipReader::Create(&input);
-  ASSERT_THAT(reader_or, IsOk());
+  ABSL_ASSERT_OK(reader_or);
   auto reader = std::move(*reader_or);
 
   std::vector<ZipReader::FileEntry> entries;
   while (reader->HasNext()) {
     auto entry_or = reader->Next();
-    ASSERT_THAT(entry_or, IsOk());
+    ABSL_ASSERT_OK(entry_or);
     entries.push_back(std::move(*entry_or));
   }
 
@@ -294,15 +293,15 @@
   std::string pptx =
       GetTestDataPath("formats/zip/testing/no_c2pa_032_asset.pptx");
   riegeli::CFileReader<> input(pptx);
-  ASSERT_THAT(input.status(), IsOk());
+  ABSL_ASSERT_OK(input.status());
   auto reader_or = ZipReader::Create(&input);
-  ASSERT_THAT(reader_or, IsOk());
+  ABSL_ASSERT_OK(reader_or);
   auto reader = std::move(*reader_or);
 
   std::vector<ZipReader::FileEntry> entries;
   while (reader->HasNext()) {
     auto entry_or = reader->Next();
-    ASSERT_THAT(entry_or, IsOk());
+    ABSL_ASSERT_OK(entry_or);
     entries.push_back(std::move(*entry_or));
   }
 
@@ -320,7 +319,7 @@
   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());
+  ABSL_ASSERT_OK(reader_or);
   auto reader = std::move(*reader_or);
 
   EXPECT_EQ(reader->central_directory_offset(), 0);
@@ -329,9 +328,9 @@
 TEST(ZipReaderTest, CentralDirectoryOffset) {
   std::string zip = GetTestDataPath("formats/zip/testing/asset_no_c2pa.zip");
   riegeli::CFileReader<> input(zip);
-  ASSERT_THAT(input.status(), IsOk());
+  ABSL_ASSERT_OK(input.status());
   auto reader_or = ZipReader::Create(&input);
-  ASSERT_THAT(reader_or, IsOk());
+  ABSL_ASSERT_OK(reader_or);
   auto reader = std::move(*reader_or);
 
   EXPECT_EQ(reader->central_directory_offset(), 139);
@@ -775,13 +774,13 @@
                            eocd_offset);
 
   auto reader_or = ZipReader::Create(&input);
-  ASSERT_THAT(reader_or, IsOk());
+  ABSL_ASSERT_OK(reader_or);
   auto reader = std::move(*reader_or);
 
   std::vector<ZipReader::FileEntry> entries;
   while (reader->HasNext()) {
     auto entry_or = reader->Next();
-    ASSERT_THAT(entry_or, IsOk());
+    ABSL_ASSERT_OK(entry_or);
     entries.push_back(std::move(*entry_or));
   }
 
diff --git a/formats/zip/writer.cc b/formats/zip/writer.cc
new file mode 100644
index 0000000..c349aad
--- /dev/null
+++ b/formats/zip/writer.cc
@@ -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.
+//
+
+#include "formats/zip/writer.h"
+
+#include <cstdint>
+#include <string>
+#include <utility>
+#include <vector>
+
+#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/string_view.h"
+#include "crypto/algorithms.h"
+#include "formats/zip/embedder.h"
+#include "formats/zip/hasher_data.h"
+#include "formats/zip/reader.h"
+#include "proto/generator.pb.h"
+#include "riegeli/bytes/reader.h"
+#include "riegeli/bytes/writer.h"
+
+namespace credentio {
+
+absl::Status ZipWriter::Write(GenerateManifestStoreRequest& request,
+                              riegeli::Reader* absl_nonnull source,
+                              riegeli::Writer* absl_nonnull destination) {
+  if (request.has_hard_binding()) {
+    return absl::InvalidArgumentError(
+        "request must not include `hard_binding` (binding is computed by "
+        "ZipWriter)");
+  }
+
+  ABSL_ASSIGN_OR_RETURN(auto reader, ZipReader::Create(source));
+
+  std::vector<ZipReader::FileEntry> file_entries;
+  while (reader->HasNext()) {
+    ABSL_ASSIGN_OR_RETURN(auto entry, reader->Next());
+    file_entries.push_back(entry);
+  }
+
+  // The two-phase approach is required for ZIP-based assets: The sections to be
+  // hashed can't be finalized until the manifest store size is known and
+  // content types stream is updated.
+
+  // Phase 1: Copy the asset to the destination, update the content types
+  // stream, compute preliminary hard binding, produce a manifest store
+  // template, and embed a zero-filled placeholder in the asset.
+  HashAlgorithm alg = HashAlgorithm::kSha256;
+  ABSL_ASSIGN_OR_RETURN(
+      *request.mutable_hard_binding()->mutable_collection_data_hash(),
+      CollectionDataHashTemplate(file_entries, alg));
+  ABSL_ASSIGN_OR_RETURN(ManifestStoreTemplate manifest_store_template,
+                        generator_.GenerateManifestStoreTemplate(request));
+  constexpr int64_t kMaxManifestStoreSizeBytes = 100 * 1024 * 1024;  // 100 MiB
+  if (manifest_store_template.manifest_store_size() <= 0 ||
+      manifest_store_template.manifest_store_size() >
+          kMaxManifestStoreSizeBytes) {
+    return absl::InvalidArgumentError(
+        absl::StrCat("invalid manifest store template size: ",
+                     manifest_store_template.manifest_store_size()));
+  }
+
+  ZipEmbedder embedder;
+  ABSL_ASSIGN_OR_RETURN(
+      auto copy_and_allocate_result,
+      embedder.CopyAndAllocate(source, file_entries,
+                               manifest_store_template.manifest_store_size(),
+                               destination));
+
+  // Phase 2: Compute final hard binding based on the updated content, finalize
+  // the manifest store, and embed the final manifest store in the asset.
+  if (!destination->SupportsReadMode()) {
+    return absl::InvalidArgumentError(
+        "ZipWriter::Write requires a riegeli::Writer that supports read mode.");
+  }
+  riegeli::Reader* phase2_reader = destination->ReadMode(0);
+  if (phase2_reader == nullptr) {
+    return absl::InternalError("failed to switch to read mode");
+  }
+
+  ABSL_ASSIGN_OR_RETURN(auto phase2_zip_reader,
+                        ZipReader::Create(phase2_reader));
+  std::vector<ZipReader::FileEntry> phase2_file_entries;
+  while (phase2_zip_reader->HasNext()) {
+    ABSL_ASSIGN_OR_RETURN(auto entry, phase2_zip_reader->Next());
+    phase2_file_entries.push_back(entry);
+  }
+  FinalizeManifestStoreRequest finalize_request;
+  *finalize_request.mutable_manifest_store_template() =
+      std::move(manifest_store_template);
+  ABSL_ASSIGN_OR_RETURN(
+      *finalize_request.mutable_hard_binding()->mutable_collection_data_hash(),
+      DigestCollectionDataHash(
+          phase2_reader, alg, phase2_file_entries,
+          copy_and_allocate_result.central_directory_offset));
+  ABSL_ASSIGN_OR_RETURN(auto response,
+                        generator_.FinalizeManifestStore(finalize_request));
+  if (response.manifest_store().size() !=
+      finalize_request.manifest_store_template().manifest_store_size()) {
+    return absl::InternalError("manifest store size does not match template");
+  }
+  ABSL_RETURN_IF_ERROR(embedder.Embed(response.manifest_store(),
+                                      copy_and_allocate_result, destination));
+  return absl::OkStatus();
+}
+
+}  // namespace credentio
diff --git a/formats/zip/writer.h b/formats/zip/writer.h
new file mode 100644
index 0000000..78b7a76
--- /dev/null
+++ b/formats/zip/writer.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_ZIP_WRITER_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_ZIP_WRITER_H_
+
+#include "absl/base/attributes.h"
+#include "absl/base/nullability.h"
+#include "absl/log/die_if_null.h"
+#include "absl/status/status.h"
+#include "generator/generator.h"
+#include "proto/generator.pb.h"
+#include "riegeli/bytes/reader.h"
+#include "riegeli/bytes/writer.h"
+
+namespace credentio {
+
+// Writer of C2PA manifest stores into ZIP-based assets.
+class ZipWriter {
+ public:
+  // Creates a Zip writer.
+  //
+  // The generator must outlive the writer.
+  explicit ZipWriter(const ManifestStoreGenerator* absl_nonnull generator
+                         ABSL_ATTRIBUTE_LIFETIME_BOUND)
+      : generator_(*ABSL_DIE_IF_NULL(generator)) {}
+
+  // Generates a C2PA manifest store and embeds it into a copy of the given
+  // asset. The riegeli::Reader and riegeli::Writer cannot be pointed to the
+  // same underlying data. The request must not include `hard_binding`.
+  // Fails if C2PA metadata already exists.
+  absl::Status Write(GenerateManifestStoreRequest& request,
+                     riegeli::Reader* absl_nonnull source,
+                     riegeli::Writer* absl_nonnull destination);
+
+ private:
+  const ManifestStoreGenerator& generator_;
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_ZIP_WRITER_H_
diff --git a/formats/zip/writer_test.cc b/formats/zip/writer_test.cc
new file mode 100644
index 0000000..9761ef3
--- /dev/null
+++ b/formats/zip/writer_test.cc
@@ -0,0 +1,131 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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/writer.h"
+
+#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 "formats/byte_range.h"
+#include "formats/zip/extractor.h"
+#include "generator/generator.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "proto/generator.pb.h"
+#include "proto/manifest.pb.h"
+#include "proto/validation_status.pb.h"
+#include "riegeli/bytes/string_reader.h"
+#include "riegeli/bytes/string_writer.h"
+#include "testing/nonprod_generator.h"
+#include "testing/nonprod_validator.h"
+#include "testing/test_file_utils.h"
+#include "validator/asset_validator.h"
+#include "validator/result.h"
+
+namespace credentio {
+namespace {
+
+using ::absl_testing::IsOkAndHolds;
+using ::absl_testing::StatusIs;
+using ::testing::AllOf;
+using ::testing::Field;
+using ::testing::Gt;
+using ::testing::HasSubstr;
+using ::testing::IsEmpty;
+using ::testing::Optional;
+
+class ZipWriterTest : public testing::Test {
+ protected:
+  void SetUp() override {
+    auto generator = CreateNonProdManifestStoreGenerator({});
+    ABSL_ASSERT_OK(generator);
+    generator_ = *std::move(generator);
+    writer_ = std::make_unique<ZipWriter>(generator_.get());
+    auto validator = CreateNonProdValidator({});
+    ABSL_ASSERT_OK(validator);
+    validator_ = *std::move(validator);
+  }
+  std::unique_ptr<ManifestStoreGenerator> generator_;
+  std::unique_ptr<ZipWriter> writer_;
+  std::unique_ptr<AssetValidator> validator_;
+};
+
+TEST_F(ZipWriterTest, Write) {
+  GenerateManifestStoreRequest request;
+  request.mutable_actions()
+      ->mutable_created()
+      ->mutable_action_metadata()
+      ->set_description("test only");
+
+  auto source = credentio_testing::GetFileReader(
+      "credentio/formats/zip/testing/"
+      "no_c2pa_032_asset.pptx");
+  ABSL_ASSERT_OK(source);
+
+  std::string result;
+  riegeli::StringWriter destination(&result);
+  ABSL_ASSERT_OK(destination.status());
+
+  ABSL_ASSERT_OK(writer_->Write(request, source->get(), &destination));
+  ASSERT_TRUE(destination.Close());
+
+  riegeli::StringReader result_reader(result);
+  ABSL_ASSERT_OK(result_reader.status());
+
+  EXPECT_THAT(ZipExtractor().ExtractManifestStoreLocation(result_reader, {}),
+              IsOkAndHolds(Optional(AllOf(Field(&ByteRange::offset, Gt(0)),
+                                          Field(&ByteRange::length, Gt(0))))));
+
+  ASSERT_TRUE(result_reader.Seek(0));
+  auto validation_result = validator_->Validate(
+      result_reader,
+      "application/"
+      "vnd.openxmlformats-officedocument.presentationml.presentation");
+  ABSL_ASSERT_OK(validation_result);
+  EXPECT_THAT(
+      (*validation_result)->proto().active_manifest().validation().failures(),
+      IsEmpty());
+}
+
+TEST_F(ZipWriterTest, WriteWithHardBindingFails) {
+  GenerateManifestStoreRequest request;
+  request.mutable_actions()
+      ->mutable_created()
+      ->mutable_action_metadata()
+      ->set_description("test only");
+  request.mutable_hard_binding();
+
+  absl::StatusOr<std::string> pptx_file_contents =
+      credentio_testing::GetContents(
+          "credentio/formats/zip/testing/"
+          "no_c2pa_032_asset.pptx");
+  ABSL_ASSERT_OK(pptx_file_contents);
+  riegeli::StringReader source(*pptx_file_contents);
+  ABSL_ASSERT_OK(source.status());
+  std::string result;
+  riegeli::StringWriter destination(&result);
+  ABSL_ASSERT_OK(destination.status());
+  EXPECT_THAT(writer_->Write(request, &source, &destination),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("must not include `hard_binding`")));
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/formats/zip/zlib_util.cc b/formats/zip/zlib_util.cc
new file mode 100644
index 0000000..f451ba0
--- /dev/null
+++ b/formats/zip/zlib_util.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 "formats/zip/zlib_util.h"
+
+#include <cstddef>
+#include <string>
+
+#include "absl/status/status.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "riegeli/base/maker.h"
+#include "riegeli/bytes/string_reader.h"
+#include "riegeli/bytes/string_writer.h"
+#include "riegeli/zlib/zlib_reader.h"
+#include "riegeli/zlib/zlib_writer.h"
+
+namespace credentio {
+
+absl::StatusOr<std::string> Compress(absl::string_view data) {
+  std::string compressed_data;
+  riegeli::ZlibWriter<riegeli::StringWriter<>> writer(
+      riegeli::Maker(&compressed_data),
+      riegeli::ZlibWriterBase::Options()
+          .set_header(riegeli::ZlibWriterBase::Header::kRaw)
+          .set_compression_level(1));
+  if (!writer.Write(data) || !writer.Close()) {
+    return absl::InternalError("Failed to compress data.");
+  }
+  return compressed_data;
+}
+
+absl::StatusOr<std::string> Uncompress(absl::string_view data,
+                                       size_t max_uncompressed_size) {
+  riegeli::ZlibReader<riegeli::StringReader<>> reader(
+      riegeli::Maker(data), riegeli::ZlibReaderBase::Options().set_header(
+                                riegeli::ZlibReaderBase::Header::kRaw));
+  std::string uncompressed_data;
+  reader.Read(max_uncompressed_size, uncompressed_data);
+  if (reader.Pull() || !reader.Close()) {
+    return absl::InternalError("Failed to uncompress data.");
+  }
+  return uncompressed_data;
+}
+
+}  // namespace credentio
diff --git a/formats/zip/zlib_util.h b/formats/zip/zlib_util.h
new file mode 100644
index 0000000..220e568
--- /dev/null
+++ b/formats/zip/zlib_util.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_ZIP_ZLIB_UTIL_H_
+#define THIRD_PARTY_CREDENTIO_FORMATS_ZIP_ZLIB_UTIL_H_
+
+#include <cstddef>
+#include <string>
+
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+
+namespace credentio {
+
+// Compresses the given data using ZLib.
+//
+// NOTE: This function should be used for compressing small files, like
+// `[Content_Types].xml`. It shouldn't be used for large data files.
+absl::StatusOr<std::string> Compress(absl::string_view data);
+
+// Uncompresses the given data using ZLib. Returns an error if the uncompressed
+// size exceeds `max_uncompressed_size` (default is 1 MiB).
+//
+// NOTE: This function should be used for uncompressing small files, like
+// `[Content_Types].xml`. It shouldn't be used for large data files.
+absl::StatusOr<std::string> Uncompress(absl::string_view data,
+                                       size_t max_uncompressed_size = 1024 *
+                                                                      1024);
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_FORMATS_ZIP_ZLIB_UTIL_H_
diff --git a/formats/zip/zlib_util_test.cc b/formats/zip/zlib_util_test.cc
new file mode 100644
index 0000000..e2ae4aa
--- /dev/null
+++ b/formats/zip/zlib_util_test.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 "formats/zip/zlib_util.h"
+
+#include <string>
+
+#include "absl/status/status.h"
+#include "absl/status/status_matchers.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+
+namespace credentio {
+namespace {
+
+TEST(ZlibUtilTest, CompressUncompressE2E) {
+  std::string data =
+      "hello world! this is a test string to be compressed. hello world!";
+
+  auto compressed = Compress(data);
+  ABSL_ASSERT_OK(compressed);
+  auto uncompressed = Uncompress(*compressed);
+  ABSL_ASSERT_OK(uncompressed);
+
+  EXPECT_EQ(*uncompressed, data);
+}
+
+TEST(ZlibUtilTest, UncompressInvalidDataFailed) {
+  std::string data = "this is not a compressed string";
+
+  EXPECT_THAT(Uncompress(data),
+              absl_testing::StatusIs(absl::StatusCode::kInternal,
+                                     "Failed to uncompress data."));
+}
+
+TEST(ZlibUtilTest, UncompressLargePayloadFailed) {
+  std::string large_string(2 * 1024 * 1024, 'a');
+  auto compressed = Compress(large_string);
+  ABSL_ASSERT_OK(compressed);
+
+  // To prevent ZIP bomb attacks, uncompressing a payload exceeding the maximum
+  // allowed size will fail.
+  EXPECT_THAT(Uncompress(*compressed),
+              absl_testing::StatusIs(absl::StatusCode::kInternal,
+                                     "Failed to uncompress data."));
+
+  // Adjusting the maximum uncompressed size to a larger value will succeed.
+  auto uncompressed =
+      Uncompress(*compressed, /*max_uncompressed_size=*/3 * 1024 * 1024);
+  ABSL_ASSERT_OK(uncompressed);
+  EXPECT_EQ(*uncompressed, large_string);
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/generator/BUILD b/generator/BUILD
new file mode 100644
index 0000000..79b6e83
--- /dev/null
+++ b/generator/BUILD
@@ -0,0 +1,191 @@
+# Copyright 2026 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#    https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT 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 = "generator",
+    srcs = ["generator.cc"],
+    hdrs = ["generator.h"],
+    deps = [
+        "//async:async_context",
+        "//proto:generator_cc_proto",
+        "@abseil-cpp//absl/functional:any_invocable",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/synchronization",
+    ],
+)
+
+cc_library(
+    name = "manifest_store_transformer",
+    hdrs = ["manifest_store_transformer.h"],
+    deps = [
+        "//jumbf:box_builder",
+        "@abseil-cpp//absl/status",
+    ],
+)
+
+cc_library(
+    name = "generator_impl",
+    srcs = ["generator_impl.cc"],
+    hdrs = ["generator_impl.h"],
+    deps = [
+        ":generator",
+        ":manifest_store_transformer",
+        "//async:async_context",
+        "//cbor",
+        "//claim:generator",
+        "//claim:manifest_box",
+        "//claim:manifest_box_assembler",
+        "//claim:manifest_box_encoder",
+        "//claim:manifest_store_view",
+        "//cose:sig_structure",
+        "//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:generator_cc_proto",
+        "//utils:cbor_padding",
+        "@abseil-cpp//absl/base:nullability",
+        "@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",
+        "@abseil-cpp//absl/strings:cord",
+        "@abseil-cpp//absl/strings:string_view",
+    ],
+)
+
+cc_test(
+    name = "generator_impl_test",
+    srcs = ["generator_impl_test.cc"],
+    deps = [
+        ":generator",
+        ":generator_impl",
+        ":manifest_store_transformer",
+        "//assertion:assertion_encoder",
+        "//async:async_context",
+        "//claim:claim_cbor_encoder",
+        "//claim:generator",
+        "//claim:local_signer",
+        "//claim:manifest_box_assembler",
+        "//claim:signer",
+        "//crypto:algorithms",
+        "//crypto:crypto_read_handler",
+        "//crypto:crypto_write_handler",
+        "//crypto/default:default_crypto_read_handler",
+        "//crypto/default:hasher",
+        "//formats/bmff:hasher",
+        "//jumbf:box_builder",
+        "//jumbf:constants",
+        "//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_cc_proto",
+        "//proto:generator_info_cc_proto",
+        "//proto:hashed_uri_cc_proto",
+        "//proto:ingredient_validation_result_cc_proto",
+        "//proto:manifest_cc_proto",
+        "//proto:signature_info_cc_proto",
+        "//proto:validation_result_cc_proto",
+        "//testing:create_test_crypto_write_handler",
+        "//testing:manifest_store_test_utils",
+        "//testing:proto_test_utils",
+        "//uuid",
+        "@abseil-cpp//absl/log:absl_check",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings:cord",
+        "@abseil-cpp//absl/strings:string_view",
+        "@abseil-cpp//absl/synchronization",
+        "@googletest//:gtest_main",
+        "@protobuf//:differencer",
+    ],
+)
+
+cc_library(
+    name = "generator_options",
+    hdrs = ["generator_options.h"],
+    deps = [
+        ":manifest_store_transformer",
+        "//assertion:assertion_encoder",
+        "//claim:claim_cbor_encoder",
+        "//claim:generator",
+        "//claim:signer",
+        "//constants:spec_version",
+        "//crypto:hash",
+        "//crypto/default:hasher",
+        "//proto:generator_info_cc_proto",
+        "//uuid",
+        "@abseil-cpp//absl/base:nullability",
+    ],
+)
+
+cc_library(
+    name = "generator_factory",
+    srcs = ["generator_factory.cc"],
+    hdrs = ["generator_factory.h"],
+    deps = [
+        ":generator",
+        ":generator_impl",
+        ":generator_options",
+        "//assertion:assertion_encoder",
+        "//async:async_context",
+        "//claim:claim_cbor_encoder",
+        "//claim:generator",
+        "//claim:manifest_box_assembler",
+        "//claim:signer",
+        "//crypto:hash",
+        "@abseil-cpp//absl/base:nullability",
+        "@abseil-cpp//absl/functional:any_invocable",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/status:statusor",
+    ],
+)
+
+cc_test(
+    name = "generator_factory_test",
+    srcs = ["generator_factory_test.cc"],
+    deps = [
+        ":generator",
+        ":generator_factory",
+        ":generator_options",
+        "//async:async_context",
+        "//claim:generator",
+        "//claim:manifest_box",
+        "//claim:manifest_box_assembler",
+        "//claim:signer",
+        "//crypto:hash",
+        "//proto:generator_cc_proto",
+        "//proto:generator_info_cc_proto",
+        "@abseil-cpp//absl/functional:any_invocable",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@abseil-cpp//absl/strings:string_view",
+        "@abseil-cpp//absl/synchronization",
+        "@googletest//:gtest_main",
+    ],
+)
diff --git a/generator/generator.cc b/generator/generator.cc
new file mode 100644
index 0000000..bc87eea
--- /dev/null
+++ b/generator/generator.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 "generator/generator.h"
+
+#include <utility>
+
+#include "absl/status/statusor.h"
+#include "absl/synchronization/notification.h"
+#include "async/async_context.h"
+#include "proto/generator.pb.h"
+
+namespace credentio {
+
+absl::StatusOr<GenerateManifestStoreResponse>
+ManifestStoreGenerator::GenerateManifestStore(
+    GenerateManifestStoreRequest request) const {
+  absl::Notification done;
+  absl::StatusOr<GenerateManifestStoreResponse> result;
+  GenerateManifestStore(std::move(request), AsyncContext(),
+                        [&](absl::StatusOr<GenerateManifestStoreResponse> r) {
+                          result = std::move(r);
+                          done.Notify();
+                        });
+  done.WaitForNotification();
+  return result;
+}
+
+absl::StatusOr<ManifestStoreTemplate>
+ManifestStoreGenerator::GenerateManifestStoreTemplate(
+    GenerateManifestStoreRequest request) const {
+  absl::Notification done;
+  absl::StatusOr<ManifestStoreTemplate> result;
+  GenerateManifestStoreTemplate(std::move(request), AsyncContext(),
+                                [&](absl::StatusOr<ManifestStoreTemplate> r) {
+                                  result = std::move(r);
+                                  done.Notify();
+                                });
+  done.WaitForNotification();
+  return result;
+}
+
+absl::StatusOr<FinalizeManifestStoreResponse>
+ManifestStoreGenerator::FinalizeManifestStore(
+    FinalizeManifestStoreRequest request) const {
+  absl::Notification done;
+  absl::StatusOr<FinalizeManifestStoreResponse> result;
+  FinalizeManifestStore(std::move(request), AsyncContext(),
+                        [&](absl::StatusOr<FinalizeManifestStoreResponse> r) {
+                          result = std::move(r);
+                          done.Notify();
+                        });
+  done.WaitForNotification();
+  return result;
+}
+
+}  // namespace credentio
diff --git a/generator/generator.h b/generator/generator.h
new file mode 100644
index 0000000..a68861f
--- /dev/null
+++ b/generator/generator.h
@@ -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.
+//
+
+#ifndef THIRD_PARTY_CREDENTIO_GENERATOR_GENERATOR_H_
+#define THIRD_PARTY_CREDENTIO_GENERATOR_GENERATOR_H_
+
+#include "absl/functional/any_invocable.h"
+#include "absl/status/statusor.h"
+#include "async/async_context.h"
+#include "proto/generator.pb.h"
+
+namespace credentio {
+// A generator for creating a C2PA Manifest Store for an asset.
+//
+// This is sometimes called the "bare metal" generation API, to distinguish it
+// from "container-level" APIs that operate on a complete media file.
+class ManifestStoreGenerator {
+ public:
+  virtual ~ManifestStoreGenerator() = default;
+  // Generates a Manifest Store -- a collection of C2PA Manifests -- from the
+  // given request according to the C2PA specification, and returns the encoded
+  // bytes that can be embedded into an asset (e.g., an image, a video, etc.).
+  // The request must include a hard binding assertion.
+  virtual void GenerateManifestStore(
+      GenerateManifestStoreRequest request, AsyncContext context,
+      absl::AnyInvocable<void(absl::StatusOr<GenerateManifestStoreResponse>) &&>
+          cb) const = 0;
+  // Blocking version.
+  absl::StatusOr<GenerateManifestStoreResponse> GenerateManifestStore(
+      GenerateManifestStoreRequest request) const;
+
+  // Generates a Manifest Store template for allocating space for the C2PA
+  // data. The request must include a hard binding assertion. Within the hard
+  // binding, the following may be placeholders (zero):
+  //   - hash values
+  //   - data hash exclusion byte offsets and lengths
+  //   - multi-asset part location byte offsets and lengths
+  // Caller must provide the fully populated binding to FinalizeManifestStore
+  // once the asset is constructed (except for the manifest store).
+  virtual void GenerateManifestStoreTemplate(
+      GenerateManifestStoreRequest request, AsyncContext context,
+      absl::AnyInvocable<void(absl::StatusOr<ManifestStoreTemplate>) &&> cb)
+      const = 0;
+  // Blocking version.
+  absl::StatusOr<ManifestStoreTemplate> GenerateManifestStoreTemplate(
+      GenerateManifestStoreRequest request) const;
+
+  // Finalizes and returns the serialized manifest store.
+  virtual void FinalizeManifestStore(
+      FinalizeManifestStoreRequest request, AsyncContext context,
+      absl::AnyInvocable<void(absl::StatusOr<FinalizeManifestStoreResponse>) &&>
+          cb) const = 0;
+  // Blocking version.
+  absl::StatusOr<FinalizeManifestStoreResponse> FinalizeManifestStore(
+      FinalizeManifestStoreRequest request) const;
+};
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_GENERATOR_GENERATOR_H_
diff --git a/generator/generator_factory.cc b/generator/generator_factory.cc
new file mode 100644
index 0000000..b30e6d6
--- /dev/null
+++ b/generator/generator_factory.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 "generator/generator_factory.h"
+
+#include <memory>
+#include <utility>
+
+#include "absl/base/nullability.h"
+#include "absl/functional/any_invocable.h"
+#include "absl/status/status.h"
+#include "absl/status/status_macros.h"
+#include "absl/status/statusor.h"
+#include "assertion/assertion_encoder.h"
+#include "async/async_context.h"
+#include "claim/claim_cbor_encoder.h"
+#include "claim/generator.h"
+#include "claim/manifest_box_assembler.h"
+#include "claim/signer.h"
+#include "crypto/hash.h"
+#include "generator/generator.h"
+#include "generator/generator_impl.h"
+#include "generator/generator_options.h"
+
+namespace credentio {
+namespace {
+// A ManifestStoreGenerator implementation that routes generation requests
+// between two underlying generators:
+// - `signing_generator`: used for full manifest store generation and
+//   finalization with real signatures.
+// - `template_generator`: used for manifest store template generation for size
+//   estimation.
+// Also retains ownership of `template_hash_factory` to guarantee its lifetime.
+class DualModeManifestStoreGenerator : public ManifestStoreGenerator {
+ public:
+  // Constructs a DualModeManifestStoreGenerator with the given signing and
+  // template generators, optionally retaining ownership of
+  // `template_hash_factory`.
+  DualModeManifestStoreGenerator(
+      std::unique_ptr<ManifestStoreGenerator> absl_nonnull signing_generator,
+      std::unique_ptr<ManifestStoreGenerator> absl_nonnull template_generator,
+      std::unique_ptr<HasherFactory> absl_nullable template_hash_factory =
+          nullptr)
+      : signing_generator_(std::move(signing_generator)),
+        template_hash_factory_(std::move(template_hash_factory)),
+        template_generator_(std::move(template_generator)) {}
+
+  // Generates a manifest store using the signing generator.
+  void GenerateManifestStore(
+      GenerateManifestStoreRequest request, AsyncContext context,
+      absl::AnyInvocable<void(absl::StatusOr<GenerateManifestStoreResponse>) &&>
+          cb) const override {
+    signing_generator_->GenerateManifestStore(
+        std::move(request), std::move(context), std::move(cb));
+  }
+
+  // Generates a manifest store template for size estimation using the template
+  // generator.
+  void GenerateManifestStoreTemplate(
+      GenerateManifestStoreRequest request, AsyncContext context,
+      absl::AnyInvocable<void(absl::StatusOr<ManifestStoreTemplate>) &&> cb)
+      const override {
+    template_generator_->GenerateManifestStoreTemplate(
+        std::move(request), std::move(context), std::move(cb));
+  }
+
+  // Finalizes the manifest store using the signing generator.
+  void FinalizeManifestStore(
+      FinalizeManifestStoreRequest request, AsyncContext context,
+      absl::AnyInvocable<void(absl::StatusOr<FinalizeManifestStoreResponse>) &&>
+          cb) const override {
+    signing_generator_->FinalizeManifestStore(
+        std::move(request), std::move(context), std::move(cb));
+  }
+
+ private:
+  std::unique_ptr<ManifestStoreGenerator> signing_generator_;
+  // Must be declared before `template_generator_` so it outlives
+  // `template_generator_`.
+  std::unique_ptr<HasherFactory> template_hash_factory_;
+  std::unique_ptr<ManifestStoreGenerator> template_generator_;
+};
+
+// Helper function to create an underlying ManifestStoreGeneratorImpl with the
+// given options, hash factory, and claim signer.
+absl::StatusOr<std::unique_ptr<ManifestStoreGenerator>> CreateGenerator(
+    const GeneratorOptions& options,
+    const HasherFactory* absl_nonnull hash_factory,
+    ClaimSigner* absl_nonnull claim_signer) {
+  ABSL_ASSIGN_OR_RETURN(auto manifest_assembler,
+                        options.manifest_assembler_factory({
+                            .uuid_generator = options.uuid_generator,
+                            .hash_factory = hash_factory,
+                            .spec_version = options.spec_version,
+                        }));
+  ABSL_ASSIGN_OR_RETURN(
+      auto manifest_encoder,
+      CreateManifestEncoder({
+          .generator_info = options.generator_info,
+          .claim_signer = claim_signer,
+          .assertion_cbor_encoder = options.assertion_cbor_encoder
+                                        ? options.assertion_cbor_encoder
+                                        : &GetAssertionEncoder(),
+          .claim_cbor_encoder = options.claim_cbor_encoder
+                                    ? options.claim_cbor_encoder
+                                    : &GetClaimCborEncoder(),
+          .hash_factory = hash_factory,
+      }));
+  return std::make_unique<ManifestStoreGeneratorImpl>(
+      std::move(manifest_assembler), std::move(manifest_encoder),
+      options.manifest_store_transformer);
+}
+
+// Validates that required options are provided.
+absl::Status ValidateOptions(const GeneratorOptions& options) {
+  if (options.signing_claim_signer == nullptr) {
+    return absl::InvalidArgumentError("signing_claim_signer is required");
+  }
+  if (options.template_claim_signer == nullptr) {
+    return absl::InvalidArgumentError("template_claim_signer is required");
+  }
+  if (options.uuid_generator == nullptr) {
+    return absl::InvalidArgumentError("uuid_generator is required");
+  }
+  if (options.hash_factory == nullptr) {
+    return absl::InvalidArgumentError("hash_factory is required");
+  }
+  if (options.manifest_assembler_factory == nullptr) {
+    return absl::InvalidArgumentError("manifest_assembler_factory is required");
+  }
+  return absl::OkStatus();
+}
+}  // namespace
+
+absl::StatusOr<std::unique_ptr<ManifestStoreGenerator>> CreateGenerator(
+    GeneratorOptions options) {
+  ABSL_RETURN_IF_ERROR(ValidateOptions(options));
+  auto template_hash_factory = std::make_unique<TemplatedHasherFactory>(
+      options.hash_factory->algorithm());
+  ABSL_ASSIGN_OR_RETURN(auto signing_generator,
+                        CreateGenerator(options, options.hash_factory,
+                                        options.signing_claim_signer));
+  ABSL_ASSIGN_OR_RETURN(auto template_generator,
+                        CreateGenerator(options, template_hash_factory.get(),
+                                        options.template_claim_signer));
+  return std::make_unique<DualModeManifestStoreGenerator>(
+      std::move(signing_generator), std::move(template_generator),
+      std::move(template_hash_factory));
+}
+
+}  // namespace credentio
diff --git a/generator/generator_factory.h b/generator/generator_factory.h
new file mode 100644
index 0000000..22e3698
--- /dev/null
+++ b/generator/generator_factory.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_GENERATOR_GENERATOR_FACTORY_H_
+#define THIRD_PARTY_CREDENTIO_GENERATOR_GENERATOR_FACTORY_H_
+
+#include <memory>
+
+#include "absl/status/statusor.h"
+#include "generator/generator.h"
+#include "generator/generator_options.h"
+
+namespace credentio {
+
+// Creates a ManifestStoreGenerator based on the given options.
+//
+// The returned generator delegates full manifest store generation and
+// finalization to an internal generator using `options.signing_claim_signer`,
+// and delegates manifest store template generation to an internal generator
+// using `options.template_claim_signer`.
+absl::StatusOr<std::unique_ptr<ManifestStoreGenerator>> CreateGenerator(
+    GeneratorOptions options);
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_GENERATOR_GENERATOR_FACTORY_H_
diff --git a/generator/generator_factory_test.cc b/generator/generator_factory_test.cc
new file mode 100644
index 0000000..7c8f85e
--- /dev/null
+++ b/generator/generator_factory_test.cc
@@ -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.
+//
+
+#include "generator/generator_factory.h"
+
+#include <memory>
+#include <string>
+#include <utility>
+
+#include "absl/functional/any_invocable.h"
+#include "absl/status/status.h"
+#include "absl/status/status_matchers.h"
+#include "absl/strings/string_view.h"
+#include "absl/synchronization/notification.h"
+#include "async/async_context.h"
+#include "claim/generator.h"
+#include "claim/manifest_box.h"
+#include "claim/manifest_box_assembler.h"
+#include "claim/signer.h"
+#include "crypto/hash.h"
+#include "generator/generator.h"
+#include "generator/generator_options.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "proto/generator.pb.h"
+#include "proto/generator_info.pb.h"
+
+namespace credentio {
+namespace {
+
+using ::absl_testing::StatusIs;
+using ::testing::HasSubstr;
+using ::testing::NotNull;
+
+class FakeClaimSigner : public ClaimSigner {
+ public:
+  void Sign(absl::string_view claim, Options options,
+            absl::AnyInvocable<void(absl::StatusOr<SignResult>) &&> cb)
+      const override {
+    std::move(cb)(SignResult{.signature = "fake_sig"});
+  }
+};
+
+class TrackingManifestBoxAssembler : public ManifestBoxAssembler {
+ public:
+  explicit TrackingManifestBoxAssembler(std::string name, int* call_count)
+      : name_(std::move(name)), call_count_(call_count) {}
+
+  absl::StatusOr<ManifestBox> Assemble(
+      const GenerateManifestStoreRequest& request) const override {
+    (*call_count_)++;
+    return absl::InternalError(name_);
+  }
+
+ private:
+  std::string name_;
+  int* call_count_;
+};
+
+TEST(GeneratorFactoryTest, ValidateOptionsRejectsNullSigningClaimSigner) {
+  FakeClaimSigner template_signer;
+  GeneratorOptions options;
+  options.signing_claim_signer = nullptr;
+  options.template_claim_signer = &template_signer;
+
+  EXPECT_THAT(CreateGenerator(std::move(options)),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("signing_claim_signer is required")));
+}
+
+TEST(GeneratorFactoryTest, ValidateOptionsRejectsNullTemplateClaimSigner) {
+  FakeClaimSigner signing_signer;
+  GeneratorOptions options;
+  options.signing_claim_signer = &signing_signer;
+  options.template_claim_signer = nullptr;
+
+  EXPECT_THAT(CreateGenerator(std::move(options)),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("template_claim_signer is required")));
+}
+
+TEST(GeneratorFactoryTest, ValidateOptionsRejectsNullUuidGenerator) {
+  FakeClaimSigner signing_signer;
+  FakeClaimSigner template_signer;
+  GeneratorOptions options;
+  options.signing_claim_signer = &signing_signer;
+  options.template_claim_signer = &template_signer;
+  options.uuid_generator = nullptr;
+
+  EXPECT_THAT(CreateGenerator(std::move(options)),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("uuid_generator is required")));
+}
+
+TEST(GeneratorFactoryTest, ValidateOptionsRejectsNullHashFactory) {
+  FakeClaimSigner signing_signer;
+  FakeClaimSigner template_signer;
+  GeneratorOptions options;
+  options.signing_claim_signer = &signing_signer;
+  options.template_claim_signer = &template_signer;
+  options.hash_factory = nullptr;
+
+  EXPECT_THAT(CreateGenerator(std::move(options)),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("hash_factory is required")));
+}
+
+TEST(GeneratorFactoryTest, ValidateOptionsRejectsNullManifestAssemblerFactory) {
+  FakeClaimSigner signing_signer;
+  FakeClaimSigner template_signer;
+  GeneratorOptions options;
+  options.signing_claim_signer = &signing_signer;
+  options.template_claim_signer = &template_signer;
+  options.manifest_assembler_factory = nullptr;
+
+  EXPECT_THAT(CreateGenerator(std::move(options)),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("manifest_assembler_factory is required")));
+}
+
+TEST(GeneratorFactoryTest, CreateGeneratorSuccess) {
+  FakeClaimSigner signing_signer;
+  FakeClaimSigner template_signer;
+  GeneratorOptions options;
+  options.generator_info.set_name("TestGenerator");
+  options.generator_info.set_spec_version("2.2.0");
+  options.signing_claim_signer = &signing_signer;
+  options.template_claim_signer = &template_signer;
+
+  auto generator_or = CreateGenerator(std::move(options));
+  ABSL_ASSERT_OK(generator_or);
+  EXPECT_THAT(*generator_or, NotNull());
+}
+
+TEST(GeneratorFactoryTest, DualModeGeneratorDelegation) {
+  FakeClaimSigner signing_signer;
+  FakeClaimSigner template_signer;
+  int signing_assemble_count = 0;
+  int template_assemble_count = 0;
+
+  GeneratorOptions options;
+  options.generator_info.set_name("TestGenerator");
+  options.generator_info.set_spec_version("2.2.0");
+  options.signing_claim_signer = &signing_signer;
+  options.template_claim_signer = &template_signer;
+  options.manifest_assembler_factory =
+      [&](const CreateManifestAssemblerOptions& opts)
+      -> absl::StatusOr<std::unique_ptr<ManifestBoxAssembler>> {
+    if (dynamic_cast<const TemplatedHasherFactory*>(opts.hash_factory) !=
+        nullptr) {
+      return std::make_unique<TrackingManifestBoxAssembler>(
+          "template_assembler", &template_assemble_count);
+    }
+    return std::make_unique<TrackingManifestBoxAssembler>(
+        "signing_assembler", &signing_assemble_count);
+  };
+
+  auto generator_or = CreateGenerator(std::move(options));
+  ABSL_ASSERT_OK(generator_or);
+  auto generator = std::move(*generator_or);
+
+  // GenerateManifestStore should delegate to the signing generator.
+  GenerateManifestStoreRequest gen_request;
+  absl::Notification gen_done;
+  absl::Status gen_status;
+  generator->GenerateManifestStore(
+      gen_request, AsyncContext(),
+      [&](absl::StatusOr<GenerateManifestStoreResponse> res) {
+        gen_status = res.status();
+        gen_done.Notify();
+      });
+  gen_done.WaitForNotification();
+  EXPECT_THAT(gen_status, StatusIs(absl::StatusCode::kInternal,
+                                   HasSubstr("signing_assembler")));
+  EXPECT_EQ(signing_assemble_count, 1);
+  EXPECT_EQ(template_assemble_count, 0);
+
+  // GenerateManifestStoreTemplate should delegate to the template generator.
+  absl::Notification template_done;
+  absl::Status template_status;
+  generator->GenerateManifestStoreTemplate(
+      gen_request, AsyncContext(),
+      [&](absl::StatusOr<ManifestStoreTemplate> res) {
+        template_status = res.status();
+        template_done.Notify();
+      });
+  template_done.WaitForNotification();
+  EXPECT_THAT(template_status, StatusIs(absl::StatusCode::kInternal,
+                                        HasSubstr("template_assembler")));
+  EXPECT_EQ(signing_assemble_count, 1);
+  EXPECT_EQ(template_assemble_count, 1);
+
+  // FinalizeManifestStore should delegate to the signing generator.
+  FinalizeManifestStoreRequest fin_request;
+  absl::Notification fin_done;
+  absl::Status fin_status;
+  generator->FinalizeManifestStore(
+      fin_request, AsyncContext(),
+      [&](absl::StatusOr<FinalizeManifestStoreResponse> res) {
+        fin_status = res.status();
+        fin_done.Notify();
+      });
+  fin_done.WaitForNotification();
+  EXPECT_THAT(fin_status, StatusIs(absl::StatusCode::kInvalidArgument,
+                                   HasSubstr("generator_context")));
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/generator/generator_impl.cc b/generator/generator_impl.cc
new file mode 100644
index 0000000..85f17c6
--- /dev/null
+++ b/generator/generator_impl.cc
@@ -0,0 +1,368 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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 "generator/generator_impl.h"
+
+#include <cstddef>
+#include <cstdint>
+#include <memory>
+#include <string>
+#include <utility>
+
+#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/cord.h"
+#include "absl/strings/string_view.h"
+#include "absl/strings/substitute.h"
+#include "async/async_context.h"
+#include "cbor/options.h"
+#include "claim/manifest_box.h"
+#include "claim/manifest_box_encoder.h"
+#include "claim/manifest_store_view.h"
+#include "cose/sig_structure.h"
+#include "generator/manifest_store_transformer.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/generator.pb.h"
+#include "utils/cbor_padding.h"
+
+namespace credentio {
+
+namespace {
+
+// Current default limit on manifest store size in our validator is 10 MiB.
+// Some PNG parsing code elsewhere fails if the manifest store is > 7.6 MiB.
+constexpr int64_t kDefaultMaxManifestStoreSizeBytes = 7 * 1024 * 1024;  // 7 MiB
+
+template <typename T>
+absl::StatusOr<size_t> GetCborSize(const T& proto,
+                                   const cbor::FromProtoOptions& options) {
+  ABSL_ASSIGN_OR_RETURN(auto cbor, cbor::FromProto(proto, options));
+  return cbor->toString().size();
+}
+
+absl::Status CheckIfPartBindingByteSizesMatched(
+    const PartBinding& template_part_binding,
+    const PartBinding& finalize_part_binding,
+    const cbor::FromProtoOptions& proto_to_cbor_options) {
+  size_t template_size;
+  size_t finalize_size;
+  switch (template_part_binding.binding_case()) {
+    case PartBinding::kBmffBasedHash: {
+      ABSL_ASSIGN_OR_RETURN(template_size,
+                            GetCborSize(template_part_binding.bmff_based_hash(),
+                                        proto_to_cbor_options));
+      ABSL_ASSIGN_OR_RETURN(finalize_size,
+                            GetCborSize(finalize_part_binding.bmff_based_hash(),
+                                        proto_to_cbor_options));
+      break;
+    }
+    case PartBinding::kBoxHash: {
+      BoxesHashAssertion template_boxes_hash = template_part_binding.box_hash();
+      BoxesHashAssertion finalize_boxes_hash = finalize_part_binding.box_hash();
+      PadBoxesHashAssertion(template_boxes_hash);
+      PadBoxesHashAssertion(finalize_boxes_hash);
+      ABSL_ASSIGN_OR_RETURN(template_size, GetCborSize(template_boxes_hash,
+                                                       proto_to_cbor_options));
+      ABSL_ASSIGN_OR_RETURN(finalize_size, GetCborSize(finalize_boxes_hash,
+                                                       proto_to_cbor_options));
+      break;
+    }
+    case PartBinding::kDataHash: {
+      DataHashAssertion template_data_hash = template_part_binding.data_hash();
+      DataHashAssertion finalize_data_hash = finalize_part_binding.data_hash();
+      PadDataHashAssertion(template_data_hash);
+      PadDataHashAssertion(finalize_data_hash);
+      ABSL_ASSIGN_OR_RETURN(template_size, GetCborSize(template_data_hash,
+                                                       proto_to_cbor_options));
+      ABSL_ASSIGN_OR_RETURN(finalize_size, GetCborSize(finalize_data_hash,
+                                                       proto_to_cbor_options));
+      break;
+    }
+    case PartBinding::BINDING_NOT_SET:
+      return absl::InvalidArgumentError(
+          "A part binding in the finalize request must be set");
+  }
+  if (template_size != finalize_size) {
+    return absl::InvalidArgumentError(
+        "The part binding differs from template in unsupported ways.");
+  }
+  return absl::OkStatus();
+}
+
+absl::Status CheckIfHardBindingByteSizesMatched(
+    const HardBinding& template_hard_binding,
+    const HardBinding& finalize_hard_binding,
+    const cbor::FromProtoOptions& proto_to_cbor_options) {
+  size_t template_size;
+  size_t finalize_size;
+  switch (template_hard_binding.binding_case()) {
+    case HardBinding::kBmffBasedHash: {
+      ABSL_ASSIGN_OR_RETURN(template_size,
+                            GetCborSize(template_hard_binding.bmff_based_hash(),
+                                        proto_to_cbor_options));
+      ABSL_ASSIGN_OR_RETURN(finalize_size,
+                            GetCborSize(finalize_hard_binding.bmff_based_hash(),
+                                        proto_to_cbor_options));
+      break;
+    }
+    case HardBinding::kBoxHash: {
+      BoxesHashAssertion template_boxes_hash = template_hard_binding.box_hash();
+      BoxesHashAssertion finalize_boxes_hash = finalize_hard_binding.box_hash();
+      PadBoxesHashAssertion(template_boxes_hash);
+      PadBoxesHashAssertion(finalize_boxes_hash);
+      ABSL_ASSIGN_OR_RETURN(template_size, GetCborSize(template_boxes_hash,
+                                                       proto_to_cbor_options));
+      ABSL_ASSIGN_OR_RETURN(finalize_size, GetCborSize(finalize_boxes_hash,
+                                                       proto_to_cbor_options));
+      break;
+    }
+    case HardBinding::kDataHash: {
+      DataHashAssertion template_data_hash = template_hard_binding.data_hash();
+      DataHashAssertion finalize_data_hash = finalize_hard_binding.data_hash();
+      PadDataHashAssertion(template_data_hash);
+      PadDataHashAssertion(finalize_data_hash);
+      ABSL_ASSIGN_OR_RETURN(template_size, GetCborSize(template_data_hash,
+                                                       proto_to_cbor_options));
+      ABSL_ASSIGN_OR_RETURN(finalize_size, GetCborSize(finalize_data_hash,
+                                                       proto_to_cbor_options));
+      break;
+    }
+    case HardBinding::kCollectionDataHash: {
+      ABSL_ASSIGN_OR_RETURN(
+          template_size,
+          GetCborSize(template_hard_binding.collection_data_hash(),
+                      proto_to_cbor_options));
+      ABSL_ASSIGN_OR_RETURN(
+          finalize_size,
+          GetCborSize(finalize_hard_binding.collection_data_hash(),
+                      proto_to_cbor_options));
+      break;
+    }
+    case HardBinding::BINDING_NOT_SET:
+      return absl::InvalidArgumentError(
+          "The hard binding in the finalize request must be set");
+  }
+  if (template_size != finalize_size) {
+    return absl::InvalidArgumentError(
+        "The final hard binding differs from template in unsupported ways.");
+  }
+
+  if (template_hard_binding.part_bindings_size() !=
+      finalize_hard_binding.part_bindings_size()) {
+    return absl::InvalidArgumentError(
+        "The number of part bindings differs between template and finalize.");
+  }
+
+  for (int i = 0; i < template_hard_binding.part_bindings_size(); ++i) {
+    ABSL_RETURN_IF_ERROR(CheckIfPartBindingByteSizesMatched(
+        template_hard_binding.part_bindings(i),
+        finalize_hard_binding.part_bindings(i), proto_to_cbor_options));
+  }
+  return absl::OkStatus();
+}
+
+absl::StatusOr<bool> HasActiveClaimTimestamp(absl::string_view manifest_store) {
+  ABSL_ASSIGN_OR_RETURN(ManifestStoreView view,
+                        ManifestStoreView::Create(manifest_store));
+  ABSL_ASSIGN_OR_RETURN(ManifestView manifest, view.GetActiveManifest());
+  ABSL_ASSIGN_OR_RETURN(ClaimSignatureView claim_signature,
+                        manifest.GetClaimSignature());
+  ABSL_ASSIGN_OR_RETURN(CoseSign1TaggedStructure cose_sign1,
+                        claim_signature.GetStruct());
+  return cose_sign1.unprotected_header.sig_tst2.has_value();
+}
+
+}  // namespace
+
+void ManifestStoreGeneratorImpl::GenerateManifestStore(
+    GenerateManifestStoreRequest request, AsyncContext context,
+    absl::AnyInvocable<void(absl::StatusOr<GenerateManifestStoreResponse>) &&>
+        cb) const {
+  int64_t max_manifest_store_size_bytes =
+      request.options().max_manifest_store_size_bytes();
+  if (max_manifest_store_size_bytes < 0) {
+    AsyncResolver<GenerateManifestStoreResponse>(std::move(context),
+                                                 std::move(cb))
+        .Return(absl::InvalidArgumentError(
+            "max_manifest_store_size_bytes must be non-negative"));
+    return;
+  }
+  if (request.options().has_max_bytes_to_shift() &&
+      request.options().max_bytes_to_shift() < 0) {
+    AsyncResolver<GenerateManifestStoreResponse>(std::move(context),
+                                                 std::move(cb))
+        .Return(absl::InvalidArgumentError(
+            "max_bytes_to_shift must be non-negative"));
+    return;
+  }
+  if (max_manifest_store_size_bytes == 0) {
+    max_manifest_store_size_bytes = kDefaultMaxManifestStoreSizeBytes;
+  }
+  auto request_ptr =
+      std::make_shared<GenerateManifestStoreRequest>(std::move(request));
+  auto manifest_or = manifest_assembler_->Assemble(*request_ptr);
+  if (!manifest_or.ok()) {
+    AsyncResolver<GenerateManifestStoreResponse>(std::move(context),
+                                                 std::move(cb))
+        .Return(manifest_or.status());
+    return;
+  }
+  auto manifest_box = std::make_shared<ManifestBox>(std::move(*manifest_or));
+  manifest_encoder_->Encode(
+      manifest_box.get(), request_ptr->options(), context,
+      [request_ptr, manifest_box, max_manifest_store_size_bytes,
+       manifest_store_transformer = manifest_store_transformer_,
+       cb = std::move(cb)](
+          absl::StatusOr<ManifestEncoderResult> res_or) mutable {
+        if (!res_or.ok()) {
+          std::move(cb)(res_or.status());
+          return;
+        }
+        absl::Cord encoded_store = res_or->box.Peek();
+        absl::StatusOr<bool> is_timestamped =
+            HasActiveClaimTimestamp(encoded_store.Flatten());
+        if (!is_timestamped.ok()) {
+          std::move(cb)(is_timestamped.status());
+          return;
+        }
+        if (manifest_store_transformer != nullptr) {
+          absl::Status transform_status =
+              manifest_store_transformer->Transform(&res_or->box);
+          if (!transform_status.ok()) {
+            std::move(cb)(transform_status);
+            return;
+          }
+        }
+        absl::Cord manifest_store_cord = std::move(res_or->box).AsCord();
+        if (manifest_store_cord.size() >
+            static_cast<uint64_t>(max_manifest_store_size_bytes)) {
+          std::move(cb)(absl::FailedPreconditionError(absl::Substitute(
+              "Generated manifest store too large ($0 > $1)",
+              manifest_store_cord.size(), max_manifest_store_size_bytes)));
+          return;
+        }
+        GenerateManifestStoreResponse response;
+        response.set_manifest_store(std::string(manifest_store_cord));
+        response.set_is_timestamped(*is_timestamped);
+        std::move(cb)(std::move(response));
+      });
+}
+
+void ManifestStoreGeneratorImpl::GenerateManifestStoreTemplate(
+    GenerateManifestStoreRequest request, AsyncContext context,
+    absl::AnyInvocable<void(absl::StatusOr<ManifestStoreTemplate>) &&> cb)
+    const {
+  std::string generator_context = request.SerializeAsString();
+  GenerateManifestStore(
+      std::move(request), context,
+      [generator_context = std::move(generator_context), cb = std::move(cb)](
+          absl::StatusOr<GenerateManifestStoreResponse> generated_or) mutable {
+        if (!generated_or.ok()) {
+          std::move(cb)(generated_or.status());
+          return;
+        }
+        ManifestStoreTemplate response;
+        response.set_generator_context(std::move(generator_context));
+        response.set_manifest_store_size(generated_or->manifest_store().size());
+        std::move(cb)(std::move(response));
+      });
+}
+
+void ManifestStoreGeneratorImpl::FinalizeManifestStore(
+    FinalizeManifestStoreRequest request, AsyncContext context,
+    absl::AnyInvocable<void(absl::StatusOr<FinalizeManifestStoreResponse>) &&>
+        cb) const {
+  if (request.manifest_store_template().generator_context().empty()) {
+    AsyncResolver<FinalizeManifestStoreResponse>(std::move(context),
+                                                 std::move(cb))
+        .Return(absl::InvalidArgumentError(
+            "The generator_context in the manifest store template is empty"));
+    return;
+  }
+  if (request.manifest_store_template().manifest_store_size() < 0) {
+    AsyncResolver<FinalizeManifestStoreResponse>(std::move(context),
+                                                 std::move(cb))
+        .Return(absl::InvalidArgumentError(
+            "manifest_store_size in template must be non-negative"));
+    return;
+  }
+  GenerateManifestStoreRequest generate_request;
+  if (!generate_request.ParseFromString(
+          request.manifest_store_template().generator_context())) {
+    AsyncResolver<FinalizeManifestStoreResponse>(std::move(context),
+                                                 std::move(cb))
+        .Return(absl::InvalidArgumentError(
+            "Failed to parse the generator_context in the manifest store "
+            "template"));
+    return;
+  }
+  if (generate_request.hard_binding().binding_case() !=
+      request.hard_binding().binding_case()) {
+    AsyncResolver<FinalizeManifestStoreResponse>(std::move(context),
+                                                 std::move(cb))
+        .Return(absl::InvalidArgumentError(
+            "The hard binding in the finalize request must match the original "
+            "request"));
+    return;
+  }
+  absl::Status check_status = CheckIfHardBindingByteSizesMatched(
+      generate_request.hard_binding(), request.hard_binding(),
+      proto_to_cbor_options_);
+  if (!check_status.ok()) {
+    AsyncResolver<FinalizeManifestStoreResponse>(std::move(context),
+                                                 std::move(cb))
+        .Return(check_status);
+    return;
+  }
+  generate_request.mutable_hard_binding()->Swap(request.mutable_hard_binding());
+
+  size_t template_manifest_store_size =
+      request.manifest_store_template().manifest_store_size();
+  GenerateManifestStore(
+      std::move(generate_request), context,
+      [template_manifest_store_size, cb = std::move(cb)](
+          absl::StatusOr<GenerateManifestStoreResponse> generated_or) mutable {
+        if (!generated_or.ok()) {
+          std::move(cb)(generated_or.status());
+          return;
+        }
+        if (generated_or->manifest_store().size() !=
+            template_manifest_store_size) {
+          std::move(cb)(absl::InternalError(absl::Substitute(
+              "The generated manifest store size ($0) does not "
+              "match the estimated size ($1), even though the hard binding "
+              "sizes match. This is an internal error.",
+              generated_or->manifest_store().size(),
+              template_manifest_store_size)));
+          return;
+        }
+        FinalizeManifestStoreResponse response;
+        response.mutable_manifest_store()->swap(
+            *generated_or->mutable_manifest_store());
+        response.set_is_timestamped(generated_or->is_timestamped());
+        std::move(cb)(std::move(response));
+      });
+}
+
+}  // namespace credentio
diff --git a/generator/generator_impl.h b/generator/generator_impl.h
new file mode 100644
index 0000000..671daec
--- /dev/null
+++ b/generator/generator_impl.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_GENERATOR_GENERATOR_IMPL_H_
+#define THIRD_PARTY_CREDENTIO_GENERATOR_GENERATOR_IMPL_H_
+
+#include <memory>
+#include <utility>
+
+#include "absl/base/nullability.h"
+#include "absl/functional/any_invocable.h"
+#include "absl/status/statusor.h"
+#include "async/async_context.h"
+#include "cbor/options.h"
+#include "claim/generator.h"
+#include "claim/manifest_box_assembler.h"
+#include "generator/generator.h"
+#include "generator/manifest_store_transformer.h"
+#include "proto/generator.pb.h"
+
+namespace credentio {
+
+class ManifestStoreGeneratorImpl : public ManifestStoreGenerator {
+ public:
+  ManifestStoreGeneratorImpl(
+      std::unique_ptr<ManifestBoxAssembler> absl_nonnull manifest_assembler,
+      std::unique_ptr<ManifestEncoder> absl_nonnull manifest_encoder,
+      const ManifestStoreTransformer* absl_nullable manifest_store_transformer)
+      : manifest_assembler_(std::move(manifest_assembler)),
+        manifest_encoder_(std::move(manifest_encoder)),
+        manifest_store_transformer_(manifest_store_transformer) {}
+  void GenerateManifestStore(
+      GenerateManifestStoreRequest request, AsyncContext context,
+      absl::AnyInvocable<void(absl::StatusOr<GenerateManifestStoreResponse>) &&>
+          cb) const override;
+  void GenerateManifestStoreTemplate(
+      GenerateManifestStoreRequest request, AsyncContext context,
+      absl::AnyInvocable<void(absl::StatusOr<ManifestStoreTemplate>) &&> cb)
+      const override;
+  void FinalizeManifestStore(
+      FinalizeManifestStoreRequest request, AsyncContext context,
+      absl::AnyInvocable<void(absl::StatusOr<FinalizeManifestStoreResponse>) &&>
+          cb) const override;
+
+ private:
+  std::unique_ptr<ManifestBoxAssembler> manifest_assembler_;
+  std::unique_ptr<ManifestEncoder> manifest_encoder_;
+  cbor::FromProtoOptions proto_to_cbor_options_;
+  const ManifestStoreTransformer* absl_nullable manifest_store_transformer_ =
+      nullptr;
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_GENERATOR_GENERATOR_IMPL_H_
diff --git a/generator/generator_impl_test.cc b/generator/generator_impl_test.cc
new file mode 100644
index 0000000..689d69c
--- /dev/null
+++ b/generator/generator_impl_test.cc
@@ -0,0 +1,1949 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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 "generator/generator_impl.h"
+
+#include <memory>
+#include <string>
+#include <utility>
+#include <vector>
+
+#include "absl/log/absl_check.h"
+#include "absl/status/status.h"
+#include "absl/status/status_matchers.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/cord.h"
+#include "absl/strings/string_view.h"
+#include "absl/synchronization/notification.h"
+#include "assertion/assertion_encoder.h"
+#include "async/async_context.h"
+#include "claim/claim_cbor_encoder.h"
+#include "claim/generator.h"
+#include "claim/local_signer.h"
+#include "claim/manifest_box_assembler.h"
+#include "claim/signer.h"
+#include "crypto/algorithms.h"
+#include "crypto/crypto_read_handler.h"
+#include "crypto/crypto_write_handler.h"
+#include "crypto/default/default_crypto_read_handler.h"
+#include "crypto/default/hasher.h"
+#include "formats/bmff/hasher.h"
+#include "generator/generator.h"
+#include "generator/manifest_store_transformer.h"
+#include "gmock/gmock.h"
+#include "google/protobuf/util/message_differencer.h"
+#include "gtest/gtest.h"
+#include "jumbf/box_builder.h"
+#include "jumbf/constants.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.pb.h"
+#include "proto/generator_info.pb.h"
+#include "proto/hashed_uri.pb.h"
+#include "proto/ingredient_validation_result.pb.h"
+#include "proto/manifest.pb.h"
+#include "proto/signature_info.pb.h"
+#include "proto/validation_result.pb.h"
+#include "testing/create_test_crypto_write_handler.h"
+#include "testing/manifest_store_test_utils.h"
+#include "testing/proto_test_utils.h"
+#include "uuid/uuid.h"
+
+namespace credentio {
+namespace {
+
+using ::absl_testing::IsOk;
+using ::absl_testing::StatusIs;
+using ::credentio_testing::ParseTextProtoOrDie;
+using ::testing::HasSubstr;
+using ::testing::Not;
+
+MATCHER_P(EqualsProtoIgnoringTimestampAndOrdering, expected, "") {
+  google::protobuf::util::MessageDifferencer differencer;
+  differencer.set_repeated_field_comparison(
+      google::protobuf::util::MessageDifferencer::AS_SET);
+  differencer.IgnoreField(
+      SignatureInfo::descriptor()->FindFieldByName("timestamping_time"));
+  std::string diff;
+  differencer.ReportDifferencesToString(&diff);
+  bool match = differencer.Compare(expected, arg);
+  if (!match) {
+    *result_listener << "\nDifference:\n" << diff;
+  }
+  return match;
+}
+
+MATCHER_P(EqualsProtoIgnoringHashTimestampAndOrdering, expected, "") {
+  google::protobuf::util::MessageDifferencer differencer;
+  differencer.set_repeated_field_comparison(
+      google::protobuf::util::MessageDifferencer::AS_SET);
+  differencer.IgnoreField(HashedUri::descriptor()->FindFieldByName("hash"));
+  differencer.IgnoreField(
+      SignatureInfo::descriptor()->FindFieldByName("timestamping_time"));
+  std::string diff;
+  differencer.ReportDifferencesToString(&diff);
+  bool match = differencer.Compare(expected, arg);
+  if (!match) {
+    *result_listener << "\nDifference:\n" << diff;
+  }
+  return match;
+}
+
+absl::StatusOr<std::unique_ptr<CryptoReadHandler>>
+CreateTrustAnyoneCryptoReadHandlerForTest() {
+  return CreateDefaultCryptoReadHandler({
+      .trust_environment = CryptoReadHandler::TrustEnvironment::kAny,
+  });
+}
+
+class MockManifestStoreTransformer : public ManifestStoreTransformer {
+ public:
+  MOCK_METHOD(absl::Status, Transform, (jumbf::BuiltSuperBox * box),
+              (const, override));
+};
+
+constexpr Uuid kUuid =
+    Uuid::FromStringOrDie("03020100-0504-0706-0908-0f0e0d0c0b0a");
+
+constexpr Uuid kUuid_2 =
+    Uuid::FromStringOrDie("12345678-1234-1234-1234-123456789012");
+
+class FakeUuidGenerator : public UuidGenerator {
+ public:
+  FakeUuidGenerator() : uuids_({kUuid, kUuid_2}) {}
+
+  Uuid Generate() const override {
+    if (index_ >= uuids_.size()) {
+      index_ = 0;
+    }
+    return uuids_[index_++];
+  }
+
+ private:
+  std::vector<Uuid> uuids_;
+  mutable int index_ = 0;
+};
+
+absl::StatusOr<GenerateManifestStoreResponse> GenerateManifestStore(
+    ManifestStoreGenerator& generator, GenerateManifestStoreRequest request) {
+  absl::Notification done;
+  absl::StatusOr<GenerateManifestStoreResponse> result;
+  generator.GenerateManifestStore(
+      std::move(request), AsyncContext(),
+      [&result, &done](absl::StatusOr<GenerateManifestStoreResponse> res) {
+        result = std::move(res);
+        done.Notify();
+      });
+  done.WaitForNotification();
+  return result;
+}
+
+absl::StatusOr<ManifestStoreTemplate> GenerateManifestStoreTemplate(
+    ManifestStoreGenerator& generator, GenerateManifestStoreRequest request) {
+  absl::Notification done;
+  absl::StatusOr<ManifestStoreTemplate> result;
+  generator.GenerateManifestStoreTemplate(
+      std::move(request), AsyncContext(),
+      [&result, &done](absl::StatusOr<ManifestStoreTemplate> res) {
+        result = std::move(res);
+        done.Notify();
+      });
+  done.WaitForNotification();
+  return result;
+}
+
+absl::StatusOr<FinalizeManifestStoreResponse> FinalizeManifestStore(
+    ManifestStoreGenerator& generator, FinalizeManifestStoreRequest request) {
+  absl::Notification done;
+  absl::StatusOr<FinalizeManifestStoreResponse> result;
+  generator.FinalizeManifestStore(
+      std::move(request), AsyncContext(),
+      [&result, &done](absl::StatusOr<FinalizeManifestStoreResponse> res) {
+        result = std::move(res);
+        done.Notify();
+      });
+  done.WaitForNotification();
+  return result;
+}
+
+class ManifestStoreGeneratorTest : public testing::Test {
+ protected:
+  ManifestStoreGeneratorTest() {
+    absl::StatusOr<std::unique_ptr<CryptoWriteHandler>> crypto_writer =
+        CreateTestCryptoWriteHandler({});
+    ABSL_CHECK_OK(crypto_writer);
+    crypto_writer_ = std::move(*crypto_writer);
+
+    generator_info_.set_name("Test generator");
+    generator_info_.set_spec_version("2.2.0");
+    absl::StatusOr<std::unique_ptr<CryptoReadHandler>> crypto_reader =
+        CreateTrustAnyoneCryptoReadHandlerForTest();
+    ABSL_CHECK_OK(crypto_reader);
+    crypto_reader_ = std::move(*crypto_reader);
+    claim_signer_ = std::make_unique<LocalClaimSigner>(crypto_writer_.get(),
+                                                       &hash_factory_);
+
+    store_generator_ = CreateGenerator();
+  }
+
+  std::unique_ptr<ManifestStoreGenerator> CreateGenerator(
+      const ManifestStoreTransformer* manifest_store_transformer = nullptr) {
+    absl::StatusOr<std::unique_ptr<ManifestBoxAssembler>> manifest_assembler =
+        CreateManifestAssembler({.uuid_generator = &uuid_generator_,
+                                 .hash_factory = &hash_factory_});
+    ABSL_CHECK_OK(manifest_assembler);
+
+    absl::StatusOr<std::unique_ptr<ManifestEncoder>> manifest_encoder =
+        CreateManifestEncoder({
+            .generator_info = generator_info_,
+            .claim_signer = claim_signer_.get(),
+            .assertion_cbor_encoder = &GetAssertionEncoder(),
+            .claim_cbor_encoder = &GetClaimCborEncoder(),
+            .hash_factory = &hash_factory_,
+        });
+    ABSL_CHECK_OK(manifest_encoder);
+
+    return std::make_unique<ManifestStoreGeneratorImpl>(
+        std::move(*manifest_assembler), std::move(*manifest_encoder),
+        manifest_store_transformer);
+  }
+
+  FakeUuidGenerator uuid_generator_;
+  std::unique_ptr<ClaimSigner> claim_signer_;
+  std::unique_ptr<CryptoWriteHandler> crypto_writer_;
+  std::unique_ptr<CryptoReadHandler> crypto_reader_;
+  Sha256HasherFactory hash_factory_;
+  GeneratorInfo generator_info_;
+  std::unique_ptr<ManifestStoreGenerator> store_generator_;
+};
+
+TEST_F(ManifestStoreGeneratorTest, Generate) {
+  GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb(
+    instance_id: "12345678-1234-1234-1234-123456789012"
+    hard_binding {
+      box_hash {
+        alg: "some alg"
+        boxes { names: "some name" names: "other name" hash: "some hash" }
+      }
+    }
+    actions {
+      created {
+        action_metadata {
+          software_agent { name: "test generator" }
+          description: "created for testing"
+        }
+        digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture"
+      }
+    }
+  )pb");
+  PartialValidationResultProto expected_validated_result = ParseTextProtoOrDie(
+      R"pb(
+        active_manifest {
+          label: "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a"
+          claim {
+            instance_id: "12345678-1234-1234-1234-123456789012"
+            claim_generator_info {
+              name: "Test generator"
+              spec_version: "2.2.0"
+            }
+            signature_info {
+              cert_serial_number: "02"
+              issuer: "OU=Test Unit,O=Test Org,CN=Test Claim Signer"
+              certificate_issuer: "OU=Test Unit,O=Test Org,CN=Test Root CA"
+              timestamping_time {}
+            }
+            created_assertions {
+              url: "self#jumbf=c2pa.assertions/c2pa.hash.boxes"
+              algorithm: "sha256"
+              hash: ")\255`[2\265\253\340\256(\265I\216_\222m\302@V\255O\204\017\316bb2\201\317\243\233\204"
+            }
+            created_assertions {
+              url: "self#jumbf=c2pa.assertions/c2pa.actions.v2"
+              algorithm: "sha256"
+              hash: "\331\271\373\3228\310\335>y\263\334\342\335\367\211Q\360\325\227\r\345$]\214\005\3047[\r#1\312"
+            }
+            default_algorithm: "sha256"
+            label: "c2pa.claim.v2"
+          }
+          assertions {
+            label: "c2pa.hash.boxes"
+            boxes_hash {
+              boxes { names: "some name" names: "other name" hash: "some hash" }
+              alg: "some alg"
+            }
+          }
+          assertions {
+            label: "c2pa.actions.v2"
+            actions {
+              actions {
+                action: "c2pa.created"
+                description: "created for testing"
+                digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture"
+                software_agent { name: "test generator" }
+              }
+            }
+          }
+          validation {
+            successes {
+              code: "timeStamp.validated"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature"
+            }
+            successes {
+              code: "timeStamp.trusted"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature"
+            }
+            successes {
+              code: "signingCredential.trusted"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature"
+            }
+            successes {
+              code: "claimSignature.insideValidity"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature"
+            }
+            successes {
+              code: "claimSignature.validated"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature"
+            }
+            successes {
+              code: "assertion.hashedURI.match"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.hash.boxes"
+            }
+            successes {
+              code: "assertion.hashedURI.match"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.actions.v2"
+            }
+            informationals {
+              code: "signingCredential.ocsp.skipped"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature"
+            }
+          }
+        }
+        hard_binding_uri: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.hash.boxes"
+      )pb");
+  absl::StatusOr<GenerateManifestStoreResponse> generated =
+      GenerateManifestStore(*store_generator_, request);
+  ABSL_ASSERT_OK(generated);
+  EXPECT_TRUE(generated->is_timestamped());
+  absl::StatusOr<std::unique_ptr<PartialValidationResultProto>> validated =
+      ValidateManifestStore(generated->manifest_store());
+  ABSL_ASSERT_OK(validated);
+  EXPECT_THAT(**validated, EqualsProtoIgnoringTimestampAndOrdering(
+                               expected_validated_result));
+}
+
+TEST_F(ManifestStoreGeneratorTest, GenerateWithGatheredAssertions) {
+  GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb(
+    instance_id: "12345678-1234-1234-1234-123456789012"
+    hard_binding {
+      box_hash {
+        alg: "some alg"
+        boxes { names: "some name" names: "other name" hash: "some hash" }
+      }
+    }
+    actions {
+      created {
+        action_metadata {
+          software_agent { name: "test generator" }
+          description: "created for testing"
+        }
+        digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture"
+      }
+    }
+    gathered_assertions {
+      label: "com.example.gathered"
+      data: "{\"test_key\": \"test_val\"}"
+      content_type: CONTENT_TYPE_JSON
+    }
+  )pb");
+  absl::StatusOr<GenerateManifestStoreResponse> generated =
+      GenerateManifestStore(*store_generator_, request);
+  ABSL_ASSERT_OK(generated);
+  EXPECT_TRUE(generated->is_timestamped());
+  absl::StatusOr<Claim> claim = ValidateClaim(generated->manifest_store());
+  ABSL_ASSERT_OK(claim);
+  ASSERT_EQ(claim->gathered_assertions_size(), 1);
+  EXPECT_EQ(claim->gathered_assertions(0).url(),
+            "self#jumbf=c2pa.assertions/com.example.gathered");
+}
+
+TEST_F(ManifestStoreGeneratorTest,
+       GenerateTemplateAndFinalizeWithGatheredAssertions) {
+  DataHashAssertion data_hash;
+  data_hash.set_hash(std::string(HashSizeBytes(HashAlgorithm::kSha256), '\0'));
+  data_hash.set_pad("");
+  GenerateManifestStoreRequest request;
+  *request.mutable_hard_binding()->mutable_data_hash() = data_hash;
+  *request.mutable_actions() = ParseTextProtoOrDie(R"pb(
+    created {
+      action_metadata {
+        software_agent { name: "test generator" }
+        description: "created for testing"
+      }
+      digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture"
+    }
+  )pb");
+  GatheredAssertion* gathered = request.add_gathered_assertions();
+  gathered->set_label("com.example.gathered");
+  gathered->set_data("{\"test_key\": \"test_val\"}");
+  gathered->set_content_type(GatheredAssertion::CONTENT_TYPE_JSON);
+
+  // Step 1: Generate template.
+  absl::StatusOr<ManifestStoreTemplate> manifest_store_template =
+      GenerateManifestStoreTemplate(*store_generator_, request);
+  ABSL_ASSERT_OK(manifest_store_template);
+
+  // Step 2: Update hash.
+  std::string new_hash(HashSizeBytes(HashAlgorithm::kSha256), '\1');
+  data_hash.set_hash(new_hash);
+
+  // Step 3: Finalize manifest store.
+  FinalizeManifestStoreRequest finalize_request;
+  *finalize_request.mutable_manifest_store_template() =
+      *manifest_store_template;
+  *finalize_request.mutable_hard_binding()->mutable_data_hash() = data_hash;
+  absl::StatusOr<FinalizeManifestStoreResponse> finalized =
+      FinalizeManifestStore(*store_generator_, finalize_request);
+  ABSL_ASSERT_OK(finalized);
+  EXPECT_TRUE(finalized->is_timestamped());
+
+  EXPECT_EQ(manifest_store_template->manifest_store_size(),
+            finalized->manifest_store().size());
+  absl::StatusOr<Claim> claim = ValidateClaim(finalized->manifest_store());
+  ABSL_ASSERT_OK(claim);
+  ASSERT_EQ(claim->gathered_assertions_size(), 1);
+  EXPECT_EQ(claim->gathered_assertions(0).url(),
+            "self#jumbf=c2pa.assertions/com.example.gathered");
+}
+
+TEST_F(ManifestStoreGeneratorTest, GenerateWithC2paOpenedAction) {
+  SimpleManifestStoreOptions options;
+  options.uuid_generator = &uuid_generator_;
+  absl::StatusOr<std::string> parent_manifest_store_bytes =
+      CreateSimpleManifestStoreBytes(options);
+  ABSL_ASSERT_OK(parent_manifest_store_bytes);
+  GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb(
+    instance_id: "12345678-1234-1234-1234-123456789012"
+    hard_binding {
+      box_hash {
+        alg: "some alg"
+        boxes { names: "some name" names: "other name" hash: "some hash" }
+      }
+    }
+    actions {
+      opened {
+        action_metadata {
+          software_agent { name: "test generator" }
+          description: "opened for testing"
+        }
+        ingredient_index: 0
+      }
+    }
+    ingredients {
+      metadata { title: "test ingredient" }
+      ingredient_validation_results {
+        manifest_store_bytes: ""
+        ingredient_validation_results {
+          active_manifest { successes { code: "assertion.dataHash.match" } }
+        }
+      }
+    }
+  )pb");
+  request.mutable_ingredients(0)
+      ->mutable_ingredient_validation_results()
+      ->set_manifest_store_bytes(*parent_manifest_store_bytes);
+  PartialValidationResultProto expected_validated_result = ParseTextProtoOrDie(
+      R"pb(
+        active_manifest {
+          label: "urn:c2pa:12345678-1234-1234-1234-123456789012"
+          claim {
+            claim_generator_info {
+              name: "Test generator"
+              spec_version: "2.2.0"
+            }
+            signature_info {
+              cert_serial_number: "02"
+              issuer: "OU=Test Unit,O=Test Org,CN=Test Claim Signer"
+              certificate_issuer: "OU=Test Unit,O=Test Org,CN=Test Root CA"
+              timestamping_time {}
+            }
+            created_assertions {
+              url: "self#jumbf=c2pa.assertions/c2pa.hash.boxes"
+              algorithm: "sha256"
+            }
+            created_assertions {
+              url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3"
+              algorithm: "sha256"
+            }
+            created_assertions {
+              url: "self#jumbf=c2pa.assertions/c2pa.actions.v2"
+              algorithm: "sha256"
+            }
+            default_algorithm: "sha256"
+            label: "c2pa.claim.v2"
+            instance_id: "12345678-1234-1234-1234-123456789012"
+          }
+          assertions {
+            label: "c2pa.hash.boxes"
+            boxes_hash {
+              boxes { names: "some name" names: "other name" hash: "some hash" }
+              alg: "some alg"
+            }
+          }
+          assertions {
+            label: "c2pa.ingredient.v3"
+            ingredient_v3 {
+              title: "test ingredient"
+              relationship: "parentOf"
+              active_manifest {
+                url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a"
+              }
+              claim_signature {
+                url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature"
+              }
+              validation_results {
+                active_manifest {
+                  successes { code: "assertion.dataHash.match" }
+                }
+              }
+            }
+          }
+          assertions {
+            label: "c2pa.actions.v2"
+            actions {
+              actions {
+                action: "c2pa.opened"
+                description: "opened for testing"
+                software_agent { name: "test generator" }
+                parameters {
+                  ingredients {
+                    url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3"
+                  }
+                }
+              }
+            }
+          }
+          validation {
+            successes {
+              code: "timeStamp.validated"
+              url: "self#jumbf=/c2pa/urn:c2pa:12345678-1234-1234-1234-123456789012/c2pa.signature"
+            }
+            successes {
+              code: "timeStamp.trusted"
+              url: "self#jumbf=/c2pa/urn:c2pa:12345678-1234-1234-1234-123456789012/c2pa.signature"
+            }
+            successes {
+              code: "signingCredential.trusted"
+              url: "self#jumbf=/c2pa/urn:c2pa:12345678-1234-1234-1234-123456789012/c2pa.signature"
+            }
+            successes {
+              code: "claimSignature.insideValidity"
+              url: "self#jumbf=/c2pa/urn:c2pa:12345678-1234-1234-1234-123456789012/c2pa.signature"
+            }
+            successes {
+              code: "claimSignature.validated"
+              url: "self#jumbf=/c2pa/urn:c2pa:12345678-1234-1234-1234-123456789012/c2pa.signature"
+            }
+            successes {
+              code: "assertion.hashedURI.match"
+              url: "self#jumbf=/c2pa/urn:c2pa:12345678-1234-1234-1234-123456789012/c2pa.assertions/c2pa.hash.boxes"
+            }
+            successes {
+              code: "assertion.hashedURI.match"
+              url: "self#jumbf=/c2pa/urn:c2pa:12345678-1234-1234-1234-123456789012/c2pa.assertions/c2pa.ingredient.v3"
+            }
+            successes {
+              code: "assertion.hashedURI.match"
+              url: "self#jumbf=/c2pa/urn:c2pa:12345678-1234-1234-1234-123456789012/c2pa.assertions/c2pa.actions.v2"
+            }
+            successes {
+              code: "ingredient.claimSignature.validated"
+              url: "self#jumbf=/c2pa/urn:c2pa:12345678-1234-1234-1234-123456789012/c2pa.assertions/c2pa.ingredient.v3"
+            }
+            informationals {
+              code: "signingCredential.ocsp.skipped"
+              url: "self#jumbf=/c2pa/urn:c2pa:12345678-1234-1234-1234-123456789012/c2pa.signature"
+            }
+          }
+        }
+        ingredient_manifests {
+          label: "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a"
+          claim {
+            claim_generator_info { name: "Test C2PA Generator" }
+            signature_info {
+              cert_serial_number: "02"
+              issuer: "OU=Test Unit,O=Test Org,CN=Test Claim Signer"
+              certificate_issuer: "OU=Test Unit,O=Test Org,CN=Test Root CA"
+              timestamping_time {}
+            }
+            created_assertions {
+              url: "self#jumbf=c2pa.assertions/c2pa.hash.boxes"
+              algorithm: "sha256"
+            }
+            created_assertions {
+              url: "self#jumbf=c2pa.assertions/c2pa.actions.v2"
+              algorithm: "sha256"
+            }
+            default_algorithm: "sha256"
+            label: "c2pa.claim.v2"
+            instance_id: "12345678-1234-1234-1234-123456789012"
+          }
+          assertions {
+            label: "c2pa.hash.boxes"
+            boxes_hash {
+              boxes { names: "some name" names: "other name" hash: "some hash" }
+              alg: "some alg"
+            }
+          }
+          assertions {
+            label: "c2pa.actions.v2"
+            actions {
+              actions {
+                action: "c2pa.created"
+                description: "created for testing"
+                digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture"
+                software_agent { name: "test generator" }
+              }
+            }
+          }
+          validation {
+            successes {
+              code: "timeStamp.validated"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature"
+            }
+            successes {
+              code: "timeStamp.trusted"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature"
+            }
+            successes {
+              code: "signingCredential.trusted"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature"
+            }
+            successes {
+              code: "claimSignature.insideValidity"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature"
+            }
+            successes {
+              code: "claimSignature.validated"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature"
+            }
+            successes {
+              code: "assertion.hashedURI.match"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.hash.boxes"
+            }
+            successes {
+              code: "assertion.hashedURI.match"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.actions.v2"
+            }
+            informationals {
+              code: "signingCredential.ocsp.skipped"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature"
+            }
+          }
+        }
+        hard_binding_uri: "self#jumbf=/c2pa/urn:c2pa:12345678-1234-1234-1234-123456789012/c2pa.assertions/c2pa.hash.boxes"
+      )pb");
+  absl::StatusOr<GenerateManifestStoreResponse> generated =
+      GenerateManifestStore(*store_generator_, request);
+  ABSL_ASSERT_OK(generated);
+  absl::StatusOr<std::unique_ptr<PartialValidationResultProto>> validated =
+      ValidateManifestStore(generated->manifest_store());
+  ABSL_ASSERT_OK(validated);
+  EXPECT_THAT(**validated, EqualsProtoIgnoringHashTimestampAndOrdering(
+                               expected_validated_result));
+}
+
+TEST_F(ManifestStoreGeneratorTest,
+       GenerateC2paOpenedActionMissingManifestStoreBytes) {
+  GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb(
+    instance_id: "03020100-0504-0706-0908-0f0e0d0c0b0a"
+    hard_binding {
+      box_hash {
+        alg: "some alg"
+        boxes { names: "some name" names: "other name" hash: "some hash" }
+      }
+    }
+    actions {
+      opened {
+        action_metadata {
+          software_agent { name: "test generator" }
+          description: "opened for testing"
+        }
+        ingredient_index: 0
+      }
+    }
+    ingredients {
+      metadata { title: "test ingredient" }
+      ingredient_validation_results {}
+    }
+  )pb");
+  PartialValidationResultProto expected_validated_result = ParseTextProtoOrDie(
+      R"pb(
+        active_manifest {
+          label: "urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a"
+          claim {
+            claim_generator_info {
+              name: "Test generator"
+              spec_version: "2.2.0"
+            }
+            signature_info {
+              cert_serial_number: "02"
+              issuer: "OU=Test Unit,O=Test Org,CN=Test Claim Signer"
+              certificate_issuer: "OU=Test Unit,O=Test Org,CN=Test Root CA"
+              timestamping_time {}
+            }
+            created_assertions {
+              url: "self#jumbf=c2pa.assertions/c2pa.hash.boxes"
+              algorithm: "sha256"
+            }
+            created_assertions {
+              url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3"
+              algorithm: "sha256"
+            }
+            created_assertions {
+              url: "self#jumbf=c2pa.assertions/c2pa.actions.v2"
+              algorithm: "sha256"
+            }
+            default_algorithm: "sha256"
+            label: "c2pa.claim.v2"
+            instance_id: "03020100-0504-0706-0908-0f0e0d0c0b0a"
+          }
+          assertions {
+            label: "c2pa.hash.boxes"
+            boxes_hash {
+              boxes { names: "some name" names: "other name" hash: "some hash" }
+              alg: "some alg"
+            }
+          }
+          assertions {
+            label: "c2pa.ingredient.v3"
+            ingredient_v3 { title: "test ingredient" relationship: "parentOf" }
+          }
+          assertions {
+            label: "c2pa.actions.v2"
+            actions {
+              actions {
+                action: "c2pa.opened"
+                description: "opened for testing"
+                software_agent { name: "test generator" }
+                parameters {
+                  ingredients {
+                    url: "self#jumbf=c2pa.assertions/c2pa.ingredient.v3"
+                  }
+                }
+              }
+            }
+          }
+          validation {
+            successes {
+              code: "timeStamp.validated"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature"
+            }
+            successes {
+              code: "timeStamp.trusted"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature"
+            }
+            successes {
+              code: "signingCredential.trusted"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature"
+            }
+            successes {
+              code: "claimSignature.insideValidity"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature"
+            }
+            successes {
+              code: "claimSignature.validated"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature"
+            }
+            successes {
+              code: "assertion.hashedURI.match"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.hash.boxes"
+            }
+            successes {
+              code: "assertion.hashedURI.match"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.ingredient.v3"
+            }
+            successes {
+              code: "assertion.hashedURI.match"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.actions.v2"
+            }
+            informationals {
+              code: "signingCredential.ocsp.skipped"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.signature"
+            }
+            informationals {
+              code: "ingredient.unknownProvenance"
+              url: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.ingredient.v3"
+            }
+          }
+        }
+        hard_binding_uri: "self#jumbf=/c2pa/urn:c2pa:03020100-0504-0706-0908-0f0e0d0c0b0a/c2pa.assertions/c2pa.hash.boxes"
+      )pb");
+  absl::StatusOr<GenerateManifestStoreResponse> generated =
+      GenerateManifestStore(*store_generator_, request);
+  ABSL_ASSERT_OK(generated);
+  absl::StatusOr<std::unique_ptr<PartialValidationResultProto>> validated =
+      ValidateManifestStore(generated->manifest_store());
+  ABSL_ASSERT_OK(validated);
+  EXPECT_THAT(**validated, EqualsProtoIgnoringHashTimestampAndOrdering(
+                               expected_validated_result));
+}
+
+TEST_F(ManifestStoreGeneratorTest,
+       GenerateC2paOpenedActionMissingValidationResultsInvalid) {
+  GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb(
+    hard_binding {
+      box_hash {
+        alg: "some alg"
+        boxes { names: "some name" names: "other name" hash: "some hash" }
+      }
+    }
+    actions {
+      opened {
+        action_metadata {
+          software_agent { name: "test generator" }
+          description: "opened for testing"
+        }
+        ingredient_index: 0
+      }
+    }
+    ingredients {
+      metadata { title: "test ingredient" }
+      ingredient_validation_results {
+        manifest_store_bytes: "test manifest store bytes"
+      }
+    }
+  )pb");
+  EXPECT_THAT(GenerateManifestStore(*store_generator_, request),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("validation results cannot be empty")));
+}
+
+TEST_F(ManifestStoreGeneratorTest, GenerateMissingHardBindingInvalid) {
+  GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb(
+    actions {
+      created {
+        action_metadata {
+          software_agent { name: "test generator" }
+          description: "created for testing"
+        }
+        digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture"
+      }
+    }
+  )pb");
+  EXPECT_THAT(
+      GenerateManifestStore(*store_generator_, request),
+      StatusIs(absl::StatusCode::kInvalidArgument, HasSubstr("hard binding")));
+}
+
+TEST_F(ManifestStoreGeneratorTest, GenerateMissingStartActionInvalid) {
+  GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb(
+    hard_binding {
+      box_hash {
+        alg: "some alg"
+        boxes { names: "some name" names: "other name" hash: "some hash" }
+      }
+    }
+  )pb");
+  EXPECT_THAT(GenerateManifestStore(*store_generator_, request),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("missing inception action")));
+}
+
+TEST_F(ManifestStoreGeneratorTest, GenerateTemplateWithBmffBasedHash) {
+  BmffBasedHashAssertion bmff_based_hash =
+      GetBmffHashTemplate(HashAlgorithm::kSha256);
+  GenerateManifestStoreRequest request;
+  *request.mutable_hard_binding()->mutable_bmff_based_hash() = bmff_based_hash;
+  *request.mutable_actions() = ParseTextProtoOrDie(R"pb(
+    created {
+      action_metadata {
+        software_agent { name: "test generator" }
+        description: "created for testing"
+      }
+      digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture"
+    }
+  )pb");
+  // Step 1: Generate template.
+  absl::StatusOr<ManifestStoreTemplate> manifest_store_template =
+      GenerateManifestStoreTemplate(*store_generator_, request);
+  ABSL_ASSERT_OK(manifest_store_template);
+
+  // Step 2: Update hash.
+  std::string new_hash(HashSizeBytes(HashAlgorithm::kSha256), '\1');
+  bmff_based_hash.set_hash(new_hash);
+
+  // Step 3: Finalize manifest store.
+  FinalizeManifestStoreRequest finalize_request;
+  *finalize_request.mutable_manifest_store_template() =
+      *manifest_store_template;
+  *finalize_request.mutable_hard_binding()->mutable_bmff_based_hash() =
+      bmff_based_hash;
+  absl::StatusOr<FinalizeManifestStoreResponse> finalized =
+      FinalizeManifestStore(*store_generator_, finalize_request);
+  ABSL_ASSERT_OK(finalized);
+  EXPECT_TRUE(finalized->is_timestamped());
+
+  EXPECT_EQ(manifest_store_template->manifest_store_size(),
+            finalized->manifest_store().size());
+  EXPECT_THAT(ValidateManifestStore(finalized->manifest_store()), IsOk());
+  EXPECT_THAT(finalized->manifest_store(), HasSubstr(new_hash));
+};
+
+TEST_F(ManifestStoreGeneratorTest, GenerateTemplateWithDataHash) {
+  DataHashAssertion data_hash;
+  data_hash.set_hash(std::string(HashSizeBytes(HashAlgorithm::kSha256), '\0'));
+  data_hash.set_pad("");
+  GenerateManifestStoreRequest request;
+  *request.mutable_hard_binding()->mutable_data_hash() = data_hash;
+  *request.mutable_actions() = ParseTextProtoOrDie(R"pb(
+    created {
+      action_metadata {
+        software_agent { name: "test generator" }
+        description: "created for testing"
+      }
+      digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture"
+    }
+  )pb");
+  // Step 1: Generate template.
+  absl::StatusOr<ManifestStoreTemplate> manifest_store_template =
+      GenerateManifestStoreTemplate(*store_generator_, request);
+  ABSL_ASSERT_OK(manifest_store_template);
+
+  // Step 2: Update hash.
+  std::string new_hash(HashSizeBytes(HashAlgorithm::kSha256), '\1');
+  data_hash.set_hash(new_hash);
+
+  // Step 3: Finalize manifest store.
+  FinalizeManifestStoreRequest finalize_request;
+  *finalize_request.mutable_manifest_store_template() =
+      *manifest_store_template;
+  *finalize_request.mutable_hard_binding()->mutable_data_hash() = data_hash;
+  absl::StatusOr<FinalizeManifestStoreResponse> finalized =
+      FinalizeManifestStore(*store_generator_, finalize_request);
+  ABSL_ASSERT_OK(finalized);
+  EXPECT_TRUE(finalized->is_timestamped());
+
+  EXPECT_EQ(manifest_store_template->manifest_store_size(),
+            finalized->manifest_store().size());
+  EXPECT_THAT(ValidateManifestStore(finalized->manifest_store()), IsOk());
+  EXPECT_THAT(finalized->manifest_store(), HasSubstr(new_hash));
+};
+
+TEST_F(ManifestStoreGeneratorTest, GenerateTemplateWithBoxesHash) {
+  BoxesHashAssertion box_hash;
+  BoxHash* box = box_hash.add_boxes();
+  box->add_names("some name");
+  box->set_hash(std::string(HashSizeBytes(HashAlgorithm::kSha256), '\0'));
+  GenerateManifestStoreRequest request;
+  *request.mutable_hard_binding()->mutable_box_hash() = box_hash;
+  *request.mutable_actions() = ParseTextProtoOrDie(R"pb(
+    created {
+      action_metadata {
+        software_agent { name: "test generator" }
+        description: "created for testing"
+      }
+      digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture"
+    }
+  )pb");
+  // Step 1: Generate template.
+  absl::StatusOr<ManifestStoreTemplate> manifest_store_template =
+      GenerateManifestStoreTemplate(*store_generator_, request);
+  ABSL_ASSERT_OK(manifest_store_template);
+
+  // Step 2: Update boxes hash.
+  std::string new_hash(HashSizeBytes(HashAlgorithm::kSha256), '\1');
+  box_hash.mutable_boxes(0)->set_hash(new_hash);
+
+  // Step 3: Finalize manifest store.
+  FinalizeManifestStoreRequest finalize_request;
+  *finalize_request.mutable_manifest_store_template() =
+      *manifest_store_template;
+  *finalize_request.mutable_hard_binding()->mutable_box_hash() = box_hash;
+  absl::StatusOr<FinalizeManifestStoreResponse> finalized =
+      FinalizeManifestStore(*store_generator_, finalize_request);
+  ABSL_ASSERT_OK(finalized);
+  EXPECT_TRUE(finalized->is_timestamped());
+
+  EXPECT_EQ(manifest_store_template->manifest_store_size(),
+            finalized->manifest_store().size());
+  EXPECT_THAT(ValidateManifestStore(finalized->manifest_store()), IsOk());
+  EXPECT_THAT(finalized->manifest_store(), HasSubstr(new_hash));
+};
+
+TEST_F(ManifestStoreGeneratorTest, GenerateTemplateWithCollectionDataHash) {
+  CollectionDataHashAssertion collection_data_hash;
+  collection_data_hash.set_zip_central_directory_hash(
+      std::string(HashSizeBytes(HashAlgorithm::kSha256), '\0'));
+  collection_data_hash.set_alg("sha256");
+  UriHashedData* uri_hash = collection_data_hash.add_uris();
+  uri_hash->set_uri("some uri");
+  uri_hash->set_type("some type");
+  uri_hash->set_hash(std::string(HashSizeBytes(HashAlgorithm::kSha256), '\0'));
+
+  GenerateManifestStoreRequest request;
+  *request.mutable_hard_binding()->mutable_collection_data_hash() =
+      collection_data_hash;
+  *request.mutable_actions() = ParseTextProtoOrDie(R"pb(
+    created {
+      action_metadata {
+        software_agent { name: "test generator" }
+        description: "created for testing"
+      }
+      digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture"
+    }
+  )pb");
+  // Step 1: Generate template.
+  absl::StatusOr<ManifestStoreTemplate> manifest_store_template =
+      GenerateManifestStoreTemplate(*store_generator_, request);
+  ABSL_ASSERT_OK(manifest_store_template);
+
+  // Step 2: Update hash.
+  std::string new_central_dir_hash(HashSizeBytes(HashAlgorithm::kSha256), '\1');
+  collection_data_hash.set_zip_central_directory_hash(new_central_dir_hash);
+  std::string new_uri_hash(HashSizeBytes(HashAlgorithm::kSha256), '\2');
+  collection_data_hash.mutable_uris(0)->set_hash(new_uri_hash);
+
+  // Step 3: Finalize manifest store.
+  FinalizeManifestStoreRequest finalize_request;
+  *finalize_request.mutable_manifest_store_template() =
+      *manifest_store_template;
+  *finalize_request.mutable_hard_binding()->mutable_collection_data_hash() =
+      collection_data_hash;
+  absl::StatusOr<FinalizeManifestStoreResponse> finalized =
+      FinalizeManifestStore(*store_generator_, finalize_request);
+  ABSL_ASSERT_OK(finalized);
+  EXPECT_TRUE(finalized->is_timestamped());
+
+  EXPECT_EQ(manifest_store_template->manifest_store_size(),
+            finalized->manifest_store().size());
+  EXPECT_THAT(ValidateManifestStore(finalized->manifest_store()), IsOk());
+  EXPECT_THAT(finalized->manifest_store(), HasSubstr(new_central_dir_hash));
+  EXPECT_THAT(finalized->manifest_store(), HasSubstr(new_uri_hash));
+};
+
+TEST_F(ManifestStoreGeneratorTest, EmptyGeneratorContext) {
+  BmffBasedHashAssertion bmff_based_hash =
+      GetBmffHashTemplate(HashAlgorithm::kSha256);
+  FinalizeManifestStoreRequest finalize_request;
+  *finalize_request.mutable_hard_binding()->mutable_bmff_based_hash() =
+      bmff_based_hash;
+  EXPECT_THAT(FinalizeManifestStore(*store_generator_, finalize_request),
+              StatusIs(absl::StatusCode::kInvalidArgument, HasSubstr("empty")));
+}
+
+TEST_F(ManifestStoreGeneratorTest, FailParsingGeneratorContext) {
+  BmffBasedHashAssertion bmff_based_hash =
+      GetBmffHashTemplate(HashAlgorithm::kSha256);
+  FinalizeManifestStoreRequest finalize_request;
+  finalize_request.mutable_manifest_store_template()->set_generator_context(
+      "invalid generator context");
+  *finalize_request.mutable_hard_binding()->mutable_bmff_based_hash() =
+      bmff_based_hash;
+  EXPECT_THAT(FinalizeManifestStore(*store_generator_, finalize_request),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("Failed to parse the generator_context")));
+}
+
+TEST_F(ManifestStoreGeneratorTest, HashBindingMissing) {
+  DataHashAssertion data_hash;
+  data_hash.set_hash(std::string(HashSizeBytes(HashAlgorithm::kSha256), '\0'));
+  data_hash.set_pad("");
+  GenerateManifestStoreRequest request;
+  *request.mutable_hard_binding()->mutable_data_hash() = data_hash;
+  *request.mutable_actions() = ParseTextProtoOrDie(R"pb(
+    created {
+      action_metadata {
+        software_agent { name: "test generator" }
+        description: "created for testing"
+      }
+      digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture"
+    }
+  )pb");
+  absl::StatusOr<ManifestStoreTemplate> manifest_store_template =
+      GenerateManifestStoreTemplate(*store_generator_, request);
+  ABSL_ASSERT_OK(manifest_store_template);
+  FinalizeManifestStoreRequest finalize_request;
+  *finalize_request.mutable_manifest_store_template() =
+      *manifest_store_template;
+  EXPECT_THAT(
+      FinalizeManifestStore(*store_generator_, finalize_request),
+      StatusIs(absl::StatusCode::kInvalidArgument, HasSubstr("hard binding")));
+}
+
+TEST_F(ManifestStoreGeneratorTest, HashBindingMismatchDataHash) {
+  DataHashAssertion data_hash;
+  data_hash.set_hash(std::string(HashSizeBytes(HashAlgorithm::kSha256), '\0'));
+  data_hash.set_pad("");
+  GenerateManifestStoreRequest request;
+  *request.mutable_hard_binding()->mutable_data_hash() = data_hash;
+  *request.mutable_actions() = ParseTextProtoOrDie(R"pb(
+    created {
+      action_metadata {
+        software_agent { name: "test generator" }
+        description: "created for testing"
+      }
+      digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture"
+    }
+  )pb");
+  absl::StatusOr<ManifestStoreTemplate> manifest_store_template =
+      GenerateManifestStoreTemplate(*store_generator_, request);
+  ABSL_ASSERT_OK(manifest_store_template);
+  FinalizeManifestStoreRequest finalize_request;
+  *finalize_request.mutable_manifest_store_template() =
+      *manifest_store_template;
+  *finalize_request.mutable_hard_binding()->mutable_box_hash() =
+      ParseTextProtoOrDie(R"pb(
+        boxes { hash: "some hash" }
+      )pb");
+  EXPECT_THAT(FinalizeManifestStore(*store_generator_, finalize_request),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("The hard binding in the finalize request "
+                                 "must match the original request")));
+}
+
+TEST_F(ManifestStoreGeneratorTest, HashBindingMismatchBoxesHash) {
+  BoxesHashAssertion box_hash;
+  BoxHash* box = box_hash.add_boxes();
+  box->add_names("some name");
+  box->set_hash(std::string(HashSizeBytes(HashAlgorithm::kSha256), '\0'));
+  GenerateManifestStoreRequest request;
+  *request.mutable_hard_binding()->mutable_box_hash() = box_hash;
+  *request.mutable_actions() = ParseTextProtoOrDie(R"pb(
+    created {
+      action_metadata {
+        software_agent { name: "test generator" }
+        description: "created for testing"
+      }
+      digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture"
+    }
+  )pb");
+  absl::StatusOr<ManifestStoreTemplate> manifest_store_template =
+      GenerateManifestStoreTemplate(*store_generator_, request);
+  ABSL_ASSERT_OK(manifest_store_template);
+  FinalizeManifestStoreRequest finalize_request;
+  *finalize_request.mutable_manifest_store_template() =
+      *manifest_store_template;
+  *finalize_request.mutable_hard_binding()->mutable_data_hash() =
+      ParseTextProtoOrDie(R"pb(
+        hash: "some hash"
+      )pb");
+  EXPECT_THAT(FinalizeManifestStore(*store_generator_, finalize_request),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("The hard binding in the finalize request "
+                                 "must match the original request")));
+}
+
+TEST_F(ManifestStoreGeneratorTest, HashBindingMismatchBmffBasedHash) {
+  BmffBasedHashAssertion bmff_based_hash =
+      GetBmffHashTemplate(HashAlgorithm::kSha256);
+  GenerateManifestStoreRequest request;
+  *request.mutable_hard_binding()->mutable_bmff_based_hash() = bmff_based_hash;
+  *request.mutable_actions() = ParseTextProtoOrDie(R"pb(
+    created {
+      action_metadata {
+        software_agent { name: "test generator" }
+        description: "created for testing"
+      }
+      digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture"
+    }
+  )pb");
+  absl::StatusOr<ManifestStoreTemplate> manifest_store_template =
+      GenerateManifestStoreTemplate(*store_generator_, request);
+  ABSL_ASSERT_OK(manifest_store_template);
+  FinalizeManifestStoreRequest finalize_request;
+  *finalize_request.mutable_manifest_store_template() =
+      *manifest_store_template;
+  *finalize_request.mutable_hard_binding()->mutable_data_hash() =
+      ParseTextProtoOrDie(R"pb(
+        hash: "some hash"
+      )pb");
+  EXPECT_THAT(FinalizeManifestStore(*store_generator_, finalize_request),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("The hard binding in the finalize request "
+                                 "must match the original request")));
+}
+
+TEST_F(ManifestStoreGeneratorTest, HashBindingMismatchCollectionDataHash) {
+  CollectionDataHashAssertion collection_data_hash = ParseTextProtoOrDie(R"pb(
+    uris { uri: "some uri" hash: "some hash" type: "some type" }
+    alg: "sha256"
+    zip_central_directory_hash: "some hash"
+  )pb");
+  GenerateManifestStoreRequest request;
+  *request.mutable_hard_binding()->mutable_collection_data_hash() =
+      collection_data_hash;
+  *request.mutable_actions() = ParseTextProtoOrDie(R"pb(
+    created {
+      action_metadata {
+        software_agent { name: "test generator" }
+        description: "created for testing"
+      }
+      digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture"
+    }
+  )pb");
+  absl::StatusOr<ManifestStoreTemplate> manifest_store_template =
+      GenerateManifestStoreTemplate(*store_generator_, request);
+  ABSL_ASSERT_OK(manifest_store_template);
+  FinalizeManifestStoreRequest finalize_request;
+  *finalize_request.mutable_manifest_store_template() =
+      *manifest_store_template;
+  *finalize_request.mutable_hard_binding()->mutable_data_hash() =
+      ParseTextProtoOrDie(R"pb(
+        hash: "some hash"
+      )pb");
+  EXPECT_THAT(FinalizeManifestStore(*store_generator_, finalize_request),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("The hard binding in the finalize request "
+                                 "must match the original request")));
+}
+
+TEST_F(ManifestStoreGeneratorTest, DataHashByteSizeAlwaysMatch) {
+  DataHashAssertion data_hash;
+  data_hash.set_hash(std::string(HashSizeBytes(HashAlgorithm::kSha256), '\0'));
+  data_hash.set_pad("");
+  DataRange* exclusion = data_hash.add_exclusions();
+  exclusion->set_start(0);
+  exclusion->set_length(0);
+  GenerateManifestStoreRequest request;
+  *request.mutable_hard_binding()->mutable_data_hash() = data_hash;
+  *request.mutable_actions() = ParseTextProtoOrDie(R"pb(
+    created {
+      action_metadata {
+        software_agent { name: "test generator" }
+        description: "created for testing"
+      }
+      digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture"
+    }
+  )pb");
+  absl::StatusOr<ManifestStoreTemplate> manifest_store_template =
+      GenerateManifestStoreTemplate(*store_generator_, request);
+  ABSL_ASSERT_OK(manifest_store_template);
+  FinalizeManifestStoreRequest finalize_request;
+  *finalize_request.mutable_manifest_store_template() =
+      *manifest_store_template;
+  data_hash.set_pad("additional padding");
+  data_hash.mutable_exclusions(0)->set_start(10);
+  data_hash.mutable_exclusions(0)->set_length(4294967296);
+  *finalize_request.mutable_hard_binding()->mutable_data_hash() = data_hash;
+  absl::StatusOr<FinalizeManifestStoreResponse> generated =
+      FinalizeManifestStore(*store_generator_, finalize_request);
+  ABSL_ASSERT_OK(generated);
+  EXPECT_THAT(generated->manifest_store(),
+              Not(HasSubstr("additional padding")));
+}
+
+TEST_F(ManifestStoreGeneratorTest, DataHashByteSizeMismatchDueToDifferentName) {
+  DataHashAssertion data_hash;
+  data_hash.set_hash(std::string(HashSizeBytes(HashAlgorithm::kSha256), '\0'));
+  data_hash.set_pad("");
+  DataRange* exclusion = data_hash.add_exclusions();
+  exclusion->set_start(0);
+  exclusion->set_length(0);
+  GenerateManifestStoreRequest request;
+  *request.mutable_hard_binding()->mutable_data_hash() = data_hash;
+  *request.mutable_actions() = ParseTextProtoOrDie(R"pb(
+    created {
+      action_metadata {
+        software_agent { name: "test generator" }
+        description: "created for testing"
+      }
+      digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture"
+    }
+  )pb");
+  absl::StatusOr<ManifestStoreTemplate> manifest_store_template =
+      GenerateManifestStoreTemplate(*store_generator_, request);
+  ABSL_ASSERT_OK(manifest_store_template);
+  FinalizeManifestStoreRequest finalize_request;
+  *finalize_request.mutable_manifest_store_template() =
+      *manifest_store_template;
+  data_hash.clear_pad();  // Padding should add it back.
+  data_hash.mutable_exclusions(0)->set_start(10);
+  data_hash.mutable_exclusions(0)->set_length(4294967296);
+  data_hash.set_name("additional name");
+  *finalize_request.mutable_hard_binding()->mutable_data_hash() = data_hash;
+  EXPECT_THAT(FinalizeManifestStore(*store_generator_, finalize_request),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("The final hard binding differs from template "
+                                 "in unsupported ways")));
+}
+
+TEST_F(ManifestStoreGeneratorTest, BoxesHashByteSizeMismatch) {
+  BoxesHashAssertion box_hash;
+  BoxHash* box = box_hash.add_boxes();
+  box->add_names("some name");
+  box->set_hash(std::string(HashSizeBytes(HashAlgorithm::kSha256), '\0'));
+  GenerateManifestStoreRequest request;
+  *request.mutable_hard_binding()->mutable_box_hash() = box_hash;
+  *request.mutable_actions() = ParseTextProtoOrDie(R"pb(
+    created {
+      action_metadata {
+        software_agent { name: "test generator" }
+        description: "created for testing"
+      }
+      digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture"
+    }
+  )pb");
+  absl::StatusOr<ManifestStoreTemplate> manifest_store_template =
+      GenerateManifestStoreTemplate(*store_generator_, request);
+  ABSL_ASSERT_OK(manifest_store_template);
+  FinalizeManifestStoreRequest finalize_request;
+  *finalize_request.mutable_manifest_store_template() =
+      *manifest_store_template;
+  box_hash.mutable_boxes(0)->add_names("additional name");
+  *finalize_request.mutable_hard_binding()->mutable_box_hash() = box_hash;
+  EXPECT_THAT(FinalizeManifestStore(*store_generator_, finalize_request),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("The final hard binding differs from template "
+                                 "in unsupported ways")));
+}
+
+TEST_F(ManifestStoreGeneratorTest, BmffBasedHashByteSizeMismatch) {
+  BmffBasedHashAssertion bmff_based_hash =
+      GetBmffHashTemplate(HashAlgorithm::kSha256);
+  GenerateManifestStoreRequest request;
+  *request.mutable_hard_binding()->mutable_bmff_based_hash() = bmff_based_hash;
+  *request.mutable_actions() = ParseTextProtoOrDie(R"pb(
+    created {
+      action_metadata {
+        software_agent { name: "test generator" }
+        description: "created for testing"
+      }
+      digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture"
+    }
+  )pb");
+  absl::StatusOr<ManifestStoreTemplate> manifest_store_template =
+      GenerateManifestStoreTemplate(*store_generator_, request);
+  ABSL_ASSERT_OK(manifest_store_template);
+  FinalizeManifestStoreRequest finalize_request;
+  *finalize_request.mutable_manifest_store_template() =
+      *manifest_store_template;
+  bmff_based_hash.add_exclusions()->set_xpath("additional exclusion");
+  *finalize_request.mutable_hard_binding()->mutable_bmff_based_hash() =
+      bmff_based_hash;
+  EXPECT_THAT(FinalizeManifestStore(*store_generator_, finalize_request),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("The final hard binding differs from template "
+                                 "in unsupported ways")));
+}
+
+TEST_F(ManifestStoreGeneratorTest,
+       CollectionDataHashByteSizeMismatchDueToDifferentUriLength) {
+  CollectionDataHashAssertion collection_data_hash = ParseTextProtoOrDie(R"pb(
+    uris { uri: "some uri" hash: "some hash" type: "some type" }
+    alg: "sha256"
+    zip_central_directory_hash: "some hash"
+  )pb");
+  GenerateManifestStoreRequest request;
+  *request.mutable_hard_binding()->mutable_collection_data_hash() =
+      collection_data_hash;
+  *request.mutable_actions() = ParseTextProtoOrDie(R"pb(
+    created {
+      action_metadata {
+        software_agent { name: "test generator" }
+        description: "created for testing"
+      }
+      digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture"
+    }
+  )pb");
+  absl::StatusOr<ManifestStoreTemplate> manifest_store_template =
+      GenerateManifestStoreTemplate(*store_generator_, request);
+  ABSL_ASSERT_OK(manifest_store_template);
+  FinalizeManifestStoreRequest finalize_request;
+  *finalize_request.mutable_manifest_store_template() =
+      *manifest_store_template;
+  // The URI is different in length.
+  collection_data_hash.mutable_uris(0)->set_uri("updated uri");
+  *finalize_request.mutable_hard_binding()->mutable_collection_data_hash() =
+      collection_data_hash;
+  EXPECT_THAT(FinalizeManifestStore(*store_generator_, finalize_request),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("The final hard binding differs from template "
+                                 "in unsupported ways")));
+}
+
+TEST_F(ManifestStoreGeneratorTest,
+       CollectionDataHashByteSizeMismatchDueToDifferentUriType) {
+  CollectionDataHashAssertion collection_data_hash = ParseTextProtoOrDie(R"pb(
+    uris { uri: "some uri" hash: "some hash" type: "some type" }
+    alg: "sha256"
+    zip_central_directory_hash: "some hash"
+  )pb");
+  GenerateManifestStoreRequest request;
+  *request.mutable_hard_binding()->mutable_collection_data_hash() =
+      collection_data_hash;
+  *request.mutable_actions() = ParseTextProtoOrDie(R"pb(
+    created {
+      action_metadata {
+        software_agent { name: "test generator" }
+        description: "created for testing"
+      }
+      digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture"
+    }
+  )pb");
+  absl::StatusOr<ManifestStoreTemplate> manifest_store_template =
+      GenerateManifestStoreTemplate(*store_generator_, request);
+  ABSL_ASSERT_OK(manifest_store_template);
+  FinalizeManifestStoreRequest finalize_request;
+  *finalize_request.mutable_manifest_store_template() =
+      *manifest_store_template;
+  collection_data_hash.mutable_uris(0)->set_type("updated type");
+  *finalize_request.mutable_hard_binding()->mutable_collection_data_hash() =
+      collection_data_hash;
+  EXPECT_THAT(FinalizeManifestStore(*store_generator_, finalize_request),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("The final hard binding differs from template "
+                                 "in unsupported ways")));
+}
+
+TEST_F(ManifestStoreGeneratorTest,
+       CollectionDataHashByteSizeMismatchDueToDifferentUriCount) {
+  CollectionDataHashAssertion collection_data_hash = ParseTextProtoOrDie(R"pb(
+    uris { uri: "some uri" hash: "some hash" type: "some type" }
+    alg: "sha256"
+    zip_central_directory_hash: "some hash"
+  )pb");
+  GenerateManifestStoreRequest request;
+  *request.mutable_hard_binding()->mutable_collection_data_hash() =
+      collection_data_hash;
+  *request.mutable_actions() = ParseTextProtoOrDie(R"pb(
+    created {
+      action_metadata {
+        software_agent { name: "test generator" }
+        description: "created for testing"
+      }
+      digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture"
+    }
+  )pb");
+  absl::StatusOr<ManifestStoreTemplate> manifest_store_template =
+      GenerateManifestStoreTemplate(*store_generator_, request);
+  ABSL_ASSERT_OK(manifest_store_template);
+  FinalizeManifestStoreRequest finalize_request;
+  *finalize_request.mutable_manifest_store_template() =
+      *manifest_store_template;
+  UriHashedData* new_uri = collection_data_hash.add_uris();
+  new_uri->set_uri("new uri");
+  new_uri->set_hash("new hash");
+  new_uri->set_type("new type");
+  *finalize_request.mutable_hard_binding()->mutable_collection_data_hash() =
+      collection_data_hash;
+  EXPECT_THAT(FinalizeManifestStore(*store_generator_, finalize_request),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("The final hard binding differs from template "
+                                 "in unsupported ways")));
+}
+
+TEST_F(ManifestStoreGeneratorTest, PartBindingByteSizeMismatch) {
+  DataHashAssertion data_hash;
+  data_hash.set_hash(std::string(HashSizeBytes(HashAlgorithm::kSha256), '\0'));
+  data_hash.set_pad("");
+  GenerateManifestStoreRequest request;
+  *request.mutable_hard_binding()->mutable_data_hash() = data_hash;
+  *request.mutable_hard_binding()->add_part_bindings()->mutable_data_hash() =
+      data_hash;
+  *request.mutable_actions() = ParseTextProtoOrDie(R"pb(
+    created {
+      action_metadata {
+        software_agent { name: "test generator" }
+        description: "created for testing"
+      }
+      digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture"
+    }
+  )pb");
+  absl::StatusOr<ManifestStoreTemplate> manifest_store_template =
+      GenerateManifestStoreTemplate(*store_generator_, request);
+  ABSL_ASSERT_OK(manifest_store_template);
+  FinalizeManifestStoreRequest finalize_request;
+  *finalize_request.mutable_manifest_store_template() =
+      *manifest_store_template;
+  data_hash.set_name("additional name");
+  *finalize_request.mutable_hard_binding()->mutable_data_hash() =
+      request.hard_binding().data_hash();
+  *finalize_request.mutable_hard_binding()
+       ->add_part_bindings()
+       ->mutable_data_hash() = data_hash;
+  EXPECT_THAT(
+      FinalizeManifestStore(*store_generator_, finalize_request),
+      StatusIs(
+          absl::StatusCode::kInvalidArgument,
+          HasSubstr(
+              "The part binding differs from template in unsupported ways.")));
+}
+
+TEST_F(ManifestStoreGeneratorTest, PartBindingCountMismatch) {
+  DataHashAssertion data_hash;
+  data_hash.set_hash(std::string(HashSizeBytes(HashAlgorithm::kSha256), '\0'));
+  data_hash.set_pad("");
+  GenerateManifestStoreRequest request;
+  *request.mutable_hard_binding()->mutable_data_hash() = data_hash;
+  *request.mutable_hard_binding()->add_part_bindings()->mutable_data_hash() =
+      data_hash;
+  *request.mutable_actions() = ParseTextProtoOrDie(R"pb(
+    created {
+      action_metadata {
+        software_agent { name: "test generator" }
+        description: "created for testing"
+      }
+      digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture"
+    }
+  )pb");
+  absl::StatusOr<ManifestStoreTemplate> manifest_store_template =
+      GenerateManifestStoreTemplate(*store_generator_, request);
+  ABSL_ASSERT_OK(manifest_store_template);
+  FinalizeManifestStoreRequest finalize_request;
+  *finalize_request.mutable_manifest_store_template() =
+      *manifest_store_template;
+  *finalize_request.mutable_hard_binding()->mutable_data_hash() =
+      request.hard_binding().data_hash();
+  // Different number of part_bindings.
+  EXPECT_THAT(FinalizeManifestStore(*store_generator_, finalize_request),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("The number of part bindings differs between "
+                                 "template and finalize.")));
+}
+
+TEST_F(ManifestStoreGeneratorTest, HardBindingNotSet) {
+  // Create a template with no hard binding in the generate request.
+  GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb(
+    actions {
+      created {
+        action_metadata {
+          software_agent { name: "test generator" }
+          description: "created for testing"
+        }
+        digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture"
+      }
+    }
+  )pb");
+  // Generate a template (this will fail as hard_binding is required).
+  EXPECT_THAT(
+      GenerateManifestStoreTemplate(*store_generator_, request),
+      StatusIs(absl::StatusCode::kInvalidArgument, HasSubstr("hard binding")));
+
+  // To test the BINDING_NOT_SET case in FinalizeManifestStore, we need to
+  // manually create a generator_context with no hard binding.
+  GenerateManifestStoreRequest template_request = request;
+  template_request.clear_hard_binding();
+  FinalizeManifestStoreRequest finalize_request;
+  finalize_request.mutable_manifest_store_template()->set_generator_context(
+      template_request.SerializeAsString());
+  *finalize_request.mutable_hard_binding() = HardBinding();
+  EXPECT_THAT(
+      FinalizeManifestStore(*store_generator_, finalize_request),
+      StatusIs(
+          absl::StatusCode::kInvalidArgument,
+          HasSubstr("The hard binding in the finalize request must be set")));
+}
+
+TEST_F(ManifestStoreGeneratorTest, ManifestStoreSizeLimitExceeded) {
+  GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb(
+    instance_id: "12345678-1234-1234-1234-123456789012"
+    hard_binding {
+      box_hash {
+        alg: "some alg"
+        boxes { names: "some name" names: "other name" hash: "some hash" }
+      }
+    }
+    actions {
+      created {
+        action_metadata {
+          software_agent { name: "test generator" }
+          description: "created for testing"
+        }
+        digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture"
+      }
+    }
+    options { max_manifest_store_size_bytes: 1000 }
+  )pb");
+  EXPECT_THAT(GenerateManifestStore(*store_generator_, request),
+              StatusIs(absl::StatusCode::kFailedPrecondition,
+                       HasSubstr("manifest store too large")));
+}
+
+TEST_F(ManifestStoreGeneratorTest, ManifestStoreSizeLimitNotExceeded) {
+  GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb(
+    instance_id: "12345678-1234-1234-1234-123456789012"
+    hard_binding {
+      box_hash {
+        alg: "some alg"
+        boxes { names: "some name" names: "other name" hash: "some hash" }
+      }
+    }
+    actions {
+      created {
+        action_metadata {
+          software_agent { name: "test generator" }
+          description: "created for testing"
+        }
+        digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture"
+      }
+    }
+    options { max_manifest_store_size_bytes: 16384 }
+  )pb");
+  ABSL_EXPECT_OK(GenerateManifestStore(*store_generator_, request));
+}
+
+TEST_F(ManifestStoreGeneratorTest, PartBindingBmffBasedHashSizeMismatch) {
+  DataHashAssertion data_hash;
+  data_hash.set_hash(std::string(HashSizeBytes(HashAlgorithm::kSha256), '\0'));
+  data_hash.set_pad("");
+  GenerateManifestStoreRequest request;
+  *request.mutable_hard_binding()->mutable_data_hash() = data_hash;
+  BmffBasedHashAssertion bmff_based_hash =
+      GetBmffHashTemplate(HashAlgorithm::kSha256);
+  *request.mutable_hard_binding()
+       ->add_part_bindings()
+       ->mutable_bmff_based_hash() = bmff_based_hash;
+  *request.mutable_actions() = ParseTextProtoOrDie(R"pb(
+    created {
+      action_metadata {
+        software_agent { name: "test generator" }
+        description: "created for testing"
+      }
+      digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture"
+    }
+  )pb");
+  absl::StatusOr<ManifestStoreTemplate> manifest_store_template =
+      GenerateManifestStoreTemplate(*store_generator_, request);
+  ABSL_ASSERT_OK(manifest_store_template);
+  FinalizeManifestStoreRequest finalize_request;
+  *finalize_request.mutable_manifest_store_template() =
+      *manifest_store_template;
+  *finalize_request.mutable_hard_binding()->mutable_data_hash() =
+      request.hard_binding().data_hash();
+  bmff_based_hash.add_exclusions()->set_xpath("additional exclusion");
+  *finalize_request.mutable_hard_binding()
+       ->add_part_bindings()
+       ->mutable_bmff_based_hash() = bmff_based_hash;
+  EXPECT_THAT(
+      FinalizeManifestStore(*store_generator_, finalize_request),
+      StatusIs(
+          absl::StatusCode::kInvalidArgument,
+          HasSubstr(
+              "The part binding differs from template in unsupported ways.")));
+}
+
+TEST_F(ManifestStoreGeneratorTest, PartBindingBoxesHashSizeMismatch) {
+  DataHashAssertion data_hash;
+  data_hash.set_hash(std::string(HashSizeBytes(HashAlgorithm::kSha256), '\0'));
+  data_hash.set_pad("");
+  GenerateManifestStoreRequest request;
+  *request.mutable_hard_binding()->mutable_data_hash() = data_hash;
+  BoxesHashAssertion box_hash;
+  BoxHash* box = box_hash.add_boxes();
+  box->add_names("some name");
+  box->set_hash(std::string(HashSizeBytes(HashAlgorithm::kSha256), '\0'));
+  *request.mutable_hard_binding()->add_part_bindings()->mutable_box_hash() =
+      box_hash;
+  *request.mutable_actions() = ParseTextProtoOrDie(R"pb(
+    created {
+      action_metadata {
+        software_agent { name: "test generator" }
+        description: "created for testing"
+      }
+      digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture"
+    }
+  )pb");
+  absl::StatusOr<ManifestStoreTemplate> manifest_store_template =
+      GenerateManifestStoreTemplate(*store_generator_, request);
+  ABSL_ASSERT_OK(manifest_store_template);
+  FinalizeManifestStoreRequest finalize_request;
+  *finalize_request.mutable_manifest_store_template() =
+      *manifest_store_template;
+  *finalize_request.mutable_hard_binding()->mutable_data_hash() =
+      request.hard_binding().data_hash();
+  box_hash.mutable_boxes(0)->add_names("additional name");
+  *finalize_request.mutable_hard_binding()
+       ->add_part_bindings()
+       ->mutable_box_hash() = box_hash;
+  EXPECT_THAT(
+      FinalizeManifestStore(*store_generator_, finalize_request),
+      StatusIs(
+          absl::StatusCode::kInvalidArgument,
+          HasSubstr(
+              "The part binding differs from template in unsupported ways.")));
+}
+
+TEST_F(ManifestStoreGeneratorTest, PartBindingDataHashSizeMismatch) {
+  DataHashAssertion data_hash;
+  data_hash.set_hash(std::string(HashSizeBytes(HashAlgorithm::kSha256), '\0'));
+  data_hash.set_pad("");
+  GenerateManifestStoreRequest request;
+  *request.mutable_hard_binding()->mutable_data_hash() = data_hash;
+  *request.mutable_hard_binding()->add_part_bindings()->mutable_data_hash() =
+      data_hash;
+  *request.mutable_actions() = ParseTextProtoOrDie(R"pb(
+    created {
+      action_metadata {
+        software_agent { name: "test generator" }
+        description: "created for testing"
+      }
+      digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture"
+    }
+  )pb");
+  absl::StatusOr<ManifestStoreTemplate> manifest_store_template =
+      GenerateManifestStoreTemplate(*store_generator_, request);
+  ABSL_ASSERT_OK(manifest_store_template);
+  FinalizeManifestStoreRequest finalize_request;
+  *finalize_request.mutable_manifest_store_template() =
+      *manifest_store_template;
+  *finalize_request.mutable_hard_binding()->mutable_data_hash() =
+      request.hard_binding().data_hash();
+  data_hash.set_name("additional name");
+  *finalize_request.mutable_hard_binding()
+       ->add_part_bindings()
+       ->mutable_data_hash() = data_hash;
+  EXPECT_THAT(
+      FinalizeManifestStore(*store_generator_, finalize_request),
+      StatusIs(
+          absl::StatusCode::kInvalidArgument,
+          HasSubstr(
+              "The part binding differs from template in unsupported ways.")));
+}
+
+TEST_F(ManifestStoreGeneratorTest, PartBindingMatchingSizes) {
+  DataHashAssertion data_hash;
+  data_hash.set_hash(std::string(HashSizeBytes(HashAlgorithm::kSha256), '\0'));
+  data_hash.set_pad("");
+  GenerateManifestStoreRequest request;
+  // Hard binding
+  *request.mutable_hard_binding()->mutable_data_hash() = data_hash;
+  // Part binding 1: DataHash
+  *request.mutable_hard_binding()->add_part_bindings()->mutable_data_hash() =
+      data_hash;
+  // Part binding 2: BmffBasedHash
+  BmffBasedHashAssertion bmff_based_hash =
+      GetBmffHashTemplate(HashAlgorithm::kSha256);
+  *request.mutable_hard_binding()
+       ->add_part_bindings()
+       ->mutable_bmff_based_hash() = bmff_based_hash;
+  // Part binding 3: BoxesHash
+  BoxesHashAssertion box_hash;
+  BoxHash* box = box_hash.add_boxes();
+  box->add_names("some name");
+  box->set_hash(std::string(HashSizeBytes(HashAlgorithm::kSha256), '\0'));
+  *request.mutable_hard_binding()->add_part_bindings()->mutable_box_hash() =
+      box_hash;
+
+  *request.mutable_actions() = ParseTextProtoOrDie(R"pb(
+    created {
+      action_metadata {
+        software_agent { name: "test generator" }
+        description: "created for testing"
+      }
+      digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture"
+    }
+  )pb");
+  absl::StatusOr<ManifestStoreTemplate> manifest_store_template =
+      GenerateManifestStoreTemplate(*store_generator_, request);
+  ABSL_ASSERT_OK(manifest_store_template);
+
+  FinalizeManifestStoreRequest finalize_request;
+  *finalize_request.mutable_manifest_store_template() =
+      *manifest_store_template;
+  // Set the same hard binding and part bindings in the finalize request.
+  *finalize_request.mutable_hard_binding() = request.hard_binding();
+
+  // Modify the hashes to ensure finalization works.
+  std::string new_hash(HashSizeBytes(HashAlgorithm::kSha256), '\1');
+  finalize_request.mutable_hard_binding()->mutable_data_hash()->set_hash(
+      new_hash);
+  finalize_request.mutable_hard_binding()
+      ->mutable_part_bindings(0)
+      ->mutable_data_hash()
+      ->set_hash(new_hash);
+  finalize_request.mutable_hard_binding()
+      ->mutable_part_bindings(1)
+      ->mutable_bmff_based_hash()
+      ->set_hash(new_hash);
+  finalize_request.mutable_hard_binding()
+      ->mutable_part_bindings(2)
+      ->mutable_box_hash()
+      ->mutable_boxes(0)
+      ->set_hash(new_hash);
+
+  ABSL_EXPECT_OK(FinalizeManifestStore(*store_generator_, finalize_request));
+}
+
+TEST_F(ManifestStoreGeneratorTest,
+       GenerateNegativeMaxManifestStoreSizeBytesFails) {
+  GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb(
+    actions {
+      created {
+        action_metadata {
+          software_agent { name: "test generator" }
+          description: "created for testing"
+        }
+        digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture"
+      }
+    }
+    hard_binding {
+      data_hash { alg: "sha256" hash: "00000000000000000000000000000000" }
+    }
+    options { max_manifest_store_size_bytes: -1 }
+  )pb");
+  EXPECT_THAT(
+      GenerateManifestStore(*store_generator_, request),
+      StatusIs(
+          absl::StatusCode::kInvalidArgument,
+          HasSubstr("max_manifest_store_size_bytes must be non-negative")));
+}
+
+TEST_F(ManifestStoreGeneratorTest, NegativeMaxBytesToShiftFails) {
+  GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb(
+    actions { created { action_metadata { description: "Test Action" } } }
+    hard_binding {
+      data_hash { alg: "sha256" hash: "00000000000000000000000000000000" }
+    }
+    options { max_bytes_to_shift: -1 }
+  )pb");
+  EXPECT_THAT(GenerateManifestStore(*store_generator_, request),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("max_bytes_to_shift must be non-negative")));
+}
+
+TEST_F(ManifestStoreGeneratorTest, NonNegativeMaxBytesToShiftSucceeds) {
+  GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb(
+    actions { created { action_metadata { description: "Test Action" } } }
+    hard_binding {
+      data_hash { alg: "sha256" hash: "00000000000000000000000000000000" }
+    }
+    options { max_bytes_to_shift: 0 }
+  )pb");
+  ABSL_EXPECT_OK(GenerateManifestStore(*store_generator_, request));
+}
+
+TEST_F(ManifestStoreGeneratorTest, FinalizeNegativeManifestStoreSizeFails) {
+  FinalizeManifestStoreRequest finalize_request;
+  finalize_request.mutable_manifest_store_template()->set_generator_context(
+      "context");
+  finalize_request.mutable_manifest_store_template()->set_manifest_store_size(
+      -1);
+  *finalize_request.mutable_hard_binding()->mutable_data_hash() =
+      ParseTextProtoOrDie(R"pb(alg: "sha256"
+                               hash: "00000000000000000000000000000000")pb");
+
+  EXPECT_THAT(
+      FinalizeManifestStore(*store_generator_, finalize_request),
+      StatusIs(
+          absl::StatusCode::kInvalidArgument,
+          HasSubstr("manifest_store_size in template must be non-negative")));
+}
+
+TEST_F(ManifestStoreGeneratorTest, ManifestStoreTransformerSuccess) {
+  GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb(
+    actions {
+      created {
+        action_metadata {
+          software_agent { name: "test generator" }
+          description: "created for testing"
+        }
+        digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture"
+      }
+    }
+    hard_binding {
+      data_hash { alg: "sha256" hash: "00000000000000000000000000000000" }
+    }
+  )pb");
+
+  MockManifestStoreTransformer mock_transformer;
+  EXPECT_CALL(mock_transformer, Transform(testing::NotNull()))
+      .WillOnce([](jumbf::BuiltSuperBox* box) {
+        EXPECT_THAT(box, testing::NotNull());
+        EXPECT_FALSE(box->Peek().empty());
+        return absl::OkStatus();
+      });
+
+  std::unique_ptr<ManifestStoreGenerator> generator =
+      CreateGenerator(&mock_transformer);
+  ABSL_EXPECT_OK(GenerateManifestStore(*generator, request));
+}
+
+TEST_F(ManifestStoreGeneratorTest, ManifestStoreTransformerModifiesSuperBox) {
+  GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb(
+    actions { created { action_metadata { description: "Test Action" } } }
+    hard_binding {
+      data_hash { alg: "sha256" hash: "00000000000000000000000000000000" }
+    }
+  )pb");
+
+  jumbf::SuperBoxBuilder builder(jumbf::kCborBoxTypeUuid, {});
+  ABSL_CHECK_OK(builder.AddContent(jumbf::kCborBoxType,
+                                   absl::Cord("custom transformed data")));
+  absl::StatusOr<jumbf::BuiltSuperBox> transformed_box =
+      std::move(builder).Finalize();
+  ABSL_ASSERT_OK(transformed_box);
+  std::string expected_transformed_store(transformed_box->Peek());
+
+  MockManifestStoreTransformer mock_transformer;
+  EXPECT_CALL(mock_transformer, Transform(testing::NotNull()))
+      .WillOnce([transformed_box = *std::move(transformed_box)](
+                    jumbf::BuiltSuperBox* box) mutable {
+        EXPECT_THAT(box, testing::NotNull());
+        EXPECT_FALSE(box->Peek().empty());
+        *box = std::move(transformed_box);
+        return absl::OkStatus();
+      });
+
+  std::unique_ptr<ManifestStoreGenerator> generator =
+      CreateGenerator(&mock_transformer);
+  absl::StatusOr<GenerateManifestStoreResponse> response =
+      GenerateManifestStore(*generator, request);
+  ABSL_ASSERT_OK(response);
+  EXPECT_EQ(response->manifest_store(), expected_transformed_store);
+}
+
+TEST_F(ManifestStoreGeneratorTest, ManifestStoreTransformerErrorPropagates) {
+  GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb(
+    actions { created { action_metadata { description: "Test Action" } } }
+    hard_binding {
+      data_hash { alg: "sha256" hash: "00000000000000000000000000000000" }
+    }
+  )pb");
+
+  MockManifestStoreTransformer mock_transformer;
+  EXPECT_CALL(mock_transformer, Transform(testing::NotNull()))
+      .WillOnce(testing::Return(absl::InternalError("transform failed")));
+
+  std::unique_ptr<ManifestStoreGenerator> generator =
+      CreateGenerator(&mock_transformer);
+  EXPECT_THAT(
+      GenerateManifestStore(*generator, request),
+      StatusIs(absl::StatusCode::kInternal, HasSubstr("transform failed")));
+}
+
+TEST_F(ManifestStoreGeneratorTest,
+       ManifestStoreTransformerTemplateErrorPropagates) {
+  GenerateManifestStoreRequest request = ParseTextProtoOrDie(R"pb(
+    actions { created { action_metadata { description: "Test Action" } } }
+    hard_binding {
+      data_hash { alg: "sha256" hash: "00000000000000000000000000000000" }
+    }
+  )pb");
+
+  MockManifestStoreTransformer mock_transformer;
+  EXPECT_CALL(mock_transformer, Transform(testing::NotNull()))
+      .WillOnce(
+          testing::Return(absl::InternalError("transform template failed")));
+
+  std::unique_ptr<ManifestStoreGenerator> generator =
+      CreateGenerator(&mock_transformer);
+  EXPECT_THAT(GenerateManifestStoreTemplate(*generator, request),
+              StatusIs(absl::StatusCode::kInternal,
+                       HasSubstr("transform template failed")));
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/generator/generator_options.h b/generator/generator_options.h
new file mode 100644
index 0000000..db3c507
--- /dev/null
+++ b/generator/generator_options.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_GENERATOR_GENERATOR_OPTIONS_H_
+#define THIRD_PARTY_CREDENTIO_GENERATOR_GENERATOR_OPTIONS_H_
+
+#include "absl/base/nullability.h"
+#include "assertion/assertion_encoder.h"
+#include "claim/claim_cbor_encoder.h"
+#include "claim/generator.h"
+#include "claim/signer.h"
+#include "constants/spec_version.h"
+#include "crypto/default/hasher.h"
+#include "crypto/hash.h"
+#include "generator/manifest_store_transformer.h"
+#include "proto/generator_info.pb.h"
+#include "uuid/uuid.h"
+
+namespace credentio {
+
+// Options for creating a ManifestStoreGenerator.
+struct GeneratorOptions {
+  // Required.
+  GeneratorInfo generator_info;
+  // Required. Must outlive the created generator.
+  ClaimSigner* signing_claim_signer = nullptr;
+  // Required. Must outlive the created generator.
+  ClaimSigner* template_claim_signer = nullptr;
+  // Optional. If provided, must outlive the created generator.
+  const UuidGenerator* absl_nonnull uuid_generator = &UuidGenerator::Default();
+  // Optional. If provided, must outlive the created generator.
+  const HasherFactory* absl_nonnull hash_factory = &DefaultHasherFactory();
+  // Optional. If provided, must outlive the created generator.
+  const AssertionEncoder* assertion_cbor_encoder = nullptr;
+  // Optional. If provided, must outlive the created generator.
+  const ClaimCborEncoder* claim_cbor_encoder = nullptr;
+  // Optional. If provided, must outlive the created generator.
+  const ManifestStoreTransformer* manifest_store_transformer = nullptr;
+  // Optional.
+  ManifestAssemblerFactory manifest_assembler_factory = CreateManifestAssembler;
+  // The C2PA specification version the generated manifests conform to.
+  SpecVersion spec_version = SpecVersion::kC2pa_2_2;
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_GENERATOR_GENERATOR_OPTIONS_H_
diff --git a/generator/manifest_store_transformer.h b/generator/manifest_store_transformer.h
new file mode 100644
index 0000000..dfe5678
--- /dev/null
+++ b/generator/manifest_store_transformer.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_GENERATOR_MANIFEST_STORE_TRANSFORMER_H_
+#define THIRD_PARTY_CREDENTIO_GENERATOR_MANIFEST_STORE_TRANSFORMER_H_
+
+#include "absl/status/status.h"
+#include "jumbf/box_builder.h"
+
+namespace credentio {
+
+// Interface for transforming manifest store JUMBF BuiltSuperBox structures
+// before final output.
+class ManifestStoreTransformer {
+ public:
+  virtual ~ManifestStoreTransformer() = default;
+  virtual absl::Status Transform(jumbf::BuiltSuperBox* box) const = 0;
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_GENERATOR_MANIFEST_STORE_TRANSFORMER_H_
diff --git a/jumbf/BUILD b/jumbf/BUILD
index a22c1e4..e30a42e 100644
--- a/jumbf/BUILD
+++ b/jumbf/BUILD
@@ -167,6 +167,7 @@
     deps = [
         ":box",
         ":constants",
+        "//jumbf/internal:encode",
         "//jumbf/internal:serialization_utils",
         "//uuid",
         "@abseil-cpp//absl/functional:overload",
@@ -174,7 +175,9 @@
         "@abseil-cpp//absl/status:status_macros",
         "@abseil-cpp//absl/status:statusor",
         "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/strings:cord",
         "@abseil-cpp//absl/strings:string_view",
+        "@riegeli//riegeli/bytes:cord_backward_writer",
     ],
 )
 
@@ -202,6 +205,7 @@
         "@abseil-cpp//absl/strings:cord",
         "@abseil-cpp//absl/strings:string_view",
         "@googletest//:gtest_main",
+        "@riegeli//riegeli/endian:endian_reading",
         "@riegeli//riegeli/endian:endian_writing",
     ],
 )
diff --git a/jumbf/README.md b/jumbf/README.md
index 98a041d..055c58d 100644
--- a/jumbf/README.md
+++ b/jumbf/README.md
@@ -9,9 +9,9 @@
 
 ### Parsing
 
--   [`ConsumeSuperBox(...)`](http://c2pa/jumbf/parse.h): Recursively parses a
-    superbox and all contained boxes into
-    [view-only structs](http://c2pa/jumbf/box.h).
+-   [`ConsumeSuperBox(...)`](http://credentio/jumbf/parse.h): Recursively parses
+    a superbox and all contained boxes into
+    [view-only structs](http://credentio/jumbf/box.h).
 
 -   Basic box structure: Complete
 
@@ -34,8 +34,8 @@
 
 ## Serialization
 
--   [`SuperBoxBuilder`](http://c2pa/jumbf/box_builder.h): Stateful builder for
-    creating superboxes.
+-   [`SuperBoxBuilder`](http://credentio/jumbf/box_builder.h): Stateful builder
+    for creating superboxes.
 
 -   Basic box / superbox structure: Complete
 
@@ -68,9 +68,9 @@
 
 ## 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).
+The public API is contained in the top-level directory, credentio/jumbf.
+Internal parsing functions are located in the
+["internal" subdirectory](http://credentio/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
diff --git a/jumbf/box_builder.h b/jumbf/box_builder.h
index 003f1d4..a17f647 100644
--- a/jumbf/box_builder.h
+++ b/jumbf/box_builder.h
@@ -16,7 +16,7 @@
 // SuperBoxEncoder: Stateful JUMBF encoder.
 //
 // This class tracks box lengths to correctly write headers. See
-// c2pa/jumbf/box_builder_test.cc for usage
+// credentio/jumbf/box_builder_test.cc for usage
 // examples.
 
 #ifndef THIRD_PARTY_CREDENTIO_JUMBF_BOX_BUILDER_H_
diff --git a/jumbf/box_builder_test.cc b/jumbf/box_builder_test.cc
index fbe0f5f..e7ec491 100644
--- a/jumbf/box_builder_test.cc
+++ b/jumbf/box_builder_test.cc
@@ -60,6 +60,26 @@
   EXPECT_EQ(super_box.contents[0].Get<CborBox>().payload, "this is not CBOR");
 }
 
+TEST(BoxBuilderTest, JsonContentBox) {
+  jumbf::SuperBoxBuilder builder(jumbf::kJsonBoxTypeUuid, {});
+  ABSL_ASSERT_OK(builder.AddContent(
+      jumbf::kJsonBoxType, jumbf::CordFromConstexpr("{\"key\": \"value\"}")));
+
+  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, kJsonBoxTypeUuid);
+  ASSERT_EQ(super_box.contents.size(), 1);
+  ASSERT_TRUE(super_box.contents[0].Holds<JsonBox>());
+  EXPECT_EQ(super_box.contents[0].Get<JsonBox>().payload,
+            "{\"key\": \"value\"}");
+}
+
 TEST(BoxBuilderTest, InspectLabelAfterFinalize) {
   jumbf::SuperBoxBuilder with_label(jumbf::kCborBoxTypeUuid,
                                     {.label = CordFromConstexpr("boxLabel")});
diff --git a/jumbf/constants.h b/jumbf/constants.h
index c3c6de7..f876766 100644
--- a/jumbf/constants.h
+++ b/jumbf/constants.h
@@ -65,6 +65,9 @@
 inline constexpr auto kCborBoxTypeUuid =
     credentio::Uuid::FromStringOrDie("63626F72-0011-0010-8000-00AA00389B71");
 
+inline constexpr auto kJsonBoxTypeUuid =
+    credentio::Uuid::FromStringOrDie("6A736F6E-0011-0010-8000-00AA00389B71");
+
 inline constexpr auto kC2PARedactionUuid =
     credentio::Uuid::FromStringOrDie("CAA98EEE-9D4D-F80E-86AD-4DFFCA263973");
 
diff --git a/jumbf/internal/encode.h b/jumbf/internal/encode.h
index 3990565..f2e672d 100644
--- a/jumbf/internal/encode.h
+++ b/jumbf/internal/encode.h
@@ -31,7 +31,7 @@
 //   &superbox_size));
 //
 // The public `SuperBoxBuilder` class in
-// c2pa/jumbf/box_builder.h is easier to use.
+// credentio/jumbf/box_builder.h is easier to use.
 
 #ifndef THIRD_PARTY_CREDENTIO_JUMBF_INTERNAL_ENCODE_H_
 #define THIRD_PARTY_CREDENTIO_JUMBF_INTERNAL_ENCODE_H_
diff --git a/jumbf/utils.cc b/jumbf/utils.cc
index 97e1555..2104dfb 100644
--- a/jumbf/utils.cc
+++ b/jumbf/utils.cc
@@ -25,11 +25,14 @@
 #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 "absl/strings/strip.h"
 #include "jumbf/box.h"
 #include "jumbf/constants.h"
+#include "jumbf/internal/encode.h"
 #include "jumbf/internal/serialization_utils.h"
+#include "riegeli/bytes/cord_backward_writer.h"
 #include "uuid/uuid.h"
 
 namespace jumbf {
@@ -142,4 +145,14 @@
   return content_size;
 }
 
+absl::StatusOr<absl::Cord> CreateC2paSaltBox(absl::string_view salt) {
+  riegeli::CordBackwardWriter<absl::Cord> writer;
+  ABSL_RETURN_IF_ERROR(
+      EncodeContentBox(kC2paSaltBoxType, absl::Cord(salt), &writer));
+  if (!writer.Close()) {
+    return writer.status();
+  }
+  return std::move(writer.dest());
+}
+
 }  // namespace jumbf
diff --git a/jumbf/utils.h b/jumbf/utils.h
index de09428..f0143b7 100644
--- a/jumbf/utils.h
+++ b/jumbf/utils.h
@@ -19,6 +19,7 @@
 #include <cstdint>
 
 #include "absl/status/statusor.h"
+#include "absl/strings/cord.h"
 #include "absl/strings/string_view.h"
 #include "jumbf/box.h"
 #include "uuid/uuid.h"
@@ -45,6 +46,9 @@
 // Returns the size of the box payload.
 int GetContextBoxSize(const ContentBox& box);
 
+// Serializes a C2PA Salt Box ('c2sh') with the given salt payload.
+absl::StatusOr<absl::Cord> CreateC2paSaltBox(absl::string_view salt);
+
 }  // namespace jumbf
 
 #endif  // THIRD_PARTY_CREDENTIO_JUMBF_UTILS_H_
diff --git a/jumbf/utils_test.cc b/jumbf/utils_test.cc
index 00fb275..390c6fe 100644
--- a/jumbf/utils_test.cc
+++ b/jumbf/utils_test.cc
@@ -36,6 +36,7 @@
 #include "jumbf/constants.h"
 #include "jumbf/parse.h"
 #include "jumbf/test_utils.h"
+#include "riegeli/endian/endian_reading.h"
 #include "riegeli/endian/endian_writing.h"
 #include "uuid/uuid.h"
 
@@ -437,5 +438,20 @@
   EXPECT_EQ(std::string(buf + 16, 16), kUuid1.ToProtoBytes());
 }
 
+TEST(UtilsTest, CreateC2paSaltBox) {
+  constexpr absl::string_view kSalt = "0123456789abcdef";
+  auto salt_box_cord = CreateC2paSaltBox(kSalt);
+  ABSL_ASSERT_OK(salt_box_cord);
+  std::string salt_box_bytes = std::string(*salt_box_cord);
+
+  absl::string_view sv = salt_box_bytes;
+  ASSERT_GE(sv.size(), 8);
+  uint32_t lbox = riegeli::ReadBigEndian<uint32_t>(&sv[0]);
+  uint32_t tbox = riegeli::ReadBigEndian<uint32_t>(&sv[4]);
+  EXPECT_EQ(lbox, 8 + kSalt.size());
+  EXPECT_EQ(tbox, kC2paSaltBoxType);
+  EXPECT_EQ(sv.substr(8), kSalt);
+}
+
 }  // namespace
 }  // namespace jumbf
diff --git a/kokoro/gcp_ubuntu/build_and_test.sh b/kokoro/gcp_ubuntu/build_and_test.sh
index da5757e..1ccefa6 100755
--- a/kokoro/gcp_ubuntu/build_and_test.sh
+++ b/kokoro/gcp_ubuntu/build_and_test.sh
@@ -19,13 +19,65 @@
 # 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}"
+# Default to running instrumentation tests when Android environment is present.
+export RUN_INSTRUMENTATION_TESTS="${RUN_INSTRUMENTATION_TESTS:-1}"
 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 ---
+function create_and_start_emulator() {
+  local emulator_name=c2pa_emulator
+  if ! sdkmanager --list_installed 2>/dev/null | grep -q "system-images;android-35;google_apis;x86_64"; then
+    echo "Emulator system image not pre-installed; downloading..."
+    echo y | sdkmanager "platforms;android-35" "emulator" "system-images;android-35;google_apis;x86_64" || true
+  fi
+
+  echo no | avdmanager create avd -n "${emulator_name}" \
+      --package "system-images;android-35;google_apis;x86_64" \
+      --force
+
+  mkdir -p "${KOKORO_ARTIFACTS_DIR}/emulator-logs"
+  "${ANDROID_HOME}/emulator/emulator" -avd "${emulator_name}" \
+    -no-window \
+    -gpu swiftshader_indirect \
+    -no-snapshot \
+    -no-audio \
+    -no-boot-anim > "${KOKORO_ARTIFACTS_DIR}/emulator-logs/emulator_${emulator_name}.log" 2>&1 &
+}
+
+function wait_for_emulator_to_boot() {
+  local retries=60 # 5 minutes (60 * 5s)
+  while [[ $retries -gt 0 ]]; do
+    local boot_completed
+    boot_completed=$(adb shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')
+    if [[ "${boot_completed}" == "1" ]]; then
+      break
+    fi
+    echo "Waiting for emulator to boot..."
+    sleep 5
+    ((retries--))
+  done
+
+  if [[ $retries -eq 0 ]]; then
+    echo "Emulator failed to boot in time."
+    exit 1
+  fi
+
+  echo "Emulator booted."
+}
+
+function cleanup() {
+  set +e
+  if [ "${RUN_INSTRUMENTATION_TESTS:-0}" = "1" ] || [ "${RUN_INSTRUMENTATION_TESTS:-false}" = "true" ]; then
+    echo "Stopping emulator..."
+    adb emu kill 2>/dev/null || true
+  fi
+}
+trap cleanup EXIT
+
+# --- Run Gradle build and 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..."
@@ -36,7 +88,16 @@
   fi
 
   cd android
-  ./gradlew assembleDebug testDebugUnitTest --no-daemon --stacktrace
+
+  gradle_tasks=(assembleDebug testDebugUnitTest)
+  if [ "${RUN_INSTRUMENTATION_TESTS:-0}" = "1" ] || [ "${RUN_INSTRUMENTATION_TESTS:-false}" = "true" ]; then
+    echo "Starting Android emulator for instrumentation tests..."
+    create_and_start_emulator
+    wait_for_emulator_to_boot
+    gradle_tasks+=(connectedAndroidTest)
+  fi
+
+  ./gradlew "${gradle_tasks[@]}" --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
index b03c640..d75130f 100644
--- a/kokoro/gcp_ubuntu/continuous.cfg
+++ b/kokoro/gcp_ubuntu/continuous.cfg
@@ -5,4 +5,5 @@
 
 container_properties {
   docker_image: "us-central1-docker.pkg.dev/kokoro-container-bakery/c2pa/builder:latest"
+  docker_privileged: true
 }
diff --git a/kokoro/gcp_ubuntu/presubmit.cfg b/kokoro/gcp_ubuntu/presubmit.cfg
index b03c640..d75130f 100644
--- a/kokoro/gcp_ubuntu/presubmit.cfg
+++ b/kokoro/gcp_ubuntu/presubmit.cfg
@@ -5,4 +5,5 @@
 
 container_properties {
   docker_image: "us-central1-docker.pkg.dev/kokoro-container-bakery/c2pa/builder:latest"
+  docker_privileged: true
 }
diff --git a/kokoro/macos/build_and_test.sh b/kokoro/macos/build_and_test.sh
new file mode 100755
index 0000000..49b2aa9
--- /dev/null
+++ b/kokoro/macos/build_and_test.sh
@@ -0,0 +1,35 @@
+#!/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.
+#
+
+# Builds and tests Credentio on a Kokoro macOS VM.
+set -euo pipefail
+
+export KOKORO_ARTIFACTS_DIR="${KOKORO_ARTIFACTS_DIR:-/tmpfs/src}"
+cd "${KOKORO_ARTIFACTS_DIR}/git/staging"
+
+if command -v bazelisk >/dev/null 2>&1; then
+  BAZEL=bazelisk
+else
+  BAZEL=bazel
+fi
+
+# A Bazel server left running keeps the VM session open until the Kokoro
+# timeout, so always shut it down.
+trap '"${BAZEL}" shutdown || true' EXIT
+
+"${BAZEL}" version
+"${BAZEL}" build ...
+"${BAZEL}" test --test_output=errors ...
diff --git a/kokoro/macos/continuous.cfg b/kokoro/macos/continuous.cfg
new file mode 100644
index 0000000..f0d534b
--- /dev/null
+++ b/kokoro/macos/continuous.cfg
@@ -0,0 +1,4 @@
+# proto-file: google3/devtools/kokoro/config/proto/build.proto
+# proto-message: BuildConfig
+
+build_file: "staging/kokoro/macos/build_and_test.sh"
diff --git a/kokoro/macos/presubmit.cfg b/kokoro/macos/presubmit.cfg
new file mode 100644
index 0000000..f0d534b
--- /dev/null
+++ b/kokoro/macos/presubmit.cfg
@@ -0,0 +1,4 @@
+# proto-file: google3/devtools/kokoro/config/proto/build.proto
+# proto-message: BuildConfig
+
+build_file: "staging/kokoro/macos/build_and_test.sh"
diff --git a/proto/BUILD b/proto/BUILD
index 1a6731c..f8bf783 100644
--- a/proto/BUILD
+++ b/proto/BUILD
@@ -702,3 +702,22 @@
     name = "ingredient_validation_result_java_proto_lite",
     deps = [":ingredient_validation_result_proto"],
 )
+
+proto_library(
+    name = "generator_proto",
+    srcs = ["generator.proto"],
+    deps = [
+        ":bmff_based_hash_assertion_proto",
+        ":boxes_hash_assertion_proto",
+        ":collection_data_hash_assertion_proto",
+        ":data_hash_assertion_proto",
+        ":generator_info_proto",
+        ":ingredient_validation_result_proto",
+        ":multi_asset_hash_assertion_proto",
+    ],
+)
+
+cc_proto_library(
+    name = "generator_cc_proto",
+    deps = [":generator_proto"],
+)
diff --git a/proto/generator.proto b/proto/generator.proto
new file mode 100644
index 0000000..c32b400
--- /dev/null
+++ b/proto/generator.proto
@@ -0,0 +1,451 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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 "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_validation_result.proto";
+import "proto/multi_asset_hash_assertion.proto";
+
+option java_multiple_files = true;
+
+// Metadata to attach to an action.
+message ActionMetadata {
+  GeneratorInfo software_agent = 1;
+  string description = 2;
+  // Was this action performed multiple times?
+  bool multiple_instances = 3;
+}
+
+// See description of c2pa.created at
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_actions.
+message CreatedAction {
+  ActionMetadata action_metadata = 1;
+  string digital_source_type = 2;
+  // Optional. Indexes (within GenerateManifestStoreRequest.ingredients) of
+  // ingredients to associate with this action.
+  repeated int64 input_ingredient_indexes = 3;
+}
+
+// See description of c2pa.opened at
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_actions.
+message OpenedAction {
+  ActionMetadata action_metadata = 1;
+  // Required. Index (within GenerateManifestStoreRequest.ingredients) of the
+  // ingredient to associate with this action.
+  int64 ingredient_index = 2;
+}
+
+// See description of c2pa.addedText at
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_actions.
+message AddedTextAction {
+  ActionMetadata action_metadata = 1;
+  string digital_source_type = 2;
+}
+
+// See description of c2pa.adjustedColor at
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_actions.
+message AdjustedColorAction {
+  ActionMetadata action_metadata = 1;
+  string digital_source_type = 2;
+}
+
+// See description of c2pa.changedSpeed at
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_actions.
+message ChangedSpeedAction {
+  ActionMetadata action_metadata = 1;
+  string digital_source_type = 2;
+}
+
+// See description of c2pa.converted at
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_actions.
+message ConvertedAction {
+  ActionMetadata action_metadata = 1;
+}
+
+// See description of c2pa.cropped at
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_actions.
+message CroppedAction {
+  ActionMetadata action_metadata = 1;
+  string digital_source_type = 2;
+}
+
+// See description of c2pa.deleted at
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_actions.
+message DeletedAction {
+  ActionMetadata action_metadata = 1;
+  string digital_source_type = 2;
+}
+
+// See description of c2pa.drawing at
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_actions.
+message DrawingAction {
+  ActionMetadata action_metadata = 1;
+  string digital_source_type = 2;
+}
+
+// See description of c2pa.dubbed at
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_actions.
+message DubbedAction {
+  ActionMetadata action_metadata = 1;
+  string digital_source_type = 2;
+}
+
+// See description of c2pa.edited at
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_actions.
+message EditedAction {
+  ActionMetadata action_metadata = 1;
+  string digital_source_type = 2;
+  // Optional. Indexes (within GenerateManifestStoreRequest.ingredients) of
+  // ingredients to associate with this action, using an `inputTo` relationship
+  // (https://spec.c2pa.org/specifications/specifications/2.2/specs/C2PA_Specification.html#_relationship).
+  repeated int64 input_ingredient_indexes = 3;
+}
+
+// See description of c2pa.edited.metadata at
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_actions.
+message EditedMetadataAction {
+  ActionMetadata action_metadata = 1;
+}
+
+// See description of c2pa.enhanced at
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_actions.
+message EnhancedAction {
+  ActionMetadata action_metadata = 1;
+  string digital_source_type = 2;
+}
+
+// See description of c2pa.filtered at
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_actions.
+message FilteredAction {
+  ActionMetadata action_metadata = 1;
+  string digital_source_type = 2;
+  // Optional. Indexes (within GenerateManifestStoreRequest.ingredients) of
+  // ingredients to associate with this action, using an `inputTo` relationship
+  // (https://spec.c2pa.org/specifications/specifications/2.2/specs/C2PA_Specification.html#_relationship).
+  repeated int64 input_ingredient_indexes = 3;
+}
+
+// See description of c2pa.orientation at
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_actions.
+message OrientationAction {
+  ActionMetadata action_metadata = 1;
+  string digital_source_type = 2;
+}
+
+// See description of c2pa.placed at
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_actions.
+message PlacedAction {
+  ActionMetadata action_metadata = 1;
+  // Required. Indexes (within GenerateManifestStoreRequest.ingredients) of
+  // ingredients to associate with this action.
+  repeated int64 ingredient_indexes = 3;
+
+  reserved 2;
+}
+
+message RepackagedAction {
+  ActionMetadata action_metadata = 1;
+}
+
+// See description of c2pa.resized at
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_actions.
+message ResizedAction {
+  ActionMetadata action_metadata = 1;
+  string digital_source_type = 2;
+}
+
+// See description of c2pa.transcoded at
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_actions.
+message TranscodedAction {
+  ActionMetadata action_metadata = 1;
+  string digital_source_type = 2;
+}
+
+// See description of c2pa.trimmed at
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_actions.
+message TrimmedAction {
+  ActionMetadata action_metadata = 1;
+  string digital_source_type = 2;
+}
+
+// See description of c2pa.unknown at
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_actions.
+message UnknownAction {
+  ActionMetadata action_metadata = 1;
+}
+
+// Describes an action that was taken on an asset.
+// See
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_actions
+// for more details.
+message Action {
+  // Required.
+  oneof action {
+    AddedTextAction added_text = 2;             // c2pa.addedText
+    AdjustedColorAction adjusted_color = 3;     // c2pa.adjustedColor
+    ChangedSpeedAction changed_speed = 4;       // c2pa.changedSpeed
+    ConvertedAction converted = 5;              // c2pa.converted
+    CroppedAction cropped = 6;                  // c2pa.cropped
+    DeletedAction deleted = 7;                  // c2pa.deleted
+    DrawingAction drawing = 8;                  // c2pa.drawing
+    DubbedAction dubbed = 9;                    // c2pa.dubbed
+    EditedAction edited = 10;                   // c2pa.edited
+    EditedMetadataAction edited_metadata = 11;  // c2pa.edited.metadata
+    EnhancedAction enhanced = 12;               // c2pa.enhanced
+    FilteredAction filtered = 13;               // c2pa.filtered
+    OrientationAction orientation = 14;         // c2pa.orientation
+    PlacedAction placed = 1;                    // c2pa.placed
+    RepackagedAction repackaged = 15;           // c2pa.repackaged
+    ResizedAction resized = 16;                 // c2pa.resized
+    TranscodedAction transcoded = 17;           // c2pa.transcoded
+    TrimmedAction trimmed = 19;                 // c2pa.trimmed
+    UnknownAction unknown = 20;                 // c2pa.unknown
+  }
+
+  reserved 18, 21;
+}
+
+// Describes a collection of Action(s) that were taken on an asset.
+// See
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_actions
+// for more details.
+message Actions {
+  // Required. The first action taken to create or import the asset.
+  oneof inception_action {
+    CreatedAction created = 1;
+    OpenedAction opened = 2;
+  }
+
+  // Optional. List of additional actions taken on the asset.
+  repeated Action actions = 3;
+
+  // If true, indicates that all actions performed are included in an actions
+  // assertion.
+  bool all_actions_included = 4;
+}
+
+// Describes an ingredient input to claim generation. Will be used to generate
+// ingredient Assertions. All ingredients must be referenced by an action.
+message IngredientInput {
+  // Optional but recommended. Metadata about the ingredient.
+  IngredientInputMetadata metadata = 8;
+
+  // Optional. Ingredient validation results returned by the validator.
+  IngredientValidationResultProto ingredient_validation_results = 6;
+
+  reserved 1, 2, 3, 4, 5, 7;
+}
+
+// Metadata about an ingredient.
+message IngredientInputMetadata {
+  // Optional. IANA Media Type of the ingredient.
+  // Instead of providing a value here, it is better to provide the media type
+  // as a parameter to the `ValidateIngredient` call.
+  string media_type = 1;
+
+  // Optional. Name of the ingredient.
+  string title = 2;
+
+  // Optional. Unique identifier such as the value of the ingredient's
+  // `xmpMM:InstanceID`. If omitted, a randomly generated UUID will be provided.
+  string instance_id = 3;
+
+  // Optional. Additional description of the ingredient.
+  string description = 4;
+
+  // Optional. URI to an informational page about the ingredient or its data.
+  string informational_uri = 5;
+}
+
+// Describes a hard binding assertion. See
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_hard_bindings
+// for more details.
+message HardBinding {
+  oneof binding {
+    BmffBasedHashAssertion bmff_based_hash = 1;
+    BoxesHashAssertion box_hash = 2;
+    DataHashAssertion data_hash = 3;
+    CollectionDataHashAssertion collection_data_hash = 5;
+  }
+
+  // Bindings for each part of a multi-part asset, in the order in which they
+  // appear in the asset.
+  repeated PartBinding part_bindings = 4;
+}
+
+// Describes a binding assertion for a part of a multi-part asset.
+// This is a simplified structure, the generator will be responsible for
+// converting this into the appropriate binding assertion for the format. See
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_multi_asset_hash
+// for more details.
+message PartBinding {
+  MultiAssetPartLocation location = 1;
+  bool is_optional = 2;
+
+  oneof binding {
+    BmffBasedHashAssertion bmff_based_hash = 4;
+    BoxesHashAssertion box_hash = 5;
+    DataHashAssertion data_hash = 6;
+  }
+
+  reserved 3;
+}
+
+message GenerateManifestStoreOptions {
+  enum TimeStampingMode {
+    // If not specified, it behaves as TIME_STAMPING_OPTIONAL.
+    TIME_STAMPING_MODE_UNSPECIFIED = 0;
+    // Time-stamping is optional and fail-open. For instance, if the
+    // time-stamping service is unavailable, no timestamp will be included and a
+    // padding space is reserved.
+    TIME_STAMPING_OPTIONAL = 1;
+    // Time-stamping is required and fail-close. For instance, the request will
+    // be failed if the time-stamping service is unavailable.
+    TIME_STAMPING_REQUIRED = 2;
+    // Time-stamping is omitted and a padding space is reserved.
+    TIME_STAMPING_OMITTED_WITH_PADDING = 3;
+  }
+  TimeStampingMode time_stamping_mode = 2;
+
+  // Controls whether the multi-asset hash assertion should be generated.
+  // If unset or false, no multi-asset hash assertion will be generated.
+  // If true, the generator will generate a multi-asset hash assertion for
+  // the asset.
+  // Impact per format:
+  // - JPEG: If true, the generator will generate a multi-asset hash assertion
+  //   for the asset when applicable (Android Motion Photo, MPF, AMP, Ultra HDR,
+  //   ...), marking any non-JPEG parts optional.
+  // Relevant only for container-level generation APIs.
+  bool generate_multi_asset_hash = 3;
+
+  // If nonzero, generation will fail if the generated C2PA manifest store would
+  // exceed this size. If zero, a default limit will be used.
+  int64 max_manifest_store_size_bytes = 4;
+
+  // If set and greater than or equal to 0, generation will fail if embedding
+  // the manifest requires shifting more bytes in the asset than this limit.
+  // This limit is compared against the file size (in bytes) minus the offset of
+  // the first INSERT instruction.
+  // NOTE: This option is currently only supported and enforced by the BMFF/MP4
+  // encoder.
+  int64 max_bytes_to_shift = 11;
+
+  reserved 1, 5, 6, 7, 8, 9, 10;
+}
+
+// Describes a gathered assertion to be included in the manifest.
+message GatheredAssertion {
+  // The content type of the gathered assertion.
+  enum ContentType {
+    CONTENT_TYPE_UNSPECIFIED = 0;
+    CONTENT_TYPE_JSON = 1;
+  }
+
+  // Label of the assertion within the assertion store.
+  string label = 1;
+
+  // Raw payload bytes of the assertion.
+  bytes data = 2;
+
+  // The content type of the data. Required.
+  ContentType content_type = 3;
+}
+
+// Describes a request to the claim generator for standard manifests.
+message GenerateManifestStoreRequest {
+  // Optional. Unique identifier for the asset version.
+  // If the asset contains XMP, then the asset’s xmpMM:InstanceID should be
+  // used as the instanceID.
+  // If omitted, the claim generator will generate a new instance ID.
+  string instance_id = 7;
+
+  // Required for "bare metal" APIs, prohibited for "container-level" APIs
+  // (e.g., PngEncoder, JpegEncoder).
+  //
+  // See
+  // https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_hard_bindings
+  // for more details.
+  HardBinding hard_binding = 6;
+  // Required. See
+  // https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_actions
+  // for more details.
+  Actions actions = 4;
+
+  // Optional. Ingredient inputs to be included in the generated manifest.
+  // All ingredients must be referenced by an action.
+  repeated IngredientInput ingredients = 5;
+
+  // Optional. Gathered assertions to be included in the generated manifest.
+  repeated GatheredAssertion gathered_assertions = 11;
+
+  reserved 1, 2, 3, 9, 10;
+
+  // Optional. Options to configure the behavior of the generator.
+  GenerateManifestStoreOptions options = 8;
+}
+
+// The result of generating a C2PA manifest store.
+message GenerateManifestStoreResponse {
+  // The generated manifest store.
+  bytes manifest_store = 1;
+  // Indicates whether the generated claim has been timestamped or not.
+  bool is_timestamped = 2;
+  reserved 3;
+}
+
+// A temporate manifest store is returned by the generator during size
+// estimation, which will be used to generate the final manifest store.
+message ManifestStoreTemplate {
+  // The expected byte size of the to-be-generated manifest store (`c2pa` JUMBF
+  // superbox). An asset writer should use this size to reserve space for the
+  // C2PA data.
+  //
+  // This size does not include format-specific wrapping such as the JPEG APP11
+  // segment header(s) or BMFF `uuid` superbox header.
+  int64 manifest_store_size = 1;
+  // An opaque information returned by the generator, which will be used
+  // subsequently to generate the final manifest store.
+  bytes generator_context = 2;
+}
+
+// A request to finalize a manifest store template into a complete manifest
+// store.
+message FinalizeManifestStoreRequest {
+  // Required. The manifest store template returned by the generator during size
+  // estimation.
+  ManifestStoreTemplate manifest_store_template = 1;
+  // Required. See
+  // https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_hard_bindings
+  // for more details.
+  HardBinding hard_binding = 5;
+  reserved 2, 3, 4;
+}
+
+// The result of finalizing a C2PA manifest store.
+message FinalizeManifestStoreResponse {
+  // The generated manifest store.
+  bytes manifest_store = 1;
+  // Indicates whether the generated claim has been timestamped or not.
+  bool is_timestamped = 2;
+
+  reserved 3;
+}
diff --git a/salt/BUILD b/salt/BUILD
new file mode 100644
index 0000000..5531c49
--- /dev/null
+++ b/salt/BUILD
@@ -0,0 +1,23 @@
+# Copyright 2026 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#    https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT 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 = "salt_generator",
+    hdrs = ["salt_generator.h"],
+)
diff --git a/salt/salt_generator.h b/salt/salt_generator.h
new file mode 100644
index 0000000..b274c75
--- /dev/null
+++ b/salt/salt_generator.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_SALT_SALT_GENERATOR_H_
+#define THIRD_PARTY_CREDENTIO_SALT_SALT_GENERATOR_H_
+
+#include <cstddef>
+#include <string>
+
+namespace credentio {
+
+// Default salt length in bytes (128 bits of entropy) per C2PA specification
+// Section 8.3.1.3 and Section 8.4.2.3.
+inline constexpr size_t kDefaultSaltLength = 16;
+
+// Abstract interface for generating assertion salt bytes.
+//
+// Salts are embedded into the private 'c2sh' box within an assertion's JUMBF
+// Description Box to ensure that redacted assertions cannot be easily
+// dictionary-attacked or pre-image brute-forced.
+class SaltGenerator {
+ public:
+  virtual ~SaltGenerator() = default;
+
+  // Generates random salt bytes of length `kDefaultSaltLength`.
+  virtual std::string Generate() const = 0;
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_SALT_SALT_GENERATOR_H_
diff --git a/testing/BUILD b/testing/BUILD
index 94b3444..74e0186 100644
--- a/testing/BUILD
+++ b/testing/BUILD
@@ -119,10 +119,12 @@
 
 cc_library(
     name = "crypto_testing",
+    testonly = False,
     srcs = ["crypto_testing.cc"],
     hdrs = ["crypto_testing.h"],
     deps = [
-        "@abseil-cpp//absl/log:check",
+        "@abseil-cpp//absl/log:absl_check",
+        "@abseil-cpp//absl/strings:string_view",
         "@abseil-cpp//absl/time",
         "@boringssl//:crypto",
     ],
@@ -175,17 +177,27 @@
     srcs = ["pem.cc"],
     hdrs = ["pem.h"],
     deps = [
+        "@abseil-cpp//absl/base:no_destructor",
+        "@abseil-cpp//absl/functional:overload",
         "@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:insecure_secret_key_access",
+        "@tink_cc//tink:key_status",
         "@tink_cc//tink:keyset_handle",
+        "@tink_cc//tink:partial_key_access",
         "@tink_cc//tink:public_key_sign",
+        "@tink_cc//tink/pem:signature_key_parser",
         "@tink_cc//tink/signature:config_v0",
-        "@tink_cc//tink/signature:signature_pem_keyset_reader",
+        "@tink_cc//tink/signature:ecdsa_parameters",
+        "@tink_cc//tink/signature:ecdsa_private_key",
+        "@tink_cc//tink/signature:ed25519_parameters",
+        "@tink_cc//tink/signature:ed25519_private_key",
+        "@tink_cc//tink/signature:rsa_ssa_pss_parameters",
+        "@tink_cc//tink/signature:rsa_ssa_pss_private_key",
+        "@tink_cc//tink/signature:signature_config",
     ],
 )
 
@@ -199,7 +211,9 @@
         "@abseil-cpp//absl/status:status_matchers",
         "@abseil-cpp//absl/strings:string_view",
         "@googletest//:gtest_main",
-        "@tink_cc//tink/signature:signature_pem_keyset_reader",
+        "@tink_cc//tink/signature:ecdsa_parameters",
+        "@tink_cc//tink/signature:ed25519_parameters",
+        "@tink_cc//tink/signature:rsa_ssa_pss_parameters",
     ],
 )
 
@@ -280,7 +294,7 @@
     name = "proto_test_utils",
     hdrs = ["proto_test_utils.h"],
     deps = [
-        "@abseil-cpp//absl/log:check",
+        "@abseil-cpp//absl/log:absl_check",
         "@abseil-cpp//absl/strings:string_view",
         "@googletest//:gtest",
         "@protobuf",
@@ -301,3 +315,174 @@
         "@riegeli//riegeli/bytes:reader",
     ],
 )
+
+cc_library(
+    name = "local_key_crypto_write_handler",
+    testonly = False,
+    srcs = ["local_key_crypto_write_handler.cc"],
+    hdrs = ["local_key_crypto_write_handler.h"],
+    deps = [
+        "//async:async_context",
+        "//crypto:algorithms",
+        "//crypto:crypto_write_handler",
+        "//crypto:timestamp_write_handler",
+        "//tsp:test_helpers",
+        "@abseil-cpp//absl/base:core_headers",
+        "@abseil-cpp//absl/base:no_destructor",
+        "@abseil-cpp//absl/base:nullability",
+        "@abseil-cpp//absl/container:flat_hash_map",
+        "@abseil-cpp//absl/functional:any_invocable",
+        "@abseil-cpp//absl/log:absl_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",
+        "@abseil-cpp//absl/synchronization",
+        "@abseil-cpp//absl/time",
+        "@abseil-cpp//absl/time:clock_interface",
+        "@tink_cc//tink:insecure_secret_key_access",
+        "@tink_cc//tink:key_status",
+        "@tink_cc//tink:keyset_handle",
+        "@tink_cc//tink:partial_key_access",
+        "@tink_cc//tink:public_key_sign",
+        "@tink_cc//tink/pem:signature_key_parser",
+        "@tink_cc//tink/signature:config_v0",
+        "@tink_cc//tink/signature:ecdsa_parameters",
+        "@tink_cc//tink/signature:ecdsa_private_key",
+        "@tink_cc//tink/signature:signature_config",
+    ],
+)
+
+cc_library(
+    name = "create_test_crypto_write_handler",
+    testonly = False,
+    srcs = ["create_test_crypto_write_handler.cc"],
+    hdrs = ["create_test_crypto_write_handler.h"],
+    deps = [
+        ":crypto_testing",
+        ":local_key_crypto_write_handler",
+        "//crypto:crypto_write_handler",
+        "@abseil-cpp//absl/base:no_destructor",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/time:clock_interface",
+    ],
+)
+
+cc_test(
+    name = "create_test_crypto_write_handler_test",
+    srcs = ["create_test_crypto_write_handler_test.cc"],
+    deps = [
+        ":create_test_crypto_write_handler",
+        ":local_key_crypto_write_handler",
+        ":test_validation_tracker",
+        "//cose:simple_cms_parser",
+        "//crypto:algorithms",
+        "//crypto:crypto_write_handler",
+        "//crypto/default:trust_store",
+        "//tsp:parsed_timestamp_token",
+        "//tsp:timestamp_verifier",
+        "//tsp:verified_timestamp",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings:string_view",
+        "@abseil-cpp//absl/synchronization",
+        "@abseil-cpp//absl/time",
+        "@abseil-cpp//absl/time:simulated_clock",
+        "@boringssl//:pki",
+        "@googletest//:gtest_main",
+    ],
+)
+
+cc_library(
+    name = "manifest_store_test_utils",
+    srcs = ["manifest_store_test_utils.cc"],
+    hdrs = ["manifest_store_test_utils.h"],
+    deps = [
+        ":create_test_crypto_write_handler",
+        ":proto_test_utils",
+        ":test_validation_tracker",
+        "//claim:local_signer",
+        "//claim:validator",
+        "//cose:verifier",
+        "//crypto:crypto_read_handler",
+        "//crypto:crypto_write_handler",
+        "//crypto/default:default_crypto_read_handler",
+        "//crypto/default:hasher",
+        "//generator",
+        "//generator:generator_factory",
+        "//jumbf:box",
+        "//jumbf:parse",
+        "//proto:generator_cc_proto",
+        "//proto:generator_info_cc_proto",
+        "//proto:manifest_cc_proto",
+        "//proto:validation_result_cc_proto",
+        "//uuid",
+        "//validator:manifest_store_validator_impl",
+        "@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:clock_interface",
+    ],
+)
+
+cc_library(
+    name = "nonprod_generator",
+    srcs = ["nonprod_generator.cc"],
+    hdrs = ["nonprod_generator.h"],
+    deps = [
+        ":create_test_crypto_write_handler",
+        "//async:async_context",
+        "//claim:local_signer",
+        "//crypto:crypto_write_handler",
+        "//crypto:hash",
+        "//crypto:templated_crypto_write_handler",
+        "//crypto/default:hasher",
+        "//generator",
+        "//generator:generator_factory",
+        "//generator:generator_options",
+        "//proto:generator_cc_proto",
+        "//proto:generator_info_cc_proto",
+        "//uuid",
+        "@abseil-cpp//absl/functional:any_invocable",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/time:clock_interface",
+    ],
+)
+
+cc_library(
+    name = "nonprod_validator",
+    srcs = ["nonprod_validator.cc"],
+    hdrs = ["nonprod_validator.h"],
+    deps = [
+        ":create_test_crypto_write_handler",
+        ":crypto_testing",
+        "//crypto:crypto_read_handler",
+        "//crypto/default:default_crypto_read_handler",
+        "//validator:asset_validator",
+        "//validator:asset_validator_impl",
+        "//validator:validator_options",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/time:clock_interface",
+    ],
+)
+
+cc_library(
+    name = "fake_manifest_store_generator",
+    hdrs = ["fake_manifest_store_generator.h"],
+    deps = [
+        "//async:async_context",
+        "//generator",
+        "//proto:generator_cc_proto",
+        "@abseil-cpp//absl/functional:any_invocable",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings:string_view",
+    ],
+)
diff --git a/testing/create_test_crypto_write_handler.cc b/testing/create_test_crypto_write_handler.cc
new file mode 100644
index 0000000..725e0c5
--- /dev/null
+++ b/testing/create_test_crypto_write_handler.cc
@@ -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.
+//
+
+#include "testing/create_test_crypto_write_handler.h"
+
+#include <memory>
+#include <utility>
+
+#include "absl/base/no_destructor.h"
+#include "absl/status/statusor.h"
+#include "crypto/crypto_write_handler.h"
+#include "testing/crypto_testing.h"
+#include "testing/local_key_crypto_write_handler.h"
+
+namespace credentio {
+
+const TestPki& DefaultTestPki() {
+  static const absl::NoDestructor<TestPki> kPki(CreateTestPki());
+  return *kPki;
+}
+
+LocalKeyClaimSignerCredentials TestClaimSignerCredentials() {
+  const TestPki& pki = DefaultTestPki();
+  return LocalKeyClaimSignerCredentials{
+      .key_pem = pki.claim_signer.key_pem,
+      .certificate_chain = {pki.claim_signer.cert_der},
+      .ocsp_responses = {"test_ocsp_response"},
+  };
+}
+
+LocalKeyTsaCredentials TestTsaCredentials() {
+  const TestPki& pki = DefaultTestPki();
+  return LocalKeyTsaCredentials{
+      .key_pem = pki.tsa.key_pem,
+      .cert_pem = pki.tsa.cert_pem,
+  };
+}
+
+absl::StatusOr<std::unique_ptr<CryptoWriteHandler>>
+CreateTestCryptoWriteHandler(TestCryptoWriteHandlerOptions options) {
+  return CreateLocalKeyCryptoWriteHandler({
+      .claim_signer = options.claim_signer.has_value()
+                          ? *std::move(options.claim_signer)
+                          : TestClaimSignerCredentials(),
+      .tsa = options.tsa.has_value() ? *std::move(options.tsa)
+                                     : TestTsaCredentials(),
+      .clock = options.clock,
+      .disable_timestamping = options.disable_timestamping,
+  });
+}
+
+}  // namespace credentio
diff --git a/testing/create_test_crypto_write_handler.h b/testing/create_test_crypto_write_handler.h
new file mode 100644
index 0000000..5d99c37
--- /dev/null
+++ b/testing/create_test_crypto_write_handler.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_TESTING_CREATE_TEST_CRYPTO_WRITE_HANDLER_H_
+#define THIRD_PARTY_CREDENTIO_TESTING_CREATE_TEST_CRYPTO_WRITE_HANDLER_H_
+
+#include <memory>
+#include <optional>
+
+#include "absl/status/statusor.h"
+#include "absl/time/clock_interface.h"
+#include "crypto/crypto_write_handler.h"
+#include "testing/crypto_testing.h"
+#include "testing/local_key_crypto_write_handler.h"
+
+namespace credentio {
+
+// Returns the default `TestPki` used by `TestClaimSignerCredentials()` and
+// `TestTsaCredentials()`.
+const TestPki& DefaultTestPki();
+
+// Returns the claim signer credentials of `DefaultTestPki()`.
+LocalKeyClaimSignerCredentials TestClaimSignerCredentials();
+
+// Returns the timestamp authority credentials of `DefaultTestPki()`.
+LocalKeyTsaCredentials TestTsaCredentials();
+
+struct TestCryptoWriteHandlerOptions {
+  // If not provided, `absl::Clock::GetRealClock()` is used.
+  absl::Clock* clock = nullptr;
+  // If true, timestamping is disabled and returns UNAVAILABLE.
+  bool disable_timestamping = false;
+  // Credentials to sign claims with. If not provided,
+  // TestClaimSignerCredentials() is used.
+  std::optional<LocalKeyClaimSignerCredentials> claim_signer;
+  // Credentials to sign timestamps with. If not provided,
+  // TestTsaCredentials() is used.
+  std::optional<LocalKeyTsaCredentials> tsa;
+};
+
+// Creates a CryptoWriteHandler for testing. By default it uses claim and
+// timestamp signers from `DefaultTestPki()`; see TestCryptoWriteHandlerOptions
+// to supply your own.
+absl::StatusOr<std::unique_ptr<CryptoWriteHandler>>
+CreateTestCryptoWriteHandler(TestCryptoWriteHandlerOptions options = {});
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_TESTING_CREATE_TEST_CRYPTO_WRITE_HANDLER_H_
diff --git a/testing/create_test_crypto_write_handler_test.cc b/testing/create_test_crypto_write_handler_test.cc
new file mode 100644
index 0000000..caaa26c
--- /dev/null
+++ b/testing/create_test_crypto_write_handler_test.cc
@@ -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.
+//
+
+#include "testing/create_test_crypto_write_handler.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/string_view.h"
+#include "absl/synchronization/notification.h"
+#include "absl/time/simulated_clock.h"
+#include "absl/time/time.h"
+#include "cose/simple_cms_parser.h"
+#include "crypto/algorithms.h"
+#include "crypto/crypto_write_handler.h"
+#include "crypto/default/trust_store.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "openssl/pki/verify.h"
+#include "testing/local_key_crypto_write_handler.h"
+#include "testing/test_validation_tracker.h"
+#include "tsp/parsed_timestamp_token.h"
+#include "tsp/timestamp_verifier.h"
+#include "tsp/verified_timestamp.h"
+
+namespace credentio {
+namespace {
+
+using ::absl_testing::IsOk;
+using ::absl_testing::StatusIs;
+using ::testing::ElementsAreArray;
+using ::testing::HasSubstr;
+using ::testing::IsEmpty;
+using ::testing::Not;
+
+absl::StatusOr<std::string> GetTimestamp(CryptoWriteHandler& handler,
+                                         HashAlgorithm hash_algorithm,
+                                         absl::string_view hashed_message) {
+  absl::Notification done;
+  absl::StatusOr<std::string> result;
+  handler.CreateTimestamp(hash_algorithm, std::string(hashed_message),
+                          /*options=*/{},
+                          [&result, &done](absl::StatusOr<std::string> res) {
+                            result = std::move(res);
+                            done.Notify();
+                          });
+  done.WaitForNotification();
+  return result;
+}
+
+TEST(CreateTestCryptoWriteHandlerTest, CreateTimestamp) {
+  absl::SimulatedClock clock;
+  absl::Time now = absl::FromUnixSeconds(1739221301);  // within validity period
+  clock.SetTime(now);
+  std::string message_imprint = "message_imprint";
+  absl::StatusOr<std::unique_ptr<CryptoWriteHandler>> writer =
+      CreateTestCryptoWriteHandler({.clock = &clock});
+  ABSL_ASSERT_OK(writer);
+  absl::StatusOr<std::string> created_timestamp =
+      GetTimestamp(**writer, HashAlgorithm::kSha384, message_imprint);
+  ABSL_ASSERT_OK(created_timestamp);
+
+  absl::StatusOr<std::unique_ptr<bssl::VerifyTrustStore>> trust_store =
+      LoadTrustStore(DefaultTestPki().root_ca.cert_pem);
+  ABSL_ASSERT_OK(trust_store);
+  TimestampVerifier verifier(trust_store->get());
+  SimpleCmsParser parser;
+
+  TestValidationTracker t;
+  absl::StatusOr<std::unique_ptr<ParsedTimestampToken>> parsed_token =
+      parser.ParseTimestampToken(*created_timestamp);
+  ABSL_ASSERT_OK(parsed_token);
+  absl::StatusOr<VerifiedTimestamp> verified_timestamp =
+      verifier.VerifyTimestampToken(**parsed_token);
+  ABSL_ASSERT_OK(verified_timestamp);
+  ASSERT_THAT(t.GetFailureStatuses(), IsEmpty());
+  EXPECT_EQ(verified_timestamp->message_imprint_hash(), message_imprint);
+  EXPECT_EQ(verified_timestamp->asserted_time(), now);
+}
+
+TEST(CreateTestCryptoWriteHandlerTest, HasCertChainAndOcspResponse) {
+  absl::StatusOr<std::unique_ptr<CryptoWriteHandler>> writer =
+      CreateTestCryptoWriteHandler({});
+  ABSL_ASSERT_OK(writer);
+  EXPECT_THAT((*writer)->GetSigner().certificate_chain(), Not(IsEmpty()));
+  EXPECT_THAT((*writer)->GetSigner().ocsp_responses(), Not(IsEmpty()));
+}
+
+TEST(CreateTestCryptoWriteHandlerTest,
+     UsesConfiguredCertChainAndOcspResponses) {
+  LocalKeyClaimSignerCredentials credentials = TestClaimSignerCredentials();
+  credentials.ocsp_responses.clear();
+  absl::StatusOr<std::unique_ptr<CryptoWriteHandler>> writer =
+      CreateTestCryptoWriteHandler({.claim_signer = std::move(credentials)});
+  ABSL_ASSERT_OK(writer);
+
+  EXPECT_THAT((*writer)->GetSigner().certificate_chain(),
+              ElementsAreArray(TestClaimSignerCredentials().certificate_chain));
+  EXPECT_THAT((*writer)->GetSigner().ocsp_responses(), IsEmpty());
+}
+
+TEST(CreateTestCryptoWriteHandlerTest, UsesConfiguredSigningKey) {
+  // Any other valid EC P-256 key will do; reuse the TSA's.
+  LocalKeyClaimSignerCredentials credentials = TestClaimSignerCredentials();
+  credentials.key_pem = TestTsaCredentials().key_pem;
+  absl::StatusOr<std::unique_ptr<CryptoWriteHandler>> writer =
+      CreateTestCryptoWriteHandler({.claim_signer = std::move(credentials)});
+  ABSL_ASSERT_OK(writer);
+  absl::StatusOr<std::unique_ptr<CryptoWriteHandler>> default_writer =
+      CreateTestCryptoWriteHandler({});
+  ABSL_ASSERT_OK(default_writer);
+
+  absl::StatusOr<SignResponse> response =
+      (*writer)->GetSigner().Sign("to_be_signed");
+  ABSL_ASSERT_OK(response);
+  absl::StatusOr<SignResponse> default_response =
+      (*default_writer)->GetSigner().Sign("to_be_signed");
+  ABSL_ASSERT_OK(default_response);
+  EXPECT_NE(response->signature, default_response->signature);
+}
+
+TEST(CreateTestCryptoWriteHandlerTest, UsesConfiguredTsa) {
+  absl::SimulatedClock clock;
+  clock.SetTime(absl::FromUnixSeconds(1739221301));  // within validity period
+  absl::StatusOr<std::unique_ptr<CryptoWriteHandler>> writer =
+      CreateTestCryptoWriteHandler(
+          {.clock = &clock, .tsa = TestTsaCredentials()});
+  ABSL_ASSERT_OK(writer);
+
+  EXPECT_THAT(GetTimestamp(**writer, HashAlgorithm::kSha384, "message_imprint"),
+              IsOk());
+}
+
+TEST(CreateTestCryptoWriteHandlerTest,
+     RejectsIncompleteClaimSignerCredentials) {
+  EXPECT_THAT(CreateTestCryptoWriteHandler({.claim_signer = {{}}}),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("Claim signer key")));
+
+  LocalKeyClaimSignerCredentials no_chain = TestClaimSignerCredentials();
+  no_chain.certificate_chain.clear();
+  EXPECT_THAT(
+      CreateTestCryptoWriteHandler({.claim_signer = std::move(no_chain)}),
+      StatusIs(absl::StatusCode::kInvalidArgument,
+               HasSubstr("Claim signer certificate chain")));
+}
+
+TEST(CreateTestCryptoWriteHandlerTest, RejectsIncompleteTsaCredentials) {
+  EXPECT_THAT(
+      CreateTestCryptoWriteHandler({.tsa = {{}}}),
+      StatusIs(absl::StatusCode::kInvalidArgument, HasSubstr("TSA key")));
+
+  LocalKeyTsaCredentials no_cert = TestTsaCredentials();
+  no_cert.cert_pem.clear();
+  EXPECT_THAT(CreateTestCryptoWriteHandler({.tsa = std::move(no_cert)}),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("TSA certificate")));
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/testing/crypto_testing.cc b/testing/crypto_testing.cc
index 7df4c10..2038bb4 100644
--- a/testing/crypto_testing.cc
+++ b/testing/crypto_testing.cc
@@ -16,125 +16,267 @@
 #include "testing/crypto_testing.h"
 
 #include <cstddef>
+#include <cstdint>
 #include <string>
+#include <utility>
 
-#include "absl/log/check.h"
+#include "absl/log/absl_check.h"
+#include "absl/strings/string_view.h"
 #include "absl/time/time.h"
 #include "openssl/asn1.h"
 #include "openssl/base.h"
+#include "openssl/bio.h"
 #include "openssl/bn.h"
 #include "openssl/digest.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/rsa.h"
+#include "openssl/stack.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);
+  ABSL_CHECK_NE(key, nullptr);
 
-  CHECK(EC_KEY_generate_key(key.get()));
+  ABSL_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()));
+  ABSL_CHECK_NE(pkey, nullptr);
+  ABSL_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));
+  ABSL_CHECK_NE(rsa_f4, nullptr);
+  ABSL_CHECK(BN_set_word(rsa_f4.get(), RSA_F4));
 
   bssl::UniquePtr<RSA> rsa(RSA_new());
-  CHECK_NE(rsa, nullptr);
+  ABSL_CHECK_NE(rsa, nullptr);
 
-  CHECK(RSA_generate_key_ex(rsa.get(), num_bits, rsa_f4.get(), nullptr));
+  ABSL_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()));
+  ABSL_CHECK_NE(pkey, nullptr);
+  ABSL_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()));
+  ABSL_CHECK_NE(ctx, nullptr);
+  ABSL_CHECK(EVP_PKEY_keygen_init(ctx.get()));
   EVP_PKEY* pkey = nullptr;
-  CHECK(EVP_PKEY_keygen(ctx.get(), &pkey));
+  ABSL_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);
+  ABSL_CHECK(!options.subject_common_name.empty());
+  ABSL_CHECK(!options.issuer_common_name.empty());
+  ABSL_CHECK(options.signing_key != nullptr);
+  ABSL_CHECK(options.private_key != nullptr);
 
   // Create the X509 object.
   bssl::UniquePtr<X509> x509(X509_new());
-  CHECK(X509_set_version(x509.get(), 2));
+  ABSL_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()));
+  ABSL_CHECK(ASN1_TIME_set_posix(not_before.get(),
+                                 absl::ToUnixSeconds(options.not_before)));
+  ABSL_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()));
+  ABSL_CHECK(ASN1_TIME_set_posix(not_after.get(),
+                                 absl::ToUnixSeconds(options.not_after)));
+  ABSL_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(
+  ABSL_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));
+  ABSL_CHECK(X509_NAME_add_entry_by_txt(
       subject_name.get(), /*field=*/"O", MBSTRING_ASC,
-      reinterpret_cast<const unsigned char*>("Google"),
+      reinterpret_cast<const unsigned char*>("Test Org"),
       /*len=*/-1, /*loc=*/-1,
       /*set=*/0));
-  CHECK(X509_NAME_add_entry_by_txt(
+  ABSL_CHECK(X509_NAME_add_entry_by_txt(
       subject_name.get(), /*field=*/"OU", MBSTRING_ASC,
-      reinterpret_cast<const unsigned char*>("Google OU"),
+      reinterpret_cast<const unsigned char*>("Test Unit"),
       /*len=*/-1, /*loc=*/-1,
       /*set=*/0));
-  CHECK(X509_set_subject_name(x509.get(), subject_name.get()));
+  ABSL_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(
+  ABSL_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));
+  ABSL_CHECK(X509_NAME_add_entry_by_txt(
       issuer_name.get(), /*field=*/"O", MBSTRING_ASC,
-      reinterpret_cast<const unsigned char*>("Google"),
+      reinterpret_cast<const unsigned char*>("Test Org"),
       /*len=*/-1, /*loc=*/-1,
       /*set=*/0));
-  CHECK(X509_NAME_add_entry_by_txt(
+  ABSL_CHECK(X509_NAME_add_entry_by_txt(
       issuer_name.get(), /*field=*/"OU", MBSTRING_ASC,
-      reinterpret_cast<const unsigned char*>("Google OU"),
+      reinterpret_cast<const unsigned char*>("Test Unit"),
       /*len=*/-1, /*loc=*/-1,
       /*set=*/0));
-  CHECK(X509_set_issuer_name(x509.get(), issuer_name.get()));
+  ABSL_CHECK(X509_set_issuer_name(x509.get(), issuer_name.get()));
+
+  if (options.serial_number.has_value()) {
+    ABSL_CHECK(ASN1_INTEGER_set_int64(X509_get_serialNumber(x509.get()),
+                                      *options.serial_number));
+  }
+
+  if (options.is_ca.has_value()) {
+    bssl::UniquePtr<BASIC_CONSTRAINTS> basic_constraints(
+        BASIC_CONSTRAINTS_new());
+    basic_constraints->ca = *options.is_ca ? 1 : 0;
+    ABSL_CHECK(X509_add1_ext_i2d(x509.get(), NID_basic_constraints,
+                                 basic_constraints.get(), /*crit=*/1,
+                                 /*flags=*/0));
+
+    bssl::UniquePtr<ASN1_BIT_STRING> key_usage(ASN1_BIT_STRING_new());
+    if (*options.is_ca) {
+      ABSL_CHECK(
+          ASN1_BIT_STRING_set_bit(key_usage.get(), /*n=*/5, /*value=*/1));
+      ABSL_CHECK(
+          ASN1_BIT_STRING_set_bit(key_usage.get(), /*n=*/6, /*value=*/1));
+    } else {
+      ABSL_CHECK(
+          ASN1_BIT_STRING_set_bit(key_usage.get(), /*n=*/0, /*value=*/1));
+    }
+    ABSL_CHECK(X509_add1_ext_i2d(x509.get(), NID_key_usage, key_usage.get(),
+                                 /*crit=*/1, /*flags=*/0));
+  }
+
+  if (!options.extended_key_usage_oids.empty()) {
+    bssl::UniquePtr<EXTENDED_KEY_USAGE> eku(EXTENDED_KEY_USAGE_new());
+    for (const std::string& oid : options.extended_key_usage_oids) {
+      bssl::UniquePtr<ASN1_OBJECT> obj(
+          OBJ_txt2obj(oid.c_str(), /*dont_search_names=*/1));
+      ABSL_CHECK(obj != nullptr);
+      ABSL_CHECK(bssl::PushToStack(eku.get(), std::move(obj)));
+    }
+    ABSL_CHECK(X509_add1_ext_i2d(x509.get(), NID_ext_key_usage, eku.get(),
+                                 options.extended_key_usage_critical ? 1 : 0,
+                                 /*flags=*/0));
+  }
 
   // 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()));
+  ABSL_CHECK(X509_set_pubkey(x509.get(), options.private_key));
+  ABSL_CHECK(X509_sign(x509.get(), options.signing_key, EVP_sha256()));
 
   return x509;
 }
 
+std::string KeyToPem(EVP_PKEY* key) {
+  bssl::UniquePtr<BIO> bio(BIO_new(BIO_s_mem()));
+  ABSL_CHECK(bio != nullptr);
+  ABSL_CHECK(PEM_write_bio_PrivateKey(bio.get(), key, /*enc=*/nullptr,
+                                      /*pass=*/nullptr, /*pass_len=*/0,
+                                      /*cb=*/nullptr, /*userdata=*/nullptr));
+  const uint8_t* data = nullptr;
+  size_t len = 0;
+  ABSL_CHECK(BIO_mem_contents(bio.get(), &data, &len));
+  return std::string(reinterpret_cast<const char*>(data), len);
+}
+
+std::string CertToPem(X509* cert) {
+  bssl::UniquePtr<BIO> bio(BIO_new(BIO_s_mem()));
+  ABSL_CHECK(bio != nullptr);
+  ABSL_CHECK(PEM_write_bio_X509(bio.get(), cert));
+  const uint8_t* data = nullptr;
+  size_t len = 0;
+  ABSL_CHECK(BIO_mem_contents(bio.get(), &data, &len));
+  return std::string(reinterpret_cast<const char*>(data), len);
+}
+
+std::string CertToDer(X509* cert) {
+  int der_len = i2d_X509(cert, nullptr);
+  ABSL_CHECK_GT(der_len, 0);
+  std::string der(der_len, '\0');
+  unsigned char* p = reinterpret_cast<unsigned char*>(&der[0]);
+  ABSL_CHECK_EQ(i2d_X509(cert, &p), der_len);
+  return der;
+}
+
+TestPki CreateTestPki() {
+  constexpr absl::string_view kRootCaCommonName = "Test Root CA";
+
+  bssl::UniquePtr<EVP_PKEY> ca_key = CreateTestEcKey(NID_X9_62_prime256v1);
+  bssl::UniquePtr<X509> ca_cert = CreateTestCertificate({
+      .subject_common_name = std::string(kRootCaCommonName),
+      .issuer_common_name = std::string(kRootCaCommonName),
+      .signing_key = ca_key.get(),
+      .private_key = ca_key.get(),
+      .serial_number = 1,
+      .is_ca = true,
+  });
+
+  bssl::UniquePtr<EVP_PKEY> claim_signer_key =
+      CreateTestEcKey(NID_X9_62_prime256v1);
+  bssl::UniquePtr<X509> claim_signer_cert = CreateTestCertificate({
+      .subject_common_name = "Test Claim Signer",
+      .issuer_common_name = std::string(kRootCaCommonName),
+      .signing_key = ca_key.get(),
+      .private_key = claim_signer_key.get(),
+      .serial_number = 2,
+      .is_ca = false,
+      .extended_key_usage_oids = {std::string(kC2paClaimSigningEkuOid)},
+  });
+
+  bssl::UniquePtr<EVP_PKEY> tsa_key = CreateTestEcKey(NID_X9_62_prime256v1);
+  bssl::UniquePtr<X509> tsa_cert = CreateTestCertificate({
+      .subject_common_name = "Test TSA",
+      .issuer_common_name = std::string(kRootCaCommonName),
+      .signing_key = ca_key.get(),
+      .private_key = tsa_key.get(),
+      .serial_number = 3,
+      .is_ca = false,
+      // The time stamping EKU must be marked critical per RFC 3161.
+      .extended_key_usage_oids = {std::string(kTimeStampingEkuOid)},
+      .extended_key_usage_critical = true,
+  });
+
+  return TestPki{
+      .root_ca =
+          {
+              .key_pem = KeyToPem(ca_key.get()),
+              .cert_pem = CertToPem(ca_cert.get()),
+              .cert_der = CertToDer(ca_cert.get()),
+          },
+      .claim_signer =
+          {
+              .key_pem = KeyToPem(claim_signer_key.get()),
+              .cert_pem = CertToPem(claim_signer_cert.get()),
+              .cert_der = CertToDer(claim_signer_cert.get()),
+          },
+      .tsa =
+          {
+              .key_pem = KeyToPem(tsa_key.get()),
+              .cert_pem = CertToPem(tsa_cert.get()),
+              .cert_der = CertToDer(tsa_cert.get()),
+          },
+  };
+}
+
 }  // namespace credentio
diff --git a/testing/crypto_testing.h b/testing/crypto_testing.h
index 946cf4e..cf21b67 100644
--- a/testing/crypto_testing.h
+++ b/testing/crypto_testing.h
@@ -18,21 +18,44 @@
 #define THIRD_PARTY_CREDENTIO_TESTING_CRYPTO_TESTING_H_
 
 #include <cstddef>
+#include <cstdint>
+#include <optional>
 #include <string>
+#include <vector>
 
+#include "absl/strings/string_view.h"
 #include "absl/time/time.h"
 #include "openssl/base.h"
 #include "openssl/x509.h"
 
 namespace credentio {
 
+// OID of the C2PA claim signing EKU. See
+// https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_trust_lists.
+inline constexpr absl::string_view kC2paClaimSigningEkuOid =
+    "1.3.6.1.4.1.62558.2.1";
+// OID of the `id-kp-timeStamping` EKU (RFC 5280).
+inline constexpr absl::string_view kTimeStampingEkuOid = "1.3.6.1.5.5.7.3.8";
+
 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);
+  absl::Time not_after = absl::FromUnixSeconds(int64_t{86400} * 365 * 100);
   EVP_PKEY* signing_key = nullptr;
   EVP_PKEY* private_key = nullptr;
+
+  // If set, the certificate's serial number is set to this value.
+  std::optional<int64_t> serial_number;
+
+  // If set, adds a critical `basicConstraints` extension with the given `cA`
+  // value, along with a critical `keyUsage` extension asserting `keyCertSign`
+  // and `cRLSign` when true, or `digitalSignature` when false.
+  std::optional<bool> is_ca;
+
+  // If non-empty, adds an `extendedKeyUsage` extension with these OIDs.
+  std::vector<std::string> extended_key_usage_oids;
+  bool extended_key_usage_critical = false;
 };
 
 // Generates an X.509 certificate based on `options`.
@@ -45,6 +68,36 @@
 
 bssl::UniquePtr<EVP_PKEY> CreateTestEdKey();
 
+// Returns the PEM encoding of `key`. CHECK-fails on error.
+std::string KeyToPem(EVP_PKEY* key);
+
+// Returns the PEM encoding of `cert`. CHECK-fails on error.
+std::string CertToPem(X509* cert);
+
+// Returns the DER encoding of `cert`. CHECK-fails on error.
+std::string CertToDer(X509* cert);
+
+// A generated private key with its certificate, in the encodings that C2PA
+// APIs consume.
+struct TestKeyPair {
+  std::string key_pem;
+  std::string cert_pem;
+  std::string cert_der;
+};
+
+// A self-signed root CA along with the leaf certificates it issued, suitable
+// for exercising C2PA claim signer and TSA trust checks end to end.
+struct TestPki {
+  TestKeyPair root_ca;
+  // Leaf with the C2PA claim signing EKU, issued by `root_ca`.
+  TestKeyPair claim_signer;
+  // Leaf with the time stamping EKU, issued by `root_ca`.
+  TestKeyPair tsa;
+};
+
+// Creates a `TestPki` using freshly generated EC P-256 keys.
+TestPki CreateTestPki();
+
 }  // namespace credentio
 
 #endif  // THIRD_PARTY_CREDENTIO_TESTING_CRYPTO_TESTING_H_
diff --git a/testing/fake_manifest_store_generator.h b/testing/fake_manifest_store_generator.h
new file mode 100644
index 0000000..e7f8956
--- /dev/null
+++ b/testing/fake_manifest_store_generator.h
@@ -0,0 +1,147 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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_MANIFEST_STORE_GENERATOR_H_
+#define THIRD_PARTY_CREDENTIO_TESTING_FAKE_MANIFEST_STORE_GENERATOR_H_
+
+#include <string>
+#include <utility>
+
+#include "absl/functional/any_invocable.h"
+#include "absl/status/status.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "async/async_context.h"
+#include "generator/generator.h"
+#include "proto/generator.pb.h"
+
+namespace credentio {
+
+// A fake implementation of ManifestStoreGenerator for testing.
+// By default, it returns a fixed magic string as the manifest store.
+// Allows configuring custom response protos or injecting error status.
+class FakeManifestStoreGenerator : public ManifestStoreGenerator {
+ public:
+  static constexpr absl::string_view kDefaultManifestStore =
+      "fake_manifest_bytes_magic_string";
+
+  explicit FakeManifestStoreGenerator(
+      absl::string_view manifest_store = kDefaultManifestStore) {
+    set_manifest_store(std::string(manifest_store));
+  }
+
+  void set_manifest_store(std::string manifest_store) {
+    generate_response_.set_manifest_store(manifest_store);
+    template_.set_manifest_store_size(manifest_store.size());
+    finalize_response_.set_manifest_store(std::move(manifest_store));
+  }
+
+  const std::string& manifest_store() const {
+    return finalize_response_.manifest_store();
+  }
+
+  void set_generate_manifest_store_response(
+      GenerateManifestStoreResponse response) {
+    generate_response_ = std::move(response);
+  }
+
+  GenerateManifestStoreResponse* mutable_generate_manifest_store_response() {
+    return &generate_response_;
+  }
+
+  const GenerateManifestStoreResponse& generate_manifest_store_response()
+      const {
+    return generate_response_;
+  }
+
+  void set_manifest_store_template(ManifestStoreTemplate response) {
+    template_ = std::move(response);
+  }
+
+  ManifestStoreTemplate* mutable_manifest_store_template() {
+    return &template_;
+  }
+
+  const ManifestStoreTemplate& manifest_store_template() const {
+    return template_;
+  }
+
+  void set_finalize_manifest_store_response(
+      FinalizeManifestStoreResponse response) {
+    finalize_response_ = std::move(response);
+  }
+
+  FinalizeManifestStoreResponse* mutable_finalize_manifest_store_response() {
+    return &finalize_response_;
+  }
+
+  const FinalizeManifestStoreResponse& finalize_manifest_store_response()
+      const {
+    return finalize_response_;
+  }
+
+  void set_status(absl::Status status) { status_ = std::move(status); }
+
+  void set_fail_generate_manifest_store(bool fail) {
+    if (fail) {
+      status_ = absl::InternalError("Manifest generation failed.");
+    } else {
+      status_ = absl::OkStatus();
+    }
+  }
+
+  void GenerateManifestStore(
+      GenerateManifestStoreRequest request, AsyncContext context,
+      absl::AnyInvocable<void(absl::StatusOr<GenerateManifestStoreResponse>) &&>
+          cb) const override {
+    if (!status_.ok()) {
+      std::move(cb)(status_);
+      return;
+    }
+    std::move(cb)(generate_response_);
+  }
+
+  void GenerateManifestStoreTemplate(
+      GenerateManifestStoreRequest request, AsyncContext context,
+      absl::AnyInvocable<void(absl::StatusOr<ManifestStoreTemplate>) &&> cb)
+      const override {
+    if (!status_.ok()) {
+      std::move(cb)(status_);
+      return;
+    }
+    std::move(cb)(template_);
+  }
+
+  void FinalizeManifestStore(
+      FinalizeManifestStoreRequest request, AsyncContext context,
+      absl::AnyInvocable<void(absl::StatusOr<FinalizeManifestStoreResponse>) &&>
+          cb) const override {
+    if (!status_.ok()) {
+      std::move(cb)(status_);
+      return;
+    }
+    std::move(cb)(finalize_response_);
+  }
+
+ private:
+  GenerateManifestStoreResponse generate_response_;
+  ManifestStoreTemplate template_;
+  FinalizeManifestStoreResponse finalize_response_;
+  absl::Status status_ = absl::OkStatus();
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_TESTING_FAKE_MANIFEST_STORE_GENERATOR_H_
diff --git a/testing/local_key_crypto_write_handler.cc b/testing/local_key_crypto_write_handler.cc
new file mode 100644
index 0000000..944bb4f
--- /dev/null
+++ b/testing/local_key_crypto_write_handler.cc
@@ -0,0 +1,270 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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/local_key_crypto_write_handler.h"
+
+#include <cstdint>
+#include <memory>
+#include <optional>
+#include <string>
+#include <utility>
+#include <vector>
+
+#include "absl/base/no_destructor.h"
+#include "absl/base/nullability.h"
+#include "absl/functional/any_invocable.h"
+#include "absl/log/absl_check.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/synchronization/mutex.h"
+#include "absl/time/clock_interface.h"
+#include "absl/time/time.h"
+#include "async/async_context.h"
+#include "crypto/algorithms.h"
+#include "crypto/crypto_write_handler.h"
+#include "crypto/timestamp_write_handler.h"
+#include "tink/insecure_secret_key_access.h"
+#include "tink/key_status.h"
+#include "tink/keyset_handle.h"
+#include "tink/partial_key_access.h"
+#include "tink/pem/signature_key_parser.h"
+#include "tink/public_key_sign.h"
+#include "tink/signature/config_v0.h"
+#include "tink/signature/ecdsa_parameters.h"
+#include "tink/signature/ecdsa_private_key.h"
+#include "tink/signature/signature_config.h"
+#include "tsp/test_helpers.h"
+
+namespace credentio {
+namespace {
+
+using ::crypto::tink::EcdsaParameters;
+using ::crypto::tink::EcdsaPrivateKey;
+using ::crypto::tink::GetPartialKeyAccess;
+using ::crypto::tink::InsecureSecretKeyAccess;
+using ::crypto::tink::KeysetHandle;
+using ::crypto::tink::KeysetHandleBuilder;
+using ::crypto::tink::KeyStatus;
+using ::crypto::tink::PublicKeySign;
+using ::crypto::tink::SignatureConfig;
+
+// Extra bytes reserved on top of a measured signature or timestamp size, since
+// DER-encoded ECDSA signatures vary in length.
+constexpr uint64_t kSizeMargin = 10;
+
+// OCSP responses are dropped once this much time has passed since the signer
+// was created.
+constexpr absl::Duration kOcspResponseLifetime = absl::Hours(24 * 7);
+
+// Returns the OID of `hash_algorithm` (see RFC 5754).
+absl::StatusOr<absl::string_view> HashAlgorithmOid(
+    HashAlgorithm hash_algorithm) {
+  switch (hash_algorithm) {
+    case HashAlgorithm::kSha256:
+      return "2.16.840.1.101.3.4.2.1";
+    case HashAlgorithm::kSha384:
+      return "2.16.840.1.101.3.4.2.2";
+    case HashAlgorithm::kSha512:
+      return "2.16.840.1.101.3.4.2.3";
+    default:
+      return absl::InvalidArgumentError(
+          absl::StrCat("Unsupported hash algorithm: ", hash_algorithm));
+  }
+}
+
+// Returns an ES256 signer for the PEM-encoded EC P-256 private key `key_pem`.
+absl::StatusOr<std::unique_ptr<PublicKeySign>> CreateEs256Signer(
+    absl::string_view key_pem,
+    EcdsaParameters::SignatureEncoding signature_encoding) {
+  // Populates the global registry that the config-less
+  // `KeysetHandleBuilder::Build()` below relies on.
+  static const absl::NoDestructor<absl::Status> registration_status(
+      SignatureConfig::Register());
+  ABSL_RETURN_IF_ERROR(*registration_status);
+
+  ABSL_ASSIGN_OR_RETURN(EcdsaParameters parameters,
+                        EcdsaParameters::Builder()
+                            .SetCurveType(EcdsaParameters::CurveType::kNistP256)
+                            .SetHashType(EcdsaParameters::HashType::kSha256)
+                            .SetSignatureEncoding(signature_encoding)
+                            .SetVariant(EcdsaParameters::Variant::kNoPrefix)
+                            .Build());
+  ABSL_ASSIGN_OR_RETURN(EcdsaPrivateKey private_key,
+                        tink_pem::PemToEcdsaPrivateKey(
+                            key_pem, parameters, InsecureSecretKeyAccess::Get(),
+                            GetPartialKeyAccess()));
+  ABSL_ASSIGN_OR_RETURN(
+      KeysetHandle keyset_handle,
+      KeysetHandleBuilder()
+          .AddEntry(KeysetHandleBuilder::Entry::CreateFromCopyableKey(
+              std::move(private_key), KeyStatus::kEnabled,
+              /*is_primary=*/true))
+          .Build());
+  return keyset_handle.GetPrimitive<PublicKeySign>(
+      crypto::tink::ConfigSignatureV0());
+}
+
+// Returns a DER-encoded RFC 3161 TimeStampToken over `hashed_message`, signed
+// by `tsa_signer` at the current time on `clock`.
+absl::StatusOr<std::string> CreateTimeStampToken(
+    HashAlgorithm hash_algorithm, absl::string_view hashed_message,
+    absl::Clock& clock, const PublicKeySign& tsa_signer,
+    absl::string_view tsa_cert_pem) {
+  ABSL_ASSIGN_OR_RETURN(absl::string_view oid,
+                        HashAlgorithmOid(hash_algorithm));
+  const std::string gen_time =
+      absl::FormatTime("%Y%m%d%H%M%SZ", clock.TimeNow(), absl::UTCTimeZone());
+  const std::string tst_info = CreateTstInfo(gen_time, hashed_message, oid);
+  return CreateTimeStampTokenEcdsa({tst_info}, tsa_signer, tsa_cert_pem,
+                                   /*embed_cert=*/true);
+}
+
+}  // namespace
+
+LocalKeySigner::LocalKeySigner(
+    std::unique_ptr<PublicKeySign> absl_nonnull signer,
+    std::vector<std::string> certificate_chain,
+    std::vector<std::string> ocsp_responses, absl::Clock* absl_nonnull clock,
+    SigningAlgorithm algorithm)
+    : signer_(std::move(ABSL_DIE_IF_NULL(signer))),
+      certificate_chain_(std::move(certificate_chain)),
+      ocsp_responses_(std::move(ocsp_responses)),
+      clock_(ABSL_DIE_IF_NULL(clock)),
+      create_time_(clock_->TimeNow()),
+      algorithm_(algorithm) {}
+
+const std::vector<std::string>& LocalKeySigner::ocsp_responses() const {
+  if (clock_->TimeNow() > create_time_ + kOcspResponseLifetime) {
+    static const absl::NoDestructor<std::vector<std::string>> kNoResponses;
+    return *kNoResponses;
+  }
+  return ocsp_responses_;
+}
+
+uint64_t LocalKeySigner::max_signature_byte_size() const {
+  absl::MutexLock lock(mutex_);
+  if (!max_signature_byte_size_.has_value()) {
+    // The interface cannot report errors, and a signer that cannot sign is
+    // unusable, so crash.
+    absl::StatusOr<SignResponse> response = Sign("measure signature size");
+    ABSL_CHECK_OK(response);
+    max_signature_byte_size_ = response->signature.size() + kSizeMargin;
+  }
+  return *max_signature_byte_size_;
+}
+
+absl::StatusOr<SignResponse> LocalKeySigner::Sign(
+    absl::string_view to_be_signed) const {
+  ABSL_ASSIGN_OR_RETURN(std::string signature, signer_->Sign(to_be_signed));
+  return SignResponse{.signature = std::move(signature)};
+}
+
+LocalKeyCryptoWriteHandler::LocalKeyCryptoWriteHandler(
+    absl::Clock* absl_nonnull clock,
+    std::unique_ptr<PublicKeySign> absl_nonnull signer,
+    std::unique_ptr<PublicKeySign> absl_nullable tsa_signer,
+    bool is_timestamping_disabled, std::vector<std::string> certificate_chain,
+    std::vector<std::string> ocsp_responses, SigningAlgorithm algorithm,
+    std::string tsa_cert_pem)
+    : clock_(ABSL_DIE_IF_NULL(clock)),
+      signer_(std::move(signer), std::move(certificate_chain),
+              std::move(ocsp_responses), clock, algorithm),
+      tsa_signer_(std::move(tsa_signer)),
+      is_timestamping_disabled_(is_timestamping_disabled),
+      tsa_cert_pem_(std::move(tsa_cert_pem)) {}
+
+void LocalKeyCryptoWriteHandler::CreateTimestamp(
+    HashAlgorithm hash_algorithm, std::string hashed_message,
+    TimestampWriteHandler::Options options,
+    absl::AnyInvocable<void(absl::StatusOr<std::string>) &&> cb) const {
+  AsyncResolver<std::string> resolver(options.async_context, std::move(cb));
+  if (is_timestamping_disabled_ || tsa_signer_ == nullptr) {
+    resolver.Return(absl::UnavailableError("TSA is disabled."));
+    return;
+  }
+  resolver.Return(CreateTimeStampToken(hash_algorithm, hashed_message, *clock_,
+                                       *tsa_signer_, tsa_cert_pem_));
+}
+
+uint64_t LocalKeyCryptoWriteHandler::max_timestamp_byte_size(
+    HashAlgorithm hash_algorithm) const {
+  if (tsa_signer_ == nullptr) {
+    return 0;
+  }
+  absl::MutexLock lock(mutex_);
+  auto [it, inserted] =
+      max_timestamp_byte_size_map_.try_emplace(hash_algorithm);
+  if (inserted) {
+    // The interface cannot report errors, and a TSA that cannot sign is
+    // unusable, so crash.
+    absl::StatusOr<std::string> token = CreateTimeStampToken(
+        hash_algorithm, std::string(HashSizeBytes(hash_algorithm), '\0'),
+        *clock_, *tsa_signer_, tsa_cert_pem_);
+    ABSL_CHECK_OK(token);
+    it->second = token->size() + kSizeMargin;
+  }
+  return it->second;
+}
+
+absl::StatusOr<std::unique_ptr<CryptoWriteHandler>>
+CreateLocalKeyCryptoWriteHandler(LocalKeyCryptoWriteHandlerOptions options) {
+  if (options.claim_signer.key_pem.empty()) {
+    return absl::InvalidArgumentError("Claim signer key is missing.");
+  }
+  if (options.claim_signer.certificate_chain.empty()) {
+    return absl::InvalidArgumentError(
+        "Claim signer certificate chain is missing.");
+  }
+  if (!options.disable_timestamping) {
+    if (options.tsa.key_pem.empty()) {
+      return absl::InvalidArgumentError("TSA key is missing.");
+    }
+    if (options.tsa.cert_pem.empty()) {
+      return absl::InvalidArgumentError("TSA certificate is missing.");
+    }
+  } else if (options.tsa.key_pem.empty() != options.tsa.cert_pem.empty()) {
+    return absl::InvalidArgumentError(
+        "TSA key and certificate must either both be provided or both be "
+        "omitted.");
+  }
+
+  // COSE requires raw (IEEE P1363) ECDSA signatures, while CMS in RFC 3161
+  // timestamps requires DER.
+  ABSL_ASSIGN_OR_RETURN(
+      std::unique_ptr<PublicKeySign> signer,
+      CreateEs256Signer(options.claim_signer.key_pem,
+                        EcdsaParameters::SignatureEncoding::kIeeeP1363));
+  std::unique_ptr<PublicKeySign> tsa_signer;
+  if (!options.tsa.key_pem.empty()) {
+    ABSL_ASSIGN_OR_RETURN(
+        tsa_signer,
+        CreateEs256Signer(options.tsa.key_pem,
+                          EcdsaParameters::SignatureEncoding::kDer));
+  }
+  absl::Clock* clock =
+      options.clock != nullptr ? options.clock : &absl::Clock::GetRealClock();
+  return std::make_unique<LocalKeyCryptoWriteHandler>(
+      clock, std::move(signer), std::move(tsa_signer),
+      options.disable_timestamping,
+      std::move(options.claim_signer.certificate_chain),
+      std::move(options.claim_signer.ocsp_responses), SigningAlgorithm::kEs256,
+      std::move(options.tsa.cert_pem));
+}
+
+}  // namespace credentio
diff --git a/testing/local_key_crypto_write_handler.h b/testing/local_key_crypto_write_handler.h
new file mode 100644
index 0000000..b851e37
--- /dev/null
+++ b/testing/local_key_crypto_write_handler.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_TESTING_LOCAL_KEY_CRYPTO_WRITE_HANDLER_H_
+#define THIRD_PARTY_CREDENTIO_TESTING_LOCAL_KEY_CRYPTO_WRITE_HANDLER_H_
+
+#include <cstdint>
+#include <memory>
+#include <optional>
+#include <string>
+#include <vector>
+
+#include "absl/base/nullability.h"
+#include "absl/base/thread_annotations.h"
+#include "absl/container/flat_hash_map.h"
+#include "absl/functional/any_invocable.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "absl/synchronization/mutex.h"
+#include "absl/time/clock_interface.h"
+#include "absl/time/time.h"
+#include "crypto/algorithms.h"
+#include "crypto/crypto_write_handler.h"
+#include "crypto/timestamp_write_handler.h"
+#include "tink/public_key_sign.h"
+
+namespace credentio {
+
+// Test signer that uses a fixed key and a caller-supplied certificate chain.
+//
+// To mimic OCSP responses going stale, `ocsp_responses()` returns an empty list
+// once a week has passed on `clock` since construction.
+//
+// This class is thread-safe.
+class LocalKeySigner final : public FixedKeySigner {
+ public:
+  // `certificate_chain` and `ocsp_responses` are DER-encoded, leaf first.
+  // `clock` must outlive this object.
+  LocalKeySigner(
+      std::unique_ptr<crypto::tink::PublicKeySign> absl_nonnull signer,
+      std::vector<std::string> certificate_chain,
+      std::vector<std::string> ocsp_responses, absl::Clock* absl_nonnull clock,
+      SigningAlgorithm algorithm = SigningAlgorithm::kEs256);
+
+  LocalKeySigner(const LocalKeySigner&) = delete;
+  LocalKeySigner& operator=(const LocalKeySigner&) = delete;
+
+  SigningAlgorithm algorithm() const override { return algorithm_; }
+  const std::vector<std::string>& certificate_chain() const override {
+    return certificate_chain_;
+  }
+  const std::vector<std::string>& ocsp_responses() const override;
+  uint64_t max_signature_byte_size() const override;
+  absl::StatusOr<SignResponse> Sign(
+      absl::string_view to_be_signed) const override;
+
+ private:
+  const std::unique_ptr<crypto::tink::PublicKeySign> absl_nonnull signer_;
+  const std::vector<std::string> certificate_chain_;
+  const std::vector<std::string> ocsp_responses_;
+  absl::Clock* absl_nonnull const clock_;
+  const absl::Time create_time_;
+  const SigningAlgorithm algorithm_;
+
+  mutable absl::Mutex mutex_;
+  mutable std::optional<uint64_t> max_signature_byte_size_
+      ABSL_GUARDED_BY(mutex_);
+};
+
+// Test implementation of CryptoWriteHandler that uses a fixed claim signer and
+// a fixed timestamp signer, both supplied by the caller.
+//
+// For local testing only: keys are held in process memory and timestamps are
+// issued locally from the supplied TSA key rather than by an RFC 3161
+// time-stamping authority.
+//
+// This class is thread-safe.
+class LocalKeyCryptoWriteHandler final : public CryptoWriteHandler {
+ public:
+  // `clock` must outlive this object. If `tsa_signer` is null or
+  // `is_timestamping_disabled` is true, `CreateTimestamp()` fails with
+  // UNAVAILABLE. `tsa_cert_pem` is the PEM-encoded certificate of `tsa_signer`.
+  LocalKeyCryptoWriteHandler(
+      absl::Clock* absl_nonnull clock,
+      std::unique_ptr<crypto::tink::PublicKeySign> absl_nonnull signer,
+      std::unique_ptr<crypto::tink::PublicKeySign> absl_nullable tsa_signer,
+      bool is_timestamping_disabled, std::vector<std::string> certificate_chain,
+      std::vector<std::string> ocsp_responses, SigningAlgorithm algorithm,
+      std::string tsa_cert_pem);
+
+  LocalKeyCryptoWriteHandler(const LocalKeyCryptoWriteHandler&) = delete;
+  LocalKeyCryptoWriteHandler& operator=(const LocalKeyCryptoWriteHandler&) =
+      delete;
+
+  const FixedKeySigner& GetSigner() override { return signer_; }
+
+  void CreateTimestamp(HashAlgorithm hash_algorithm, std::string hashed_message,
+                       TimestampWriteHandler::Options options,
+                       absl::AnyInvocable<void(absl::StatusOr<std::string>) &&>
+                           cb) const override;
+
+  uint64_t max_timestamp_byte_size(HashAlgorithm hash_algorithm) const override;
+
+ private:
+  bool is_timestamping_enabled() const {
+    return !is_timestamping_disabled_ && tsa_signer_ != nullptr;
+  }
+
+  absl::Clock* absl_nonnull const clock_;
+  LocalKeySigner signer_;
+  const std::unique_ptr<crypto::tink::PublicKeySign> absl_nullable tsa_signer_;
+  const bool is_timestamping_disabled_;
+  const std::string tsa_cert_pem_;
+
+  mutable absl::Mutex mutex_;
+  mutable absl::flat_hash_map<HashAlgorithm, uint64_t>
+      max_timestamp_byte_size_map_ ABSL_GUARDED_BY(mutex_);
+};
+
+// Credentials for the test claim signer.
+struct LocalKeyClaimSignerCredentials {
+  // The signing key, as a PEM-encoded EC P-256 private key.
+  std::string key_pem;
+  // The certificate chain for `key_pem`, DER-encoded, leaf first.
+  std::vector<std::string> certificate_chain;
+  // DER-encoded OCSP responses covering `certificate_chain`. May be empty.
+  std::vector<std::string> ocsp_responses;
+};
+
+// Credentials for the test timestamp authority.
+struct LocalKeyTsaCredentials {
+  // The signing key, as a PEM-encoded EC P-256 private key.
+  std::string key_pem;
+  // The certificate for `key_pem`, PEM-encoded.
+  std::string cert_pem;
+};
+
+// Options for `CreateLocalKeyCryptoWriteHandler()`.
+struct LocalKeyCryptoWriteHandlerOptions {
+  // Credentials to sign claims with. Required.
+  LocalKeyClaimSignerCredentials claim_signer;
+  // Credentials to sign timestamps with. Required unless
+  // `disable_timestamping` is true.
+  LocalKeyTsaCredentials tsa;
+  // The clock to read the signing and timestamping time from. It must be set
+  // to a time within the validity period of the certificates above, and must
+  // outlive the returned handler. If null, the real clock is used.
+  absl::Clock* absl_nullable clock = nullptr;
+  // If true, timestamping is disabled and returns UNAVAILABLE.
+  bool disable_timestamping = false;
+};
+
+// Creates a CryptoWriteHandler that signs claims with ES256 and timestamps with
+// the given test credentials. It performs no validation of its own, so the
+// caller is responsible for pairing the keys with certificates that their
+// verifier trusts.
+absl::StatusOr<std::unique_ptr<CryptoWriteHandler>>
+CreateLocalKeyCryptoWriteHandler(LocalKeyCryptoWriteHandlerOptions options);
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_TESTING_LOCAL_KEY_CRYPTO_WRITE_HANDLER_H_
diff --git a/testing/manifest_store_test_utils.cc b/testing/manifest_store_test_utils.cc
new file mode 100644
index 0000000..dc56727
--- /dev/null
+++ b/testing/manifest_store_test_utils.cc
@@ -0,0 +1,138 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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/manifest_store_test_utils.h"
+
+#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/str_join.h"
+#include "absl/strings/string_view.h"
+#include "claim/local_signer.h"
+#include "claim/validator.h"
+#include "cose/verifier.h"
+#include "crypto/crypto_read_handler.h"
+#include "crypto/crypto_write_handler.h"
+#include "crypto/default/default_crypto_read_handler.h"
+#include "crypto/default/hasher.h"
+#include "generator/generator.h"
+#include "generator/generator_factory.h"
+#include "jumbf/box.h"
+#include "jumbf/parse.h"
+#include "proto/generator.pb.h"
+#include "proto/generator_info.pb.h"
+#include "proto/manifest.pb.h"
+#include "proto/validation_result.pb.h"
+#include "testing/create_test_crypto_write_handler.h"
+#include "testing/proto_test_utils.h"
+#include "testing/test_validation_tracker.h"
+#include "validator/manifest_store_validator_impl.h"
+
+namespace credentio {
+
+absl::StatusOr<std::string> CreateSimpleManifestStoreBytes(
+    const SimpleManifestStoreOptions& options) {
+  ABSL_ASSIGN_OR_RETURN(
+      std::unique_ptr<CryptoWriteHandler> crypto_write_handler,
+      CreateTestCryptoWriteHandler({
+          .clock = options.clock,
+          .disable_timestamping = options.disable_timestamping,
+      }));
+  LocalClaimSigner claim_signer(crypto_write_handler.get(),
+                                &DefaultHasherFactory());
+  GeneratorInfo generator_info;
+  if (options.generator_info.has_value()) {
+    generator_info = *options.generator_info;
+  } else {
+    generator_info.set_name("Test C2PA Generator");
+  }
+  ABSL_ASSIGN_OR_RETURN(
+      std::unique_ptr<ManifestStoreGenerator> manifest_store_generator,
+      CreateGenerator({
+          .generator_info = std::move(generator_info),
+          .signing_claim_signer = &claim_signer,
+          .template_claim_signer = &claim_signer,
+          .uuid_generator = options.uuid_generator,
+      }));
+  auto request = credentio_testing::ParseTextProtoOrDie<
+      GenerateManifestStoreRequest>(R"pb(
+    instance_id: "12345678-1234-1234-1234-123456789012"
+    hard_binding {
+      box_hash {
+        alg: "some alg"
+        boxes { names: "some name" names: "other name" hash: "some hash" }
+      }
+    }
+    actions {
+      created {
+        action_metadata {
+          software_agent { name: "test generator" }
+          description: "created for testing"
+        }
+        digital_source_type: "https://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture"
+      }
+    }
+  )pb");
+  ABSL_ASSIGN_OR_RETURN(
+      auto generated, manifest_store_generator->GenerateManifestStore(request));
+  return std::string(generated.manifest_store());
+}
+
+absl::StatusOr<std::unique_ptr<PartialValidationResultProto>>
+ValidateManifestStore(absl::string_view manifest_store_bytes) {
+  ABSL_ASSIGN_OR_RETURN(
+      std::unique_ptr<CryptoReadHandler> crypto_read_handler,
+      CreateDefaultCryptoReadHandler({
+          .trust_environment = CryptoReadHandler::TrustEnvironment::kAny,
+      }));
+  ManifestStoreValidatorImpl validator({
+      .crypto_read_handler = std::move(crypto_read_handler),
+  });
+  return validator.Validate(manifest_store_bytes);
+}
+
+absl::StatusOr<Claim> ValidateClaim(absl::string_view manifest_store_bytes) {
+  ABSL_ASSIGN_OR_RETURN(auto manifest_store_box,
+                        jumbf::ConsumeSuperBox(&manifest_store_bytes, 9));
+  ABSL_ASSIGN_OR_RETURN(
+      std::unique_ptr<CryptoReadHandler> crypto_read_handler,
+      CreateDefaultCryptoReadHandler({
+          .trust_environment = CryptoReadHandler::TrustEnvironment::kAny,
+      }));
+  std::unique_ptr<CoseVerifier> cose_verifier = CreateCoseVerifier(
+      {.crypto_read_handler = std::move(crypto_read_handler)});
+  ClaimValidatorImpl validator(cose_verifier.get());
+  TestValidationTracker validation_tracker;
+  if (manifest_store_box.contents.empty()) {
+    return absl::InvalidArgumentError("Manifest store is empty");
+  }
+  if (!manifest_store_box.contents.back().Holds<jumbf::SuperBox>()) {
+    return absl::InvalidArgumentError("Last box is not a super box");
+  }
+  jumbf::SuperBox manifest_box =
+      manifest_store_box.contents.back().Get<jumbf::SuperBox>();
+  auto result = validator.Validate(manifest_box, validation_tracker.tracker());
+  if (!validation_tracker.GetFailures().empty()) {
+    return absl::InvalidArgumentError(
+        absl::StrJoin(validation_tracker.GetFailureStatuses(), ";"));
+  }
+  return result.value();
+}
+
+}  // namespace credentio
diff --git a/testing/manifest_store_test_utils.h b/testing/manifest_store_test_utils.h
new file mode 100644
index 0000000..73b857d
--- /dev/null
+++ b/testing/manifest_store_test_utils.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.
+//
+
+// Utilities for creating and validating C2PA manifest stores in tests.
+#ifndef THIRD_PARTY_CREDENTIO_TESTING_MANIFEST_STORE_TEST_UTILS_H_
+#define THIRD_PARTY_CREDENTIO_TESTING_MANIFEST_STORE_TEST_UTILS_H_
+
+#include <memory>
+#include <optional>
+#include <string>
+
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "absl/time/clock_interface.h"
+#include "proto/generator_info.pb.h"
+#include "proto/manifest.pb.h"
+#include "proto/validation_result.pb.h"
+#include "uuid/uuid.h"
+
+namespace credentio {
+
+struct SimpleManifestStoreOptions {
+  // If omitted, default test claim generator info will be used.
+  std::optional<GeneratorInfo> generator_info = std::nullopt;
+  // Optional. If not provided, `absl::Clock::GetRealClock()` is used.
+  absl::Clock* clock = nullptr;
+  // Optional. If not provided, a default UuidGenerator will be used.
+  const UuidGenerator* uuid_generator = &UuidGenerator::Default();
+  // If true, timestamping is disabled.
+  bool disable_timestamping = false;
+};
+
+// Creates a simple manifest store bytes for testing.
+absl::StatusOr<std::string> CreateSimpleManifestStoreBytes(
+    const SimpleManifestStoreOptions& options = {});
+
+// Validates the given manifest store bytes by returning the partial validation
+// result.
+absl::StatusOr<std::unique_ptr<PartialValidationResultProto>>
+ValidateManifestStore(absl::string_view manifest_store_bytes);
+
+// Validates the claim in the given manifest store bytes and returns the claim.
+absl::StatusOr<Claim> ValidateClaim(absl::string_view manifest_store_bytes);
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_TESTING_MANIFEST_STORE_TEST_UTILS_H_
diff --git a/testing/nonprod_generator.cc b/testing/nonprod_generator.cc
new file mode 100644
index 0000000..6fa099a
--- /dev/null
+++ b/testing/nonprod_generator.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 "testing/nonprod_generator.h"
+
+#include <memory>
+#include <utility>
+
+#include "absl/functional/any_invocable.h"
+#include "absl/status/status_macros.h"
+#include "absl/status/statusor.h"
+#include "async/async_context.h"
+#include "claim/local_signer.h"
+#include "crypto/crypto_write_handler.h"
+#include "crypto/default/hasher.h"
+#include "crypto/hash.h"
+#include "crypto/templated_crypto_write_handler.h"
+#include "generator/generator.h"
+#include "generator/generator_factory.h"
+#include "generator/generator_options.h"
+#include "proto/generator.pb.h"
+#include "proto/generator_info.pb.h"
+#include "testing/create_test_crypto_write_handler.h"
+
+namespace credentio {
+namespace {
+
+class NonProdManifestStoreGenerator : public ManifestStoreGenerator {
+ public:
+  NonProdManifestStoreGenerator(
+      std::unique_ptr<CryptoWriteHandler> signing_handler,
+      std::unique_ptr<TemplatedHasherFactory> template_hash_factory,
+      std::unique_ptr<TemplatedCryptoWriteHandler>
+          template_crypto_write_handler,
+      std::unique_ptr<LocalClaimSigner> template_claim_signer,
+      std::unique_ptr<LocalClaimSigner> signing_claim_signer,
+      std::unique_ptr<ManifestStoreGenerator> generator)
+      : signing_handler_(std::move(signing_handler)),
+        template_hash_factory_(std::move(template_hash_factory)),
+        template_crypto_write_handler_(
+            std::move(template_crypto_write_handler)),
+        template_claim_signer_(std::move(template_claim_signer)),
+        signing_claim_signer_(std::move(signing_claim_signer)),
+        generator_(std::move(generator)) {}
+
+  void GenerateManifestStore(
+      GenerateManifestStoreRequest request, AsyncContext context,
+      absl::AnyInvocable<void(absl::StatusOr<GenerateManifestStoreResponse>) &&>
+          cb) const override {
+    generator_->GenerateManifestStore(std::move(request), std::move(context),
+                                      std::move(cb));
+  }
+
+  void GenerateManifestStoreTemplate(
+      GenerateManifestStoreRequest request, AsyncContext context,
+      absl::AnyInvocable<void(absl::StatusOr<ManifestStoreTemplate>) &&> cb)
+      const override {
+    generator_->GenerateManifestStoreTemplate(
+        std::move(request), std::move(context), std::move(cb));
+  }
+
+  void FinalizeManifestStore(
+      FinalizeManifestStoreRequest request, AsyncContext context,
+      absl::AnyInvocable<void(absl::StatusOr<FinalizeManifestStoreResponse>) &&>
+          cb) const override {
+    generator_->FinalizeManifestStore(std::move(request), std::move(context),
+                                      std::move(cb));
+  }
+
+ private:
+  std::unique_ptr<CryptoWriteHandler> signing_handler_;
+  std::unique_ptr<TemplatedHasherFactory> template_hash_factory_;
+  std::unique_ptr<TemplatedCryptoWriteHandler> template_crypto_write_handler_;
+  std::unique_ptr<LocalClaimSigner> template_claim_signer_;
+  std::unique_ptr<LocalClaimSigner> signing_claim_signer_;
+  std::unique_ptr<ManifestStoreGenerator> generator_;
+};
+
+}  // namespace
+
+absl::StatusOr<std::unique_ptr<ManifestStoreGenerator>>
+CreateNonProdManifestStoreGenerator(
+    const NonProdManifestStoreGeneratorOptions& options) {
+  ABSL_ASSIGN_OR_RETURN(
+      std::unique_ptr<CryptoWriteHandler> crypto_write_handler,
+      CreateTestCryptoWriteHandler({
+          .clock = options.clock,
+          .disable_timestamping = options.disable_timestamping,
+      }));
+
+  const HasherFactory& hash_factory = DefaultHasherFactory();
+  auto template_hash_factory =
+      std::make_unique<TemplatedHasherFactory>(hash_factory.algorithm());
+  auto template_crypto_write_handler =
+      std::make_unique<TemplatedCryptoWriteHandler>(crypto_write_handler.get());
+  auto template_claim_signer = std::make_unique<LocalClaimSigner>(
+      template_crypto_write_handler.get(), template_hash_factory.get());
+  auto signing_claim_signer = std::make_unique<LocalClaimSigner>(
+      crypto_write_handler.get(), &hash_factory);
+
+  GeneratorOptions generator_options;
+  if (options.generator_info.has_value()) {
+    generator_options.generator_info = *options.generator_info;
+  } else {
+    generator_options.generator_info.set_name("Google NonProd C2PA Generator");
+  }
+  generator_options.signing_claim_signer = signing_claim_signer.get();
+  generator_options.template_claim_signer = template_claim_signer.get();
+  generator_options.hash_factory = &hash_factory;
+  if (options.uuid_generator != nullptr) {
+    generator_options.uuid_generator = options.uuid_generator;
+  }
+
+  ABSL_ASSIGN_OR_RETURN(std::unique_ptr<ManifestStoreGenerator> generator,
+                        CreateGenerator(std::move(generator_options)));
+  return std::make_unique<NonProdManifestStoreGenerator>(
+      std::move(crypto_write_handler), std::move(template_hash_factory),
+      std::move(template_crypto_write_handler),
+      std::move(template_claim_signer), std::move(signing_claim_signer),
+      std::move(generator));
+}
+
+}  // namespace credentio
diff --git a/testing/nonprod_generator.h b/testing/nonprod_generator.h
new file mode 100644
index 0000000..44cdbfe
--- /dev/null
+++ b/testing/nonprod_generator.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_NONPROD_GENERATOR_H_
+#define THIRD_PARTY_CREDENTIO_TESTING_NONPROD_GENERATOR_H_
+
+#include <memory>
+#include <optional>
+
+#include "absl/status/statusor.h"
+#include "absl/time/clock_interface.h"
+#include "generator/generator.h"
+#include "proto/generator_info.pb.h"
+#include "uuid/uuid.h"
+
+namespace credentio {
+
+struct NonProdManifestStoreGeneratorOptions {
+  // If omitted, fake claim generator info will be used.
+  std::optional<GeneratorInfo> generator_info = std::nullopt;
+  // Optional. If not provided, the system clock will be used.
+  absl::Clock* clock = nullptr;
+  // Optional. If not provided, a default UuidGenerator will be used.
+  const UuidGenerator* uuid_generator = &UuidGenerator::Default();
+  // If true, timestamping is disabled.
+  bool disable_timestamping = false;
+};
+
+// WARNING: DO NOT USE IN PRODUCTION.
+// WARNING: This signs with UNTRUSTED keys.
+//
+// Creates a non-prod version of ManifestStoreGenerator, using claim and
+// timestamp signers whose certificates are issued by `DefaultTestPki()`.
+absl::StatusOr<std::unique_ptr<ManifestStoreGenerator>>
+CreateNonProdManifestStoreGenerator(
+    const NonProdManifestStoreGeneratorOptions& options = {});
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_TESTING_NONPROD_GENERATOR_H_
diff --git a/testing/nonprod_validator.cc b/testing/nonprod_validator.cc
new file mode 100644
index 0000000..c533e20
--- /dev/null
+++ b/testing/nonprod_validator.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 "testing/nonprod_validator.h"
+
+#include <memory>
+#include <utility>
+
+#include "absl/status/status_macros.h"
+#include "absl/status/statusor.h"
+#include "crypto/crypto_read_handler.h"
+#include "crypto/default/default_crypto_read_handler.h"
+#include "testing/create_test_crypto_write_handler.h"
+#include "testing/crypto_testing.h"
+#include "validator/asset_validator.h"
+#include "validator/asset_validator_impl.h"
+#include "validator/validator_options.h"
+
+namespace credentio {
+
+absl::StatusOr<std::unique_ptr<AssetValidator>> CreateNonProdValidator(
+    const NonProdValidatorOptions& options) {
+  const TestPki& pki = DefaultTestPki();
+  DefaultCryptoReadHandlerOptions crypto_options;
+  crypto_options.claim_signer_trust_anchors_pem = pki.root_ca.cert_pem;
+  crypto_options.tsa_trust_anchors_pem = pki.root_ca.cert_pem;
+  crypto_options.clock = options.clock;
+  ABSL_ASSIGN_OR_RETURN(std::unique_ptr<CryptoReadHandler> crypto_read_handler,
+                        CreateDefaultCryptoReadHandler(crypto_options));
+  return std::make_unique<AssetValidatorImpl>(ValidatorOptions{
+      .crypto_read_handler = std::move(crypto_read_handler),
+      .clock = options.clock,
+      .max_chunk_bytes = options.max_chunk_bytes,
+  });
+}
+
+}  // namespace credentio
diff --git a/testing/nonprod_validator.h b/testing/nonprod_validator.h
new file mode 100644
index 0000000..b24358f
--- /dev/null
+++ b/testing/nonprod_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_TESTING_NONPROD_VALIDATOR_H_
+#define THIRD_PARTY_CREDENTIO_TESTING_NONPROD_VALIDATOR_H_
+
+#include <cstdint>
+#include <memory>
+
+#include "absl/status/statusor.h"
+#include "absl/time/clock_interface.h"
+#include "validator/asset_validator.h"
+
+namespace credentio {
+
+struct NonProdValidatorOptions {
+  // See ValidatorOptions::max_chunk_bytes in
+  // credentio/validator/validator_options.h.
+  int64_t max_chunk_bytes = 1048576;  // 1 MiB
+
+  // Clock to use for checking certificate validity.
+  // Should only be overridden for testing.
+  absl::Clock* clock = &absl::Clock::GetRealClock();
+};
+
+// Creates a new `AssetValidator` instance with the given `options`, trusting
+// certificates issued by `DefaultTestPki()`.
+//
+// Output of this implementation *must not be considered trustworthy*.
+absl::StatusOr<std::unique_ptr<AssetValidator>> CreateNonProdValidator(
+    const NonProdValidatorOptions& options = {});
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_TESTING_NONPROD_VALIDATOR_H_
diff --git a/testing/pem.cc b/testing/pem.cc
index 9844e16..4dea5ac 100644
--- a/testing/pem.cc
+++ b/testing/pem.cc
@@ -15,11 +15,12 @@
 
 #include "testing/pem.h"
 
-#include <cstdint>
 #include <memory>
 #include <string>
-#include <utility>
+#include <variant>
 
+#include "absl/base/no_destructor.h"
+#include "absl/functional/overload.h"
 #include "absl/status/status.h"
 #include "absl/status/status_macros.h"
 #include "absl/status/statusor.h"
@@ -33,31 +34,77 @@
 #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/insecure_secret_key_access.h"
+#include "tink/key_status.h"
 #include "tink/keyset_handle.h"
+#include "tink/partial_key_access.h"
+#include "tink/pem/signature_key_parser.h"
 #include "tink/public_key_sign.h"
 #include "tink/signature/config_v0.h"
-#include "tink/signature/signature_pem_keyset_reader.h"
+#include "tink/signature/ecdsa_parameters.h"
+#include "tink/signature/ecdsa_private_key.h"
+#include "tink/signature/ed25519_parameters.h"
+#include "tink/signature/ed25519_private_key.h"
+#include "tink/signature/rsa_ssa_pss_parameters.h"
+#include "tink/signature/rsa_ssa_pss_private_key.h"
+#include "tink/signature/signature_config.h"
 
 namespace credentio {
 
-using ::crypto::tink::PemKeyParams;
+using ::crypto::tink::EcdsaParameters;
+using ::crypto::tink::Ed25519Parameters;
+using ::crypto::tink::GetPartialKeyAccess;
+using ::crypto::tink::InsecureSecretKeyAccess;
+using ::crypto::tink::KeysetHandle;
+using ::crypto::tink::KeysetHandleBuilder;
 using ::crypto::tink::PublicKeySign;
-using ::crypto::tink::SignaturePemKeysetReaderBuilder;
+using ::crypto::tink::RsaSsaPssParameters;
 
 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>(
+    absl::string_view pem,
+    const std::variant<EcdsaParameters, Ed25519Parameters, RsaSsaPssParameters>&
+        key_params) {
+  static const absl::NoDestructor<absl::Status> registration_status(
+      []() { return crypto::tink::SignatureConfig::Register(); }());
+  ABSL_RETURN_IF_ERROR(*registration_status);
+  using TinkPrivateKey = std::variant<crypto::tink::EcdsaPrivateKey,
+                                      crypto::tink::RsaSsaPssPrivateKey,
+                                      crypto::tink::Ed25519PrivateKey>;
+  absl::StatusOr<TinkPrivateKey> private_key = std::visit(
+      absl::Overload(
+          [&pem](
+              const EcdsaParameters& params) -> absl::StatusOr<TinkPrivateKey> {
+            return tink_pem::PemToEcdsaPrivateKey(
+                pem, params, InsecureSecretKeyAccess::Get(),
+                GetPartialKeyAccess());
+          },
+          [&pem](const RsaSsaPssParameters& params)
+              -> absl::StatusOr<TinkPrivateKey> {
+            return tink_pem::PemToRsaSsaPssPrivateKey(
+                pem, params, InsecureSecretKeyAccess::Get(),
+                GetPartialKeyAccess());
+          },
+          [&pem](const Ed25519Parameters& params)
+              -> absl::StatusOr<TinkPrivateKey> {
+            return tink_pem::PemToEd25519PrivateKey(
+                pem, params, InsecureSecretKeyAccess::Get(),
+                GetPartialKeyAccess());
+          }),
+      key_params);
+  if (!private_key.ok()) {
+    return private_key.status();
+  }
+
+  KeysetHandleBuilder builder;
+  std::visit(
+      [&builder](const auto& key) {
+        builder.AddEntry(KeysetHandleBuilder::Entry::CreateFromCopyableKey(
+            key, crypto::tink::KeyStatus::kEnabled, /*is_primary=*/true));
+      },
+      *private_key);
+
+  ABSL_ASSIGN_OR_RETURN(KeysetHandle handle, builder.Build());
+  return handle.GetPrimitive<crypto::tink::PublicKeySign>(
       crypto::tink::ConfigSignatureV0());
 }
 
@@ -73,7 +120,8 @@
     char* name = nullptr;
     char* header = nullptr;
     unsigned char* data = nullptr;
-    int64_t len = 0;
+    long len = 0;  // NOLINT: Can't use `int64_t` because on ARM that becomes
+                   // long long which is incompatible with `PEM_read_bio(...)`.
     if (!PEM_read_bio(bio.get(), &name, &header, &data, &len)) {
       break;
     }
diff --git a/testing/pem.h b/testing/pem.h
index 234fe7d..c56116b 100644
--- a/testing/pem.h
+++ b/testing/pem.h
@@ -18,18 +18,24 @@
 
 #include <memory>
 #include <string>
+#include <variant>
 
 #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"
+#include "tink/signature/ecdsa_parameters.h"
+#include "tink/signature/ed25519_parameters.h"
+#include "tink/signature/rsa_ssa_pss_parameters.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);
+    absl::string_view pem,
+    const std::variant<crypto::tink::EcdsaParameters,
+                       crypto::tink::Ed25519Parameters,
+                       crypto::tink::RsaSsaPssParameters>& 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.
diff --git a/testing/pem_test.cc b/testing/pem_test.cc
index 7eaa0c9..a096e62 100644
--- a/testing/pem_test.cc
+++ b/testing/pem_test.cc
@@ -23,24 +23,20 @@
 #include "absl/strings/string_view.h"
 #include "gmock/gmock.h"
 #include "gtest/gtest.h"
-#include "tink/signature/signature_pem_keyset_reader.h"
+#include "tink/signature/ecdsa_parameters.h"
+#include "tink/signature/ed25519_parameters.h"
+#include "tink/signature/rsa_ssa_pss_parameters.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 ::crypto::tink::EcdsaParameters;
+using ::crypto::tink::Ed25519Parameters;
+using ::crypto::tink::RsaSsaPssParameters;
 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
@@ -208,110 +204,125 @@
 -----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));
+  absl::StatusOr<EcdsaParameters> params =
+      EcdsaParameters::Builder()
+          .SetCurveType(EcdsaParameters::CurveType::kNistP256)
+          .SetHashType(EcdsaParameters::HashType::kSha256)
+          .SetSignatureEncoding(EcdsaParameters::SignatureEncoding::kDer)
+          .SetVariant(EcdsaParameters::Variant::kNoPrefix)
+          .Build();
+  ABSL_ASSERT_OK(params);
+  EXPECT_THAT(LoadTestKeyFromPem("not pem data", *params),
+              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::StatusOr<EcdsaParameters> params =
+      EcdsaParameters::Builder()
+          .SetCurveType(EcdsaParameters::CurveType::kNistP256)
+          .SetHashType(EcdsaParameters::HashType::kSha256)
+          .SetSignatureEncoding(EcdsaParameters::SignatureEncoding::kIeeeP1363)
+          .SetVariant(EcdsaParameters::Variant::kNoPrefix)
+          .Build();
+  ABSL_ASSERT_OK(params);
+  auto signer = LoadTestKeyFromPem(kPemKeyP256, *params);
   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::StatusOr<EcdsaParameters> params =
+      EcdsaParameters::Builder()
+          .SetCurveType(EcdsaParameters::CurveType::kNistP384)
+          .SetHashType(EcdsaParameters::HashType::kSha384)
+          .SetSignatureEncoding(EcdsaParameters::SignatureEncoding::kIeeeP1363)
+          .SetVariant(EcdsaParameters::Variant::kNoPrefix)
+          .Build();
+  ABSL_ASSERT_OK(params);
+  auto signer = LoadTestKeyFromPem(kPemKeyP384, *params);
   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::StatusOr<EcdsaParameters> params =
+      EcdsaParameters::Builder()
+          .SetCurveType(EcdsaParameters::CurveType::kNistP521)
+          .SetHashType(EcdsaParameters::HashType::kSha512)
+          .SetSignatureEncoding(EcdsaParameters::SignatureEncoding::kIeeeP1363)
+          .SetVariant(EcdsaParameters::Variant::kNoPrefix)
+          .Build();
+  ABSL_ASSERT_OK(params);
+  auto signer = LoadTestKeyFromPem(kPemKeyP521, *params);
   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, ED25519) {
+  absl::StatusOr<Ed25519Parameters> params =
+      Ed25519Parameters::Create(Ed25519Parameters::Variant::kNoPrefix);
+  ABSL_ASSERT_OK(params);
+  auto signer = LoadTestKeyFromPem(kPemKeyEd25519, *params);
+  ABSL_ASSERT_OK(signer);
+  EXPECT_THAT((*signer)->Sign("data to sign"), IsOk());
 }
 
 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::StatusOr<RsaSsaPssParameters> params =
+      RsaSsaPssParameters::Builder()
+          .SetModulusSizeInBits(2048)
+          .SetSigHashType(RsaSsaPssParameters::HashType::kSha256)
+          .SetMgf1HashType(RsaSsaPssParameters::HashType::kSha256)
+          .SetSaltLengthInBytes(32)
+          .SetVariant(RsaSsaPssParameters::Variant::kNoPrefix)
+          .Build();
+  ABSL_ASSERT_OK(params);
+  auto signer = LoadTestKeyFromPem(kPemKeyRsa2048, *params);
   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::StatusOr<RsaSsaPssParameters> params =
+      RsaSsaPssParameters::Builder()
+          .SetModulusSizeInBits(3072)
+          .SetSigHashType(RsaSsaPssParameters::HashType::kSha256)
+          .SetMgf1HashType(RsaSsaPssParameters::HashType::kSha256)
+          .SetSaltLengthInBytes(32)
+          .SetVariant(RsaSsaPssParameters::Variant::kNoPrefix)
+          .Build();
+  ABSL_ASSERT_OK(params);
+  auto signer = LoadTestKeyFromPem(kPemKeyRsa3072, *params);
   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::StatusOr<RsaSsaPssParameters> params =
+      RsaSsaPssParameters::Builder()
+          .SetModulusSizeInBits(4096)
+          .SetSigHashType(RsaSsaPssParameters::HashType::kSha256)
+          .SetMgf1HashType(RsaSsaPssParameters::HashType::kSha256)
+          .SetSaltLengthInBytes(32)
+          .SetVariant(RsaSsaPssParameters::Variant::kNoPrefix)
+          .Build();
+  ABSL_ASSERT_OK(params);
+  auto signer = LoadTestKeyFromPem(kPemKeyRsa4096, *params);
   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,
-                                 }),
+  absl::StatusOr<RsaSsaPssParameters> params =
+      RsaSsaPssParameters::Builder()
+          .SetModulusSizeInBits(3072)
+          .SetSigHashType(RsaSsaPssParameters::HashType::kSha256)
+          .SetMgf1HashType(RsaSsaPssParameters::HashType::kSha256)
+          .SetSaltLengthInBytes(32)
+          .SetVariant(RsaSsaPssParameters::Variant::kNoPrefix)
+          .Build();
+  ABSL_ASSERT_OK(params);
+  EXPECT_THAT(LoadTestKeyFromPem(kPemKeyRsa4096, *params),
               StatusIs(absl::StatusCode::kInvalidArgument));
 }
 
diff --git a/testing/proto_test_utils.h b/testing/proto_test_utils.h
index bf8ba88..987281d 100644
--- a/testing/proto_test_utils.h
+++ b/testing/proto_test_utils.h
@@ -16,7 +16,9 @@
 #ifndef THIRD_PARTY_CREDENTIO_TESTING_PROTO_TEST_UTILS_H_
 #define THIRD_PARTY_CREDENTIO_TESTING_PROTO_TEST_UTILS_H_
 
-#include "absl/log/check.h"
+#include <type_traits>
+
+#include "absl/log/absl_check.h"
 #include "absl/strings/string_view.h"
 #include "gmock/gmock.h"
 #include "google/protobuf/text_format.h"
@@ -24,21 +26,58 @@
 
 namespace credentio_testing {
 
-MATCHER_P(EqualsProto, expected, "") {
-  return google::protobuf::util::MessageDifferencer::Equals(arg, expected);
+template <typename T, typename M>
+bool CompareProtos(const M& arg, const T& expected) {
+  if constexpr (std::is_convertible_v<T, absl::string_view>) {
+    M expected_msg;
+    ABSL_CHECK(
+        google::protobuf::TextFormat::ParseFromString(expected, &expected_msg));
+    return google::protobuf::util::MessageDifferencer::Equals(arg,
+                                                              expected_msg);
+  } else {
+    return google::protobuf::util::MessageDifferencer::Equals(arg, expected);
+  }
 }
 
+MATCHER(EqualsProto, "") {
+  return CompareProtos(std::get<0>(arg), std::get<1>(arg));
+}
+
+MATCHER_P(EqualsProto, expected, "") { return CompareProtos(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>
+class ParseTextProtoOrDieProxy {
+ public:
+  explicit ParseTextProtoOrDieProxy(absl::string_view text_proto)
+      : text_proto_(text_proto) {}
+
+  template <typename T>
+  operator T() const {  // NOLINT(google-explicit-constructor)
+    T proto;
+    ABSL_CHECK(
+        google::protobuf::TextFormat::ParseFromString(text_proto_, &proto));
+    return proto;
+  }
+
+ private:
+  absl::string_view text_proto_;
+};
+
+template <typename T = ParseTextProtoOrDieProxy>
 T ParseTextProtoOrDie(absl::string_view text_proto) {
-  T proto;
-  CHECK(google::protobuf::TextFormat::ParseFromString(text_proto, &proto));
-  return proto;
+  if constexpr (std::is_same_v<T, ParseTextProtoOrDieProxy>) {
+    return ParseTextProtoOrDieProxy(text_proto);
+  } else {
+    T proto;
+    ABSL_CHECK(
+        google::protobuf::TextFormat::ParseFromString(text_proto, &proto));
+    return proto;
+  }
 }
 
 }  // namespace credentio_testing
diff --git a/tools/BUILD b/tools/BUILD
index 5b96d35..cf83f83 100644
--- a/tools/BUILD
+++ b/tools/BUILD
@@ -13,7 +13,9 @@
 # limitations under the License.
 #
 
-load("@rules_cc//cc:defs.bzl", "cc_binary")
+load("@rules_cc//cc:cc_binary.bzl", "cc_binary")
+load("@rules_cc//cc:cc_library.bzl", "cc_library")
+load("@rules_cc//cc:cc_test.bzl", "cc_test")
 
 package(default_visibility = ["//visibility:public"])
 
@@ -23,7 +25,6 @@
     deps = [
         "//crypto:crypto_read_handler",
         "//crypto/default:default_crypto_read_handler",
-        "//formats:core_registry",
         "//utils:crjson",
         "//utils:media_type",
         "//validator:asset_validator_impl",
@@ -34,12 +35,115 @@
         "@abseil-cpp//absl/flags:usage",
         "@abseil-cpp//absl/log:initialize",
         "@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",
         "@nlohmann_json//:json",
         "@protobuf",
         "@riegeli//riegeli/bytes:cfile_reader",
+    ],
+)
+
+cc_library(
+    name = "asset_writer",
+    srcs = ["asset_writer.cc"],
+    hdrs = ["asset_writer.h"],
+    deps = [
+        "//claim:local_signer",
+        "//crypto:crypto_write_handler",
+        "//crypto:hash",
+        "//crypto:templated_crypto_write_handler",
+        "//crypto/default:hasher",
+        "//formats/bmff:writer",
+        "//formats/gif:writer",
+        "//formats/id3:writer",
+        "//formats/jpeg:writer",
+        "//formats/pdf:writer",
+        "//formats/png:writer",
+        "//formats/riff:writer",
+        "//formats/tiff:writer",
+        "//formats/zip:writer",
+        "//generator",
+        "//generator:generator_factory",
+        "//generator:generator_options",
+        "//proto:generator_cc_proto",
+        "//testing:local_key_crypto_write_handler",
+        "//utils:media_type",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/strings:string_view",
+        "@riegeli//riegeli/bytes:cfile_reader",
+        "@riegeli//riegeli/bytes:cfile_writer",
         "@riegeli//riegeli/bytes:reader",
+        "@riegeli//riegeli/bytes:writer",
+    ],
+)
+
+cc_binary(
+    name = "asset_write",
+    srcs = ["asset_write_main.cc"],
+    deps = [
+        ":asset_writer",
+        "//crypto/default:pem",
+        "//proto:generator_cc_proto",
+        "//testing:local_key_crypto_write_handler",
+        "@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",
+    ],
+)
+
+cc_test(
+    name = "asset_writer_test",
+    srcs = ["asset_writer_test.cc"],
+    data = [
+        "//formats/zip/testing:assets",
+    ],
+    deps = [
+        ":asset_writer",
+        "//crypto/default:default_crypto_read_handler",
+        "//formats/bmff:extractor",
+        "//formats/bmff:test_utils",
+        "//formats/gif:constants",
+        "//formats/gif:extractor",
+        "//formats/gif:test_builder",
+        "//formats/id3:extractor",
+        "//formats/jpeg:extractor",
+        "//formats/jpeg/testing:jpeg_creator",
+        "//formats/pdf:extractor",
+        "//formats/png:extractor",
+        "//formats/png:test_utils",
+        "//formats/riff:create_riff",
+        "//formats/riff:extractor",
+        "//formats/tiff:constants",
+        "//formats/tiff:extractor",
+        "//formats/zip:extractor",
+        "//proto:generator_cc_proto",
+        "//proto:manifest_cc_proto",
+        "//proto:validation_status_cc_proto",
+        "//testing:crypto_testing",
+        "//testing:local_key_crypto_write_handler",
+        "//testing:test_file_utils",
+        "//validator:asset_validator_impl",
+        "//validator:validator_options",
+        "@abseil-cpp//absl/log:absl_check",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/status:status_matchers",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings",
+        "@boringssl//:crypto",
+        "@googletest//:gtest_main",
+        "@riegeli//riegeli/bytes:cfile_reader",
+        "@riegeli//riegeli/bytes:string_reader",
+        "@riegeli//riegeli/bytes:string_writer",
+        "@riegeli//riegeli/endian:endian_writing",
     ],
 )
diff --git a/tools/asset_validator_main.cc b/tools/asset_validator_main.cc
index 8c2aaa8..71f9a25 100644
--- a/tools/asset_validator_main.cc
+++ b/tools/asset_validator_main.cc
@@ -17,6 +17,7 @@
 #include <iostream>
 #include <memory>
 #include <optional>
+#include <ostream>
 #include <sstream>
 #include <string>
 #include <utility>
@@ -26,16 +27,15 @@
 #include "absl/flags/usage.h"
 #include "absl/log/initialize.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/crypto_read_handler.h"
 #include "crypto/default/default_crypto_read_handler.h"
-#include "formats/core_registry.h"
 #include "google/protobuf/text_format.h"
 #include "nlohmann/json_fwd.hpp"
 #include "riegeli/bytes/cfile_reader.h"
-#include "riegeli/bytes/reader.h"
 #include "utils/crjson.h"
 #include "utils/media_type.h"
 #include "validator/asset_validator_impl.h"
@@ -55,6 +55,9 @@
 ABSL_FLAG(std::string, output_format, "crjson",
           "The format to output the validation results in. [txtpb, crjson] "
           "(Default: crjson)");
+ABSL_FLAG(std::string, output_file, "",
+          "Path to the output file to write validation results to (Optional; "
+          "defaults to stdout).");
 
 namespace {
 
@@ -69,43 +72,23 @@
   return buffer.str();
 }
 
-absl::Status PrintResultInTxtpb(const credentio::ValidationResult& result) {
+absl::Status PrintResultInTxtpb(const credentio::ValidationResult& result,
+                                std::ostream& out) {
   std::string text_format;
   if (google::protobuf::TextFormat::PrintToString(result.proto(),
                                                   &text_format)) {
-    std::cout << "Validation Result:\n" << text_format << "\n";
+    out << text_format;
   } else {
     return absl::InternalError("Failed to convert result proto to text format");
   }
   return absl::OkStatus();
 }
 
-absl::Status PrintResultInCrJson(
-    riegeli::Reader& reader, std::optional<absl::string_view> media_type_opt,
-    const credentio::ValidationResult& result) {
-  if (!media_type_opt.has_value()) {
-    return absl::InvalidArgumentError("Media type is required");
-  }
-
-  auto registry = credentio::CreateCoreFormatRegistry();
-  auto format = registry->GetFormat(*media_type_opt);
-  if (!format.ok()) {
-    return format.status();
-  }
-  if (!reader.Seek(0) || reader.pos() != 0) {
-    return absl::DataLossError("Failed to seek to start of reader");
-  }
-  auto manifest_store = (*format)->extractor()->ExtractManifestStore(reader);
-  if (!manifest_store.ok()) {
-    return manifest_store.status();
-  }
-
-  absl::StatusOr<nlohmann::json> crjson =
-      credentio::ConvertToCrJson(*manifest_store, result.proto());
-  if (!crjson.ok()) {
-    return crjson.status();
-  }
-  std::cout << "Validation Result (crjson):\n" << crjson->dump(2) << "\n";
+absl::Status PrintResultInCrJson(const credentio::ValidationResult& result,
+                                 std::ostream& out) {
+  ABSL_ASSIGN_OR_RETURN(nlohmann::json crjson,
+                        credentio::ConvertToCrJson(result));
+  out << crjson.dump(2);
   return absl::OkStatus();
 }
 
@@ -116,7 +99,7 @@
       "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>] "
-      "[--output_format=<txtpb, crjson>]");
+      "[--output_format=<txtpb, crjson>] [--output_file=<output_path>]");
   absl::InitializeLog();
   absl::ParseCommandLine(argc, argv);
 
@@ -205,18 +188,29 @@
     return 1;
   }
 
-  std::cout << "Validation successful!\n";
+  const std::string output_path = absl::GetFlag(FLAGS_output_file);
 
-  if (output_format == "crjson") {
-    absl::Status status = PrintResultInCrJson(reader, media_type_opt, **result);
-    if (status.ok()) {
-      return 0;
+  std::ofstream file_out;
+  std::ostream* out = &std::cout;
+  if (!output_path.empty()) {
+    file_out.open(output_path);
+    if (!file_out.is_open()) {
+      std::cerr << "Error: Failed to open output file: " << output_path << "\n";
+      return 1;
     }
-    std::cerr << "Failed to print result in crjson, reverting to txtpb: "
-              << status << "\n";
+    out = &file_out;
   }
 
-  absl::Status status = PrintResultInTxtpb(**result);
+  if (output_format == "crjson") {
+    absl::Status status = PrintResultInCrJson(**result, *out);
+    if (!status.ok()) {
+      std::cerr << "Failed to print result in crjson: " << status << "\n";
+      return 1;
+    }
+    return 0;
+  }
+
+  absl::Status status = PrintResultInTxtpb(**result, *out);
   if (!status.ok()) {
     std::cerr << "Failed to print result in txtpb: " << status << "\n";
     return 1;
diff --git a/tools/asset_write_main.cc b/tools/asset_write_main.cc
new file mode 100644
index 0000000..9785e52
--- /dev/null
+++ b/tools/asset_write_main.cc
@@ -0,0 +1,213 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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 <sstream>
+#include <string>
+#include <utility>
+#include <vector>
+
+#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/default/pem.h"
+#include "google/protobuf/text_format.h"
+#include "proto/generator.pb.h"
+#include "testing/local_key_crypto_write_handler.h"
+#include "tools/asset_writer.h"
+
+ABSL_FLAG(std::string, input, "",
+          "Path to the input asset file to read from (Required).");
+ABSL_FLAG(std::string, output, "",
+          "Path to the output asset file to write to (Required).");
+ABSL_FLAG(std::string, claim_signer_key, "",
+          "Path to PEM file containing the claim signer's EC P-256 private key "
+          "(Required).");
+ABSL_FLAG(std::string, claim_signer_cert, "",
+          "Path to PEM file containing the claim signer's certificate chain, "
+          "leaf first (Required).");
+ABSL_FLAG(std::string, claim_signer_ocsp, "",
+          "Path to DER file containing an OCSP response for the claim signer "
+          "certificate (Optional).");
+ABSL_FLAG(std::string, tsa_key, "",
+          "Path to PEM file containing the TSA's EC P-256 private key "
+          "(Optional; required unless timestamping is disabled).");
+ABSL_FLAG(std::string, tsa_cert, "",
+          "Path to PEM file containing the TSA's certificate "
+          "(Optional; required unless timestamping is disabled).");
+ABSL_FLAG(bool, disable_timestamping, false,
+          "If true, disables timestamping when writing the C2PA manifest store "
+          "(Default: false).");
+ABSL_FLAG(std::string, request_txtpb, "",
+          "Path to textproto file containing GenerateManifestStoreRequest "
+          "(Optional; defaults to a basic 'created' action).");
+
+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(
+      "Writes C2PA manifest stores into asset files.\n"
+      "Usage:\n  asset_write --input=<input_path> --output=<output_path> "
+      "--claim_signer_key=<pem_path> --claim_signer_cert=<pem_path> "
+      "[--claim_signer_ocsp=<der_path>] [--tsa_key=<pem_path>] "
+      "[--tsa_cert=<pem_path>] [--disable_timestamping] "
+      "[--request_txtpb=<txtpb_path>]");
+  absl::InitializeLog();
+  absl::ParseCommandLine(argc, argv);
+
+  const std::string input_path = absl::GetFlag(FLAGS_input);
+  if (input_path.empty()) {
+    std::cerr << "Error: --input flag is required.\n";
+    return 1;
+  }
+
+  const std::string output_path = absl::GetFlag(FLAGS_output);
+  if (output_path.empty()) {
+    std::cerr << "Error: --output flag is required.\n";
+    return 1;
+  }
+
+  const std::string claim_signer_key_path =
+      absl::GetFlag(FLAGS_claim_signer_key);
+  if (claim_signer_key_path.empty()) {
+    std::cerr << "Error: --claim_signer_key flag is required.\n";
+    return 1;
+  }
+
+  const std::string claim_signer_cert_path =
+      absl::GetFlag(FLAGS_claim_signer_cert);
+  if (claim_signer_cert_path.empty()) {
+    std::cerr << "Error: --claim_signer_cert flag is required.\n";
+    return 1;
+  }
+
+  credentio::LocalKeyCryptoWriteHandlerOptions crypto_options;
+
+  absl::StatusOr<std::string> key_pem = GetContents(claim_signer_key_path);
+  if (!key_pem.ok()) {
+    std::cerr << "Error reading claim signer key file: " << key_pem.status()
+              << "\n";
+    return 1;
+  }
+  crypto_options.claim_signer.key_pem = *std::move(key_pem);
+
+  absl::StatusOr<std::string> cert_pem = GetContents(claim_signer_cert_path);
+  if (!cert_pem.ok()) {
+    std::cerr << "Error reading claim signer cert file: " << cert_pem.status()
+              << "\n";
+    return 1;
+  }
+  absl::StatusOr<std::vector<std::string>> cert_chain =
+      credentio::LoadCertsFromPem(*cert_pem);
+  if (!cert_chain.ok()) {
+    std::cerr << "Error parsing claim signer certificate chain: "
+              << cert_chain.status() << "\n";
+    return 1;
+  }
+  crypto_options.claim_signer.certificate_chain = *std::move(cert_chain);
+
+  const std::string claim_signer_ocsp_path =
+      absl::GetFlag(FLAGS_claim_signer_ocsp);
+  if (!claim_signer_ocsp_path.empty()) {
+    absl::StatusOr<std::string> ocsp_der = GetContents(claim_signer_ocsp_path);
+    if (!ocsp_der.ok()) {
+      std::cerr << "Error reading claim signer OCSP file: " << ocsp_der.status()
+                << "\n";
+      return 1;
+    }
+    crypto_options.claim_signer.ocsp_responses.push_back(*std::move(ocsp_der));
+  }
+
+  const std::string tsa_key_path = absl::GetFlag(FLAGS_tsa_key);
+  const std::string tsa_cert_path = absl::GetFlag(FLAGS_tsa_cert);
+  bool disable_timestamping = absl::GetFlag(FLAGS_disable_timestamping);
+
+  if (!disable_timestamping && tsa_key_path.empty() && tsa_cert_path.empty()) {
+    std::cerr << "WARNING: --tsa_key and --tsa_cert are not provided. "
+                 "Disabling timestamping.\n";
+    disable_timestamping = true;
+  }
+
+  crypto_options.disable_timestamping = disable_timestamping;
+  if (!disable_timestamping) {
+    if (tsa_key_path.empty() || tsa_cert_path.empty()) {
+      std::cerr << "Error: Both --tsa_key and --tsa_cert must be provided when "
+                   "timestamping is enabled.\n";
+      return 1;
+    }
+    absl::StatusOr<std::string> tsa_key_pem = GetContents(tsa_key_path);
+    if (!tsa_key_pem.ok()) {
+      std::cerr << "Error reading TSA key file: " << tsa_key_pem.status()
+                << "\n";
+      return 1;
+    }
+    crypto_options.tsa.key_pem = *std::move(tsa_key_pem);
+
+    absl::StatusOr<std::string> tsa_cert_pem = GetContents(tsa_cert_path);
+    if (!tsa_cert_pem.ok()) {
+      std::cerr << "Error reading TSA cert file: " << tsa_cert_pem.status()
+                << "\n";
+      return 1;
+    }
+    crypto_options.tsa.cert_pem = *std::move(tsa_cert_pem);
+  }
+
+  credentio::GenerateManifestStoreRequest request;
+  const std::string request_txtpb_path = absl::GetFlag(FLAGS_request_txtpb);
+  if (!request_txtpb_path.empty()) {
+    absl::StatusOr<std::string> request_txtpb = GetContents(request_txtpb_path);
+    if (!request_txtpb.ok()) {
+      std::cerr << "Error reading request txtpb file: "
+                << request_txtpb.status() << "\n";
+      return 1;
+    }
+    if (!google::protobuf::TextFormat::ParseFromString(*request_txtpb,
+                                                       &request)) {
+      std::cerr << "Error parsing GenerateManifestStoreRequest from txtpb.\n";
+      return 1;
+    }
+  } else {
+    request.mutable_actions()->mutable_created();
+  }
+
+  absl::Status status = credentio::WriteAssetFile(
+      std::move(crypto_options), std::move(request), input_path, output_path);
+  if (!status.ok()) {
+    std::cerr << "Asset write failed: " << status << "\n";
+    return 1;
+  }
+
+  std::cout << "Successfully wrote C2PA asset to " << output_path << "\n";
+  return 0;
+}
diff --git a/tools/asset_writer.cc b/tools/asset_writer.cc
new file mode 100644
index 0000000..a177688
--- /dev/null
+++ b/tools/asset_writer.cc
@@ -0,0 +1,182 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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 "tools/asset_writer.h"
+
+#include <memory>
+#include <string>
+#include <utility>
+
+#include "absl/status/status.h"
+#include "absl/status/status_macros.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/string_view.h"
+#include "claim/local_signer.h"
+#include "crypto/crypto_write_handler.h"
+#include "crypto/default/hasher.h"
+#include "crypto/hash.h"
+#include "crypto/templated_crypto_write_handler.h"
+#include "formats/bmff/writer.h"
+#include "formats/gif/writer.h"
+#include "formats/id3/writer.h"
+#include "formats/jpeg/writer.h"
+#include "formats/pdf/writer.h"
+#include "formats/png/writer.h"
+#include "formats/riff/writer.h"
+#include "formats/tiff/writer.h"
+#include "formats/zip/writer.h"
+#include "generator/generator.h"
+#include "generator/generator_factory.h"
+#include "generator/generator_options.h"
+#include "proto/generator.pb.h"
+#include "riegeli/bytes/cfile_reader.h"
+#include "riegeli/bytes/cfile_writer.h"
+#include "riegeli/bytes/reader.h"
+#include "riegeli/bytes/writer.h"
+#include "testing/local_key_crypto_write_handler.h"
+#include "utils/media_type.h"
+
+namespace credentio {
+
+absl::Status WriteAsset(LocalKeyCryptoWriteHandlerOptions crypto_options,
+                        GenerateManifestStoreRequest request,
+                        absl::string_view media_type, riegeli::Reader& source,
+                        riegeli::Writer& destination) {
+  ABSL_ASSIGN_OR_RETURN(
+      std::unique_ptr<CryptoWriteHandler> crypto_write_handler,
+      CreateLocalKeyCryptoWriteHandler(std::move(crypto_options)));
+
+  const HasherFactory& hash_factory = DefaultHasherFactory();
+  TemplatedHasherFactory template_hash_factory(hash_factory.algorithm());
+  TemplatedCryptoWriteHandler template_crypto_write_handler(
+      crypto_write_handler.get());
+
+  LocalClaimSigner template_claim_signer(&template_crypto_write_handler,
+                                         &template_hash_factory);
+  LocalClaimSigner signing_claim_signer(crypto_write_handler.get(),
+                                        &hash_factory);
+
+  GeneratorOptions generator_options;
+  generator_options.generator_info.set_name("c2pa_asset_write");
+  generator_options.signing_claim_signer = &signing_claim_signer;
+  generator_options.template_claim_signer = &template_claim_signer;
+  generator_options.hash_factory = &hash_factory;
+
+  ABSL_ASSIGN_OR_RETURN(std::unique_ptr<ManifestStoreGenerator> generator,
+                        CreateGenerator(std::move(generator_options)));
+
+  if (!request.has_actions()) {
+    request.mutable_actions()->mutable_created();
+  }
+
+  if (media_type == "image/png") {
+    PngWriter writer(generator.get());
+    return writer.Write(std::move(request), &source, &destination,
+                        /*output_metadata=*/nullptr);
+  }
+  if (media_type == "image/jpeg") {
+    JpegWriter writer(generator.get());
+    return writer.Write(std::move(request), &source, &destination,
+                        /*output_metadata=*/nullptr);
+  }
+  if (media_type == "image/gif") {
+    GifWriter writer(generator.get());
+    return writer.Write(std::move(request), &source, &destination,
+                        /*output_metadata=*/nullptr);
+  }
+  if (media_type == "video/mp4" || media_type == "video/quicktime" ||
+      media_type == "image/heic" || media_type == "image/heif" ||
+      media_type == "image/avif" || media_type == "audio/mp4") {
+    BmffWriter writer(generator.get());
+    return writer.Write(std::move(request), &source, &destination,
+                        /*output_metadata=*/nullptr);
+  }
+  if (media_type == "image/tiff" || media_type == "image/tiff-fx" ||
+      media_type == "image/x-adobe-dng") {
+    TiffWriter writer(generator.get());
+    return writer.Write(std::move(request), &source, &destination,
+                        /*output_metadata=*/nullptr);
+  }
+  if (media_type == "image/webp" || media_type == "audio/wav" ||
+      media_type == "audio/x-wav" || media_type == "audio/vnd.wave" ||
+      media_type == "video/x-msvideo" || media_type == "video/avi") {
+    RiffWriter writer(generator.get());
+    return writer.Write(std::move(request), &source, &destination,
+                        /*output_metadata=*/nullptr);
+  }
+  if (media_type == "audio/mpeg" || media_type == "audio/mp3" ||
+      media_type == "audio/flac" || media_type == "audio/x-flac") {
+    Id3Writer writer(generator.get());
+    return writer.Write(std::move(request), &source, &destination);
+  }
+  if (media_type == "application/pdf") {
+    PdfWriter writer(generator.get());
+    return writer.Write(std::move(request), &source, &destination);
+  }
+  if (media_type == "application/zip" ||
+      media_type ==
+          "application/"
+          "vnd.openxmlformats-officedocument.presentationml.presentation" ||
+      media_type ==
+          "application/"
+          "vnd.openxmlformats-officedocument.wordprocessingml.document" ||
+      media_type ==
+          "application/"
+          "vnd.openxmlformats-officedocument.spreadsheetml.sheet") {
+    ZipWriter writer(generator.get());
+    return writer.Write(request, &source, &destination);
+  }
+
+  return absl::UnimplementedError(
+      absl::StrCat("Unsupported media type for asset_write: ", media_type));
+}
+
+absl::Status WriteAssetFile(LocalKeyCryptoWriteHandlerOptions crypto_options,
+                            GenerateManifestStoreRequest request,
+                            absl::string_view input_path,
+                            absl::string_view output_path) {
+  // Writing in place would truncate the input asset before it is read.
+  if (input_path == output_path) {
+    return absl::InvalidArgumentError(absl::StrCat(
+        "In-place writing is not supported; the output path must differ from "
+        "the input path: ",
+        input_path));
+  }
+
+  ABSL_ASSIGN_OR_RETURN(std::string media_type, MediaType(input_path));
+
+  riegeli::CFileReader<> source(input_path);
+  if (!source.ok()) {
+    return source.status();
+  }
+
+  riegeli::CFileWriter<> destination(output_path);
+  if (!destination.ok()) {
+    return destination.status();
+  }
+
+  ABSL_RETURN_IF_ERROR(WriteAsset(std::move(crypto_options), std::move(request),
+                                  media_type, source, destination));
+
+  if (!source.Close()) {
+    return source.status();
+  }
+  if (!destination.Close()) {
+    return destination.status();
+  }
+  return absl::OkStatus();
+}
+
+}  // namespace credentio
diff --git a/tools/asset_writer.h b/tools/asset_writer.h
new file mode 100644
index 0000000..360db88
--- /dev/null
+++ b/tools/asset_writer.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_TOOLS_ASSET_WRITER_H_
+#define THIRD_PARTY_CREDENTIO_TOOLS_ASSET_WRITER_H_
+
+#include "absl/status/status.h"
+#include "absl/strings/string_view.h"
+#include "proto/generator.pb.h"
+#include "riegeli/bytes/reader.h"
+#include "riegeli/bytes/writer.h"
+#include "testing/local_key_crypto_write_handler.h"
+
+namespace credentio {
+
+// Writes a C2PA manifest store into `destination` based on `source` and
+// `request`, using `LocalKeyCryptoWriteHandler` configured with
+// `crypto_options`.
+//
+// `media_type` specifies the MIME type of the asset (e.g., "image/png").
+// See the "Supported Media Formats" section of README.md for supported types.
+absl::Status WriteAsset(LocalKeyCryptoWriteHandlerOptions crypto_options,
+                        GenerateManifestStoreRequest request,
+                        absl::string_view media_type, riegeli::Reader& source,
+                        riegeli::Writer& destination);
+
+// Convenience wrapper that opens `input_path` and `output_path` and infers
+// the media type from `input_path`.
+absl::Status WriteAssetFile(LocalKeyCryptoWriteHandlerOptions crypto_options,
+                            GenerateManifestStoreRequest request,
+                            absl::string_view input_path,
+                            absl::string_view output_path);
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_TOOLS_ASSET_WRITER_H_
diff --git a/tools/asset_writer_test.cc b/tools/asset_writer_test.cc
new file mode 100644
index 0000000..75c3654
--- /dev/null
+++ b/tools/asset_writer_test.cc
@@ -0,0 +1,533 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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 "tools/asset_writer.h"
+
+#include <cstdint>
+#include <fstream>
+#include <ios>
+#include <iterator>
+#include <memory>
+#include <string>
+#include <utility>
+#include <vector>
+
+#include "absl/log/absl_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 "crypto/default/default_crypto_read_handler.h"
+#include "formats/bmff/extractor.h"
+#include "formats/bmff/test_utils.h"
+#include "formats/gif/constants.h"
+#include "formats/gif/extractor.h"
+#include "formats/gif/test_builder.h"
+#include "formats/id3/extractor.h"
+#include "formats/jpeg/extractor.h"
+#include "formats/jpeg/testing/jpeg_creator.h"
+#include "formats/pdf/extractor.h"
+#include "formats/png/extractor.h"
+#include "formats/png/test_utils.h"
+#include "formats/riff/create_riff.h"
+#include "formats/riff/extractor.h"
+#include "formats/tiff/constants.h"
+#include "formats/tiff/extractor.h"
+#include "formats/zip/extractor.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "proto/generator.pb.h"
+#include "proto/manifest.pb.h"
+#include "proto/validation_status.pb.h"
+#include "riegeli/bytes/cfile_reader.h"
+#include "riegeli/bytes/string_reader.h"
+#include "riegeli/bytes/string_writer.h"
+#include "riegeli/endian/endian_writing.h"
+#include "testing/crypto_testing.h"
+#include "testing/local_key_crypto_write_handler.h"
+#include "testing/test_file_utils.h"
+#include "validator/asset_validator_impl.h"
+#include "validator/validator_options.h"
+
+namespace credentio {
+namespace {
+
+using ::absl_testing::IsOk;
+using ::absl_testing::StatusIs;
+using ::testing::IsEmpty;
+using ::testing::Not;
+
+LocalKeyCryptoWriteHandlerOptions CreateWriteOptions(
+    const TestPki& pki, bool disable_timestamping) {
+  LocalKeyCryptoWriteHandlerOptions options;
+  options.claim_signer.key_pem = pki.claim_signer.key_pem;
+  options.claim_signer.certificate_chain = {pki.claim_signer.cert_der};
+  options.disable_timestamping = disable_timestamping;
+  if (!disable_timestamping) {
+    options.tsa.key_pem = pki.tsa.key_pem;
+    options.tsa.cert_pem = pki.tsa.cert_pem;
+  }
+  return options;
+}
+
+// Returns a validator that enforces claim signer and TSA trust against the
+// root CA of `pki`.
+std::unique_ptr<AssetValidatorImpl> CreateTestValidator(const TestPki& pki) {
+  DefaultCryptoReadHandlerOptions crypto_options;
+  crypto_options.claim_signer_trust_anchors_pem = pki.root_ca.cert_pem;
+  crypto_options.tsa_trust_anchors_pem = pki.root_ca.cert_pem;
+  auto crypto_read_handler = CreateDefaultCryptoReadHandler(crypto_options);
+  ABSL_CHECK_OK(crypto_read_handler);
+  return std::make_unique<AssetValidatorImpl>(ValidatorOptions{
+      .crypto_read_handler = *std::move(crypto_read_handler),
+  });
+}
+
+TEST(AssetWriterTest, WritesPngManifestStore) {
+  auto png_or = CreatePng({Chunk{.type = 0x49444154, .payload = "pixel_data"}});
+  ABSL_ASSERT_OK(png_or);
+
+  riegeli::StringReader<> source(*png_or);
+  std::string output_bytes;
+  riegeli::StringWriter<> destination(&output_bytes);
+
+  GenerateManifestStoreRequest request;
+  request.mutable_actions()->mutable_created();
+
+  TestPki pki = CreateTestPki();
+  EXPECT_THAT(WriteAsset(CreateWriteOptions(pki, /*disable_timestamping=*/true),
+                         std::move(request), "image/png", source, destination),
+              IsOk());
+  ASSERT_TRUE(destination.Close());
+
+  riegeli::StringReader<> result_reader(output_bytes);
+  PngExtractor extractor;
+  auto extracted = extractor.ExtractManifestStore(result_reader);
+  ABSL_ASSERT_OK(extracted);
+  EXPECT_THAT(*extracted, Not(IsEmpty()));
+
+  ASSERT_TRUE(result_reader.Seek(0));
+  auto validator = CreateTestValidator(pki);
+  auto validation_result = validator->Validate(result_reader, "image/png");
+  ABSL_ASSERT_OK(validation_result);
+  EXPECT_THAT(
+      (*validation_result)->proto().active_manifest().validation().failures(),
+      IsEmpty());
+}
+
+TEST(AssetWriterTest, WritesJpegManifestStore) {
+  std::string jpeg_bytes = CreateJpeg({});
+  riegeli::StringReader<> source(jpeg_bytes);
+  std::string output_bytes;
+  riegeli::StringWriter<> destination(&output_bytes);
+
+  GenerateManifestStoreRequest request;
+  request.mutable_actions()->mutable_created();
+
+  TestPki pki = CreateTestPki();
+  EXPECT_THAT(WriteAsset(CreateWriteOptions(pki, /*disable_timestamping=*/true),
+                         std::move(request), "image/jpeg", source, destination),
+              IsOk());
+  ASSERT_TRUE(destination.Close());
+
+  riegeli::StringReader<> result_reader(output_bytes);
+  JpegExtractor extractor;
+  auto extracted = extractor.ExtractManifestStore(result_reader);
+  ABSL_ASSERT_OK(extracted);
+  EXPECT_THAT(*extracted, Not(IsEmpty()));
+
+  ASSERT_TRUE(result_reader.Seek(0));
+  auto validator = CreateTestValidator(pki);
+  auto validation_result = validator->Validate(result_reader, "image/jpeg");
+  ABSL_ASSERT_OK(validation_result);
+  EXPECT_THAT(
+      (*validation_result)->proto().active_manifest().validation().failures(),
+      IsEmpty());
+}
+
+TEST(AssetWriterTest, WritesGifManifestStore) {
+  TestGifBuilder builder;
+  builder.AddGifHeader();
+  builder.AddLogicalDescriptor(/*has_color_table=*/false);
+  builder.AddBlock(kGifExtensionIntroducer, kGifExtensionApplication,
+                   "other_app_info", "misc_data");
+  builder.AddBlock(kGifTrailerIntroducer);
+  std::string gif_bytes = builder.GetAsset();
+
+  riegeli::StringReader<> source(gif_bytes);
+  std::string output_bytes;
+  riegeli::StringWriter<> destination(&output_bytes);
+
+  GenerateManifestStoreRequest request;
+  request.mutable_actions()->mutable_created();
+
+  TestPki pki = CreateTestPki();
+  EXPECT_THAT(WriteAsset(CreateWriteOptions(pki, /*disable_timestamping=*/true),
+                         std::move(request), "image/gif", source, destination),
+              IsOk());
+  ASSERT_TRUE(destination.Close());
+
+  riegeli::StringReader<> result_reader(output_bytes);
+  GifExtractor extractor;
+  auto extracted = extractor.ExtractManifestStore(result_reader);
+  ABSL_ASSERT_OK(extracted);
+  EXPECT_THAT(*extracted, Not(IsEmpty()));
+
+  ASSERT_TRUE(result_reader.Seek(0));
+  auto validator = CreateTestValidator(pki);
+  auto validation_result = validator->Validate(result_reader, "image/gif");
+  ABSL_ASSERT_OK(validation_result);
+  EXPECT_THAT(
+      (*validation_result)->proto().active_manifest().validation().failures(),
+      IsEmpty());
+}
+
+TEST(AssetWriterTest, WritesBmffManifestStore) {
+  std::string bmff_bytes = absl::StrCat(
+      credentio_testing::Box("ftyp", {"isom\x00\x00\x02\x00isom", 12}),
+      credentio_testing::Box("moov", ""),
+      credentio_testing::Box("mdat", "video_payload"));
+
+  riegeli::StringReader<> source(bmff_bytes);
+  std::string output_bytes;
+  riegeli::StringWriter<> destination(&output_bytes);
+
+  GenerateManifestStoreRequest request;
+  request.mutable_actions()->mutable_created();
+
+  TestPki pki = CreateTestPki();
+  EXPECT_THAT(WriteAsset(CreateWriteOptions(pki, /*disable_timestamping=*/true),
+                         std::move(request), "video/mp4", source, destination),
+              IsOk());
+  ASSERT_TRUE(destination.Close());
+
+  riegeli::StringReader<> result_reader(output_bytes);
+  BmffExtractor extractor;
+  auto extracted = extractor.ExtractManifestStore(result_reader);
+  ABSL_ASSERT_OK(extracted);
+  EXPECT_THAT(*extracted, Not(IsEmpty()));
+
+  ASSERT_TRUE(result_reader.Seek(0));
+  auto validator = CreateTestValidator(pki);
+  auto validation_result = validator->Validate(result_reader, "video/mp4");
+  ABSL_ASSERT_OK(validation_result);
+  EXPECT_THAT(
+      (*validation_result)->proto().active_manifest().validation().failures(),
+      IsEmpty());
+}
+
+TEST(AssetWriterTest, WritesTiffManifestStore) {
+  std::string tiff_bytes;
+  riegeli::StringWriter<> writer(&tiff_bytes);
+  riegeli::WriteBigEndian<uint32_t>(kTiffBigEndianHeader, writer);
+  riegeli::WriteBigEndian<uint32_t>(8, writer);  // Offset to first IFD
+  riegeli::WriteBigEndian<uint16_t>(1, writer);  // 1 entry
+  riegeli::WriteBigEndian<uint16_t>(0x0100, writer);
+  riegeli::WriteBigEndian<uint16_t>(kTiffTypeShort, writer);
+  riegeli::WriteBigEndian<uint32_t>(1, writer);
+  riegeli::WriteBigEndian<uint32_t>(10, writer);
+  riegeli::WriteBigEndian<uint32_t>(0, writer);  // next IFD offset
+  ASSERT_TRUE(writer.Close());
+
+  riegeli::StringReader<> source(tiff_bytes);
+  std::string output_bytes;
+  riegeli::StringWriter<> destination(&output_bytes);
+
+  GenerateManifestStoreRequest request;
+  request.mutable_actions()->mutable_created();
+
+  TestPki pki = CreateTestPki();
+  EXPECT_THAT(WriteAsset(CreateWriteOptions(pki, /*disable_timestamping=*/true),
+                         std::move(request), "image/tiff", source, destination),
+              IsOk());
+  ASSERT_TRUE(destination.Close());
+
+  riegeli::StringReader<> result_reader(output_bytes);
+  TiffExtractor extractor;
+  auto extracted = extractor.ExtractManifestStore(result_reader);
+  ABSL_ASSERT_OK(extracted);
+  EXPECT_THAT(*extracted, Not(IsEmpty()));
+
+  ASSERT_TRUE(result_reader.Seek(0));
+  auto validator = CreateTestValidator(pki);
+  auto validation_result = validator->Validate(result_reader, "image/tiff");
+  ABSL_ASSERT_OK(validation_result);
+  EXPECT_THAT(
+      (*validation_result)->proto().active_manifest().validation().failures(),
+      IsEmpty());
+}
+
+TEST(AssetWriterTest, WritesRiffManifestStore) {
+  std::string riff_bytes = credentio_riff::RiffChunk(
+      "WEBP", credentio_riff::Chunk("VP8 ", "image_data"));
+
+  riegeli::StringReader<> source(riff_bytes);
+  std::string output_bytes;
+  riegeli::StringWriter<> destination(&output_bytes);
+
+  GenerateManifestStoreRequest request;
+  request.mutable_actions()->mutable_created();
+
+  TestPki pki = CreateTestPki();
+  EXPECT_THAT(WriteAsset(CreateWriteOptions(pki, /*disable_timestamping=*/true),
+                         std::move(request), "image/webp", source, destination),
+              IsOk());
+  ASSERT_TRUE(destination.Close());
+
+  riegeli::StringReader<> result_reader(output_bytes);
+  RiffExtractor extractor;
+  auto extracted = extractor.ExtractManifestStore(result_reader);
+  ABSL_ASSERT_OK(extracted);
+  EXPECT_THAT(*extracted, Not(IsEmpty()));
+
+  ASSERT_TRUE(result_reader.Seek(0));
+  auto validator = CreateTestValidator(pki);
+  auto validation_result = validator->Validate(result_reader, "image/webp");
+  ABSL_ASSERT_OK(validation_result);
+  EXPECT_THAT(
+      (*validation_result)->proto().active_manifest().validation().failures(),
+      IsEmpty());
+}
+
+TEST(AssetWriterTest, WritesId3ManifestStore) {
+  constexpr char kMp3Bytes[] = {'\xFF', '\xF3', '\x40', '\xC0',
+                                '\x01', '\x02', '\x03', '\x04'};
+  std::string id3_bytes(kMp3Bytes, sizeof(kMp3Bytes));
+
+  riegeli::StringReader<> source(id3_bytes);
+  std::string output_bytes;
+  riegeli::StringWriter<> destination(&output_bytes);
+
+  GenerateManifestStoreRequest request;
+  request.mutable_actions()->mutable_created();
+
+  TestPki pki = CreateTestPki();
+  EXPECT_THAT(WriteAsset(CreateWriteOptions(pki, /*disable_timestamping=*/true),
+                         std::move(request), "audio/mpeg", source, destination),
+              IsOk());
+  ASSERT_TRUE(destination.Close());
+
+  riegeli::StringReader<> result_reader(output_bytes);
+  Id3Extractor extractor;
+  auto extracted = extractor.ExtractManifestStore(result_reader);
+  ABSL_ASSERT_OK(extracted);
+  EXPECT_THAT(*extracted, Not(IsEmpty()));
+
+  ASSERT_TRUE(result_reader.Seek(0));
+  auto validator = CreateTestValidator(pki);
+  auto validation_result = validator->Validate(result_reader, "audio/mpeg");
+  ABSL_ASSERT_OK(validation_result);
+  EXPECT_THAT(
+      (*validation_result)->proto().active_manifest().validation().failures(),
+      IsEmpty());
+}
+
+TEST(AssetWriterTest, WritesPdfManifestStore) {
+  constexpr absl::string_view kPdfBytes = 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 4>>
+startxref
+98
+%%EOF
+)";
+
+  riegeli::StringReader<> source(kPdfBytes);
+  std::string output_bytes;
+  riegeli::StringWriter<> destination(&output_bytes);
+
+  GenerateManifestStoreRequest request;
+  request.mutable_actions()->mutable_created();
+
+  TestPki pki = CreateTestPki();
+  EXPECT_THAT(
+      WriteAsset(CreateWriteOptions(pki, /*disable_timestamping=*/true),
+                 std::move(request), "application/pdf", source, destination),
+      IsOk());
+  ASSERT_TRUE(destination.Close());
+
+  riegeli::StringReader<> result_reader(output_bytes);
+  PdfExtractor extractor;
+  auto extracted = extractor.ExtractManifestStore(result_reader);
+  ABSL_ASSERT_OK(extracted);
+  EXPECT_THAT(*extracted, Not(IsEmpty()));
+
+  ASSERT_TRUE(result_reader.Seek(0));
+  auto validator = CreateTestValidator(pki);
+  auto validation_result =
+      validator->Validate(result_reader, "application/pdf");
+  ABSL_ASSERT_OK(validation_result);
+  EXPECT_THAT(
+      (*validation_result)->proto().active_manifest().validation().failures(),
+      IsEmpty());
+}
+
+TEST(AssetWriterTest, WritesZipManifestStore) {
+  absl::StatusOr<std::string> pptx_bytes = credentio_testing::GetContents(
+      "credentio/formats/zip/testing/"
+      "no_c2pa_032_asset.pptx");
+  ABSL_ASSERT_OK(pptx_bytes);
+  riegeli::StringReader<> source(*pptx_bytes);
+  std::string output_bytes;
+  riegeli::StringWriter<> destination(&output_bytes);
+
+  GenerateManifestStoreRequest request;
+  request.mutable_actions()->mutable_created();
+
+  constexpr absl::string_view kPptxMediaType =
+      "application/"
+      "vnd.openxmlformats-officedocument.presentationml.presentation";
+
+  TestPki pki = CreateTestPki();
+  EXPECT_THAT(
+      WriteAsset(CreateWriteOptions(pki, /*disable_timestamping=*/true),
+                 std::move(request), kPptxMediaType, source, destination),
+      IsOk());
+  ASSERT_TRUE(destination.Close());
+
+  riegeli::StringReader<> result_reader(output_bytes);
+  ZipExtractor extractor;
+  auto extracted = extractor.ExtractManifestStore(result_reader);
+  ABSL_ASSERT_OK(extracted);
+  EXPECT_THAT(*extracted, Not(IsEmpty()));
+
+  ASSERT_TRUE(result_reader.Seek(0));
+  auto validator = CreateTestValidator(pki);
+  auto validation_result = validator->Validate(result_reader, kPptxMediaType);
+  ABSL_ASSERT_OK(validation_result);
+  EXPECT_THAT(
+      (*validation_result)->proto().active_manifest().validation().failures(),
+      IsEmpty());
+}
+
+TEST(AssetWriterTest, WritesPngWithTimestamp) {
+  auto png_or = CreatePng({Chunk{.type = 0x49444154, .payload = "pixel_data"}});
+  ABSL_ASSERT_OK(png_or);
+
+  riegeli::StringReader<> source(*png_or);
+  std::string output_bytes;
+  riegeli::StringWriter<> destination(&output_bytes);
+
+  GenerateManifestStoreRequest request;
+  request.mutable_actions()->mutable_created();
+
+  TestPki pki = CreateTestPki();
+  EXPECT_THAT(
+      WriteAsset(CreateWriteOptions(pki, /*disable_timestamping=*/false),
+                 std::move(request), "image/png", source, destination),
+      IsOk());
+  ASSERT_TRUE(destination.Close());
+
+  riegeli::StringReader<> result_reader(output_bytes);
+  auto validator = CreateTestValidator(pki);
+  auto validation_result = validator->Validate(result_reader, "image/png");
+  ABSL_ASSERT_OK(validation_result);
+  EXPECT_THAT(
+      (*validation_result)->proto().active_manifest().validation().failures(),
+      IsEmpty());
+}
+
+TEST(AssetWriterTest, WriteAssetFileWritesAndValidatesPng) {
+  auto png_or = CreatePng({Chunk{.type = 0x49444154, .payload = "pixel_data"}});
+  ABSL_ASSERT_OK(png_or);
+
+  const std::string input_path =
+      absl::StrCat(testing::TempDir(), "/test_input.png");
+  const std::string output_path =
+      absl::StrCat(testing::TempDir(), "/test_output.png");
+
+  {
+    std::ofstream out(input_path, std::ios::binary);
+    out << *png_or;
+  }
+
+  GenerateManifestStoreRequest request;
+  request.mutable_actions()->mutable_created();
+
+  TestPki pki = CreateTestPki();
+  EXPECT_THAT(
+      WriteAssetFile(CreateWriteOptions(pki, /*disable_timestamping=*/true),
+                     std::move(request), input_path, output_path),
+      IsOk());
+
+  riegeli::CFileReader<> result_reader(output_path);
+  ASSERT_TRUE(result_reader.ok());
+  auto validator = CreateTestValidator(pki);
+  auto validation_result = validator->Validate(result_reader, "image/png");
+  ABSL_ASSERT_OK(validation_result);
+  EXPECT_THAT(
+      (*validation_result)->proto().active_manifest().validation().failures(),
+      IsEmpty());
+}
+
+TEST(AssetWriterTest, RejectsInPlaceWrite) {
+  auto png_or = CreatePng({Chunk{.type = 0x49444154, .payload = "pixel_data"}});
+  ABSL_ASSERT_OK(png_or);
+
+  const std::string path =
+      absl::StrCat(testing::TempDir(), "/test_in_place.png");
+  {
+    std::ofstream out(path, std::ios::binary);
+    out << *png_or;
+  }
+
+  GenerateManifestStoreRequest request;
+  request.mutable_actions()->mutable_created();
+
+  TestPki pki = CreateTestPki();
+  EXPECT_THAT(
+      WriteAssetFile(CreateWriteOptions(pki, /*disable_timestamping=*/true),
+                     std::move(request), path, path),
+      StatusIs(absl::StatusCode::kInvalidArgument));
+
+  // The input asset must be left untouched.
+  std::ifstream in(path, std::ios::binary);
+  const std::string contents((std::istreambuf_iterator<char>(in)),
+                             std::istreambuf_iterator<char>());
+  EXPECT_EQ(contents, *png_or);
+}
+
+TEST(AssetWriterTest, RejectsUnsupportedMediaType) {
+  riegeli::StringReader<> source("fake_unknown_bytes");
+  std::string output_bytes;
+  riegeli::StringWriter<> destination(&output_bytes);
+
+  GenerateManifestStoreRequest request;
+  request.mutable_actions()->mutable_created();
+
+  TestPki pki = CreateTestPki();
+  EXPECT_THAT(WriteAsset(CreateWriteOptions(pki, /*disable_timestamping=*/true),
+                         std::move(request), "application/octet-stream", source,
+                         destination),
+              StatusIs(absl::StatusCode::kUnimplemented));
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/tools/create_test_pki.sh b/tools/create_test_pki.sh
new file mode 100755
index 0000000..0051d8c
--- /dev/null
+++ b/tools/create_test_pki.sh
@@ -0,0 +1,171 @@
+#!/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.
+#
+
+#
+# Creates a private root CA and, issued by it, EC P-256 keys and certificates
+# for a C2PA claim signer and a Time Stamping Authority (TSA). The output is
+# intended for local testing of tools:asset_write and tools:c2pa_validate only.
+#
+# Usage: create_test_pki.sh [output_dir]
+#
+# output_dir defaults to ./test_pki and must not already contain keys.
+#
+# The certificates follow the C2PA certificate profile checked by the
+# validator: X.509 v3 with the digitalSignature key usage. The claim signer
+# certificate carries the C2PA claim signing EKU (1.3.6.1.4.1.62558.2.1) and no
+# anyExtendedKeyUsage. The TSA certificate carries a critical timeStamping EKU
+# as required by RFC 3161.
+#
+# Keep root_ca_key.pem private: anyone holding it can issue certificates that
+# validators configured with root_ca_cert.pem will trust.
+#
+# Requires OpenSSL 1.1.1 or later.
+
+set -euo pipefail
+
+readonly CURVE="P-256"
+readonly ROOT_CA_DAYS=3650
+readonly LEAF_DAYS=365
+readonly ORGANIZATION="Credentio Test"
+
+#######################################
+# Writes the OpenSSL config with the certificate extensions for each profile.
+# Arguments:
+#   Path of the config file to write.
+#######################################
+write_openssl_config() {
+  cat > "$1" <<'EOF'
+[ req ]
+distinguished_name = req_dn
+
+[ req_dn ]
+
+[ root_ca ]
+basicConstraints       = critical, CA:TRUE
+keyUsage               = critical, keyCertSign, cRLSign
+subjectKeyIdentifier   = hash
+
+# C2PA claim signer: digitalSignature KU and the C2PA claim signing EKU.
+[ claim_signer ]
+basicConstraints       = critical, CA:FALSE
+keyUsage               = critical, digitalSignature
+extendedKeyUsage       = 1.3.6.1.4.1.62558.2.1
+subjectKeyIdentifier   = hash
+authorityKeyIdentifier = keyid:always
+
+# RFC 3161 TSA: timeStamping must be the only EKU and marked critical.
+[ tsa ]
+basicConstraints       = critical, CA:FALSE
+keyUsage               = critical, digitalSignature
+extendedKeyUsage       = critical, timeStamping
+subjectKeyIdentifier   = hash
+authorityKeyIdentifier = keyid:always
+EOF
+}
+
+#######################################
+# Generates an EC private key in PKCS#8 PEM format.
+# Arguments:
+#   Output key path.
+#######################################
+generate_key() {
+  openssl genpkey -algorithm EC -pkeyopt "ec_paramgen_curve:${CURVE}" \
+    -out "$1" 2>/dev/null
+  chmod 600 "$1"
+}
+
+#######################################
+# Creates the self-signed root CA key and certificate.
+# Arguments:
+#   Output directory.
+#######################################
+create_root_ca() {
+  local dir="$1"
+  generate_key "${dir}/root_ca_key.pem"
+  openssl req -x509 -new -key "${dir}/root_ca_key.pem" -sha256 \
+    -days "${ROOT_CA_DAYS}" \
+    -subj "/O=${ORGANIZATION}/CN=${ORGANIZATION} Root CA" \
+    -config "${dir}/openssl.cnf" -extensions root_ca \
+    -out "${dir}/root_ca_cert.pem"
+}
+
+#######################################
+# Creates a key and a certificate issued by the root CA.
+# Arguments:
+#   Output directory.
+#   Name of the profile section in openssl.cnf; also the output file prefix.
+#   Common name of the certificate subject.
+#######################################
+issue_leaf() {
+  local dir="$1"
+  local profile="$2"
+  local common_name="$3"
+
+  generate_key "${dir}/${profile}_key.pem"
+  openssl req -new -key "${dir}/${profile}_key.pem" \
+    -subj "/O=${ORGANIZATION}/CN=${common_name}" \
+    -config "${dir}/openssl.cnf" |
+    openssl x509 -req -sha256 -days "${LEAF_DAYS}" \
+      -CA "${dir}/root_ca_cert.pem" -CAkey "${dir}/root_ca_key.pem" \
+      -set_serial "0x$(openssl rand -hex 16)" \
+      -extfile "${dir}/openssl.cnf" -extensions "${profile}" \
+      -out "${dir}/${profile}_cert.pem" 2>/dev/null
+}
+
+main() {
+  if [[ $# -gt 1 ]]; then
+    echo "Usage: $0 [output_dir]" >&2
+    exit 1
+  fi
+  local dir="${1:-test_pki}"
+
+  if ! command -v openssl >/dev/null; then
+    echo "Error: openssl not found in PATH." >&2
+    exit 1
+  fi
+  if compgen -G "${dir}/*_key.pem" >/dev/null; then
+    echo "Error: ${dir} already contains keys; refusing to overwrite." >&2
+    exit 1
+  fi
+
+  mkdir -p "${dir}"
+  write_openssl_config "${dir}/openssl.cnf"
+  create_root_ca "${dir}"
+  issue_leaf "${dir}" claim_signer "${ORGANIZATION} Claim Signer"
+  issue_leaf "${dir}" tsa "${ORGANIZATION} TSA"
+  openssl verify -CAfile "${dir}/root_ca_cert.pem" \
+    "${dir}/claim_signer_cert.pem" "${dir}/tsa_cert.pem" >/dev/null
+
+  local abs_dir
+  abs_dir="$(cd "${dir}" && pwd)"
+  cat <<EOF
+Created test PKI in ${abs_dir}:
+
+  asset_write flags:
+    --claim_signer_key=${abs_dir}/claim_signer_key.pem
+    --claim_signer_cert=${abs_dir}/claim_signer_cert.pem
+    --tsa_key=${abs_dir}/tsa_key.pem
+    --tsa_cert=${abs_dir}/tsa_cert.pem
+
+  c2pa_validate flags:
+    --claim_signer_trust=${abs_dir}/root_ca_cert.pem
+    --tsa_trust=${abs_dir}/root_ca_cert.pem
+
+These credentials are for local testing only. Keep root_ca_key.pem private.
+EOF
+}
+
+main "$@"
diff --git a/tsp/BUILD b/tsp/BUILD
index 626b70e..0d19e95 100644
--- a/tsp/BUILD
+++ b/tsp/BUILD
@@ -120,6 +120,7 @@
         "//crypto/default:hasher",
         "//testing:boringssl_utils",
         "//testing:cms",
+        "@abseil-cpp//absl/base:no_destructor",
         "@abseil-cpp//absl/base:nullability",
         "@abseil-cpp//absl/log",
         "@abseil-cpp//absl/log:absl_check",
@@ -132,11 +133,16 @@
         "@abseil-cpp//absl/types:span",
         "@boringssl//:crypto",
         "@tink_cc//proto:common_cc_proto",
-        "@tink_cc//tink:cleartext_keyset_handle",
+        "@tink_cc//tink:insecure_secret_key_access",
+        "@tink_cc//tink:key_status",
         "@tink_cc//tink:keyset_handle",
+        "@tink_cc//tink:partial_key_access",
         "@tink_cc//tink:public_key_sign",
+        "@tink_cc//tink/pem:signature_key_parser",
         "@tink_cc//tink/signature:config_v0",
-        "@tink_cc//tink/signature:signature_pem_keyset_reader",
+        "@tink_cc//tink/signature:ecdsa_parameters",
+        "@tink_cc//tink/signature:ecdsa_private_key",
+        "@tink_cc//tink/signature:signature_config",
     ],
 )
 
@@ -156,7 +162,7 @@
         "//crypto:cbs_utils",
         "//testing:test_file_utils",
         "@abseil-cpp//absl/base:no_destructor",
-        "@abseil-cpp//absl/log:check",
+        "@abseil-cpp//absl/log:absl_check",
         "@abseil-cpp//absl/status",
         "@abseil-cpp//absl/status:status_matchers",
         "@abseil-cpp//absl/strings",
diff --git a/tsp/test_helpers.cc b/tsp/test_helpers.cc
index 07f3432..0e734e1 100644
--- a/tsp/test_helpers.cc
+++ b/tsp/test_helpers.cc
@@ -24,6 +24,7 @@
 #include <variant>
 #include <vector>
 
+#include "absl/base/no_destructor.h"
 #include "absl/base/nullability.h"
 #include "absl/log/absl_check.h"
 #include "absl/log/check.h"
@@ -51,34 +52,47 @@
 #include "proto/common.pb.h"
 #include "testing/boringssl_utils.h"
 #include "testing/cms.h"
-#include "tink/cleartext_keyset_handle.h"
+#include "tink/insecure_secret_key_access.h"
+#include "tink/key_status.h"
 #include "tink/keyset_handle.h"
+#include "tink/partial_key_access.h"
+#include "tink/pem/signature_key_parser.h"
 #include "tink/public_key_sign.h"
 #include "tink/signature/config_v0.h"
-#include "tink/signature/signature_pem_keyset_reader.h"
+#include "tink/signature/ecdsa_parameters.h"
+#include "tink/signature/ecdsa_private_key.h"
+#include "tink/signature/signature_config.h"
 #include "tsp/constants.h"
 #include "tsp/status_codes.h"
 
 namespace credentio {
 namespace {
 
+using ::crypto::tink::EcdsaParameters;
+using ::crypto::tink::EcdsaPrivateKey;
 using ::crypto::tink::KeysetHandle;
+using ::crypto::tink::KeysetHandleBuilder;
 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<KeysetHandle> LoadEcdsaPrivateKeyFromPem(
+    absl::string_view pem_data) {
+  ABSL_ASSIGN_OR_RETURN(
+      EcdsaParameters params,
+      EcdsaParameters::Builder()
+          .SetCurveType(EcdsaParameters::CurveType::kNistP256)
+          .SetHashType(EcdsaParameters::HashType::kSha256)
+          .SetSignatureEncoding(EcdsaParameters::SignatureEncoding::kDer)
+          .SetVariant(EcdsaParameters::Variant::kNoPrefix)
+          .Build());
+  ABSL_ASSIGN_OR_RETURN(
+      EcdsaPrivateKey private_key,
+      tink_pem::PemToEcdsaPrivateKey(
+          pem_data, params, crypto::tink::InsecureSecretKeyAccess::Get(),
+          crypto::tink::GetPartialKeyAccess()));
+  KeysetHandleBuilder builder;
+  builder.AddEntry(KeysetHandleBuilder::Entry::CreateFromCopyableKey(
+      private_key, crypto::tink::KeyStatus::kEnabled, /*is_primary=*/true));
+  return builder.Build();
 }
 
 absl::StatusOr<std::vector<uint8_t>> SignWithBoringSsl(
@@ -283,11 +297,14 @@
     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);
+  static const absl::NoDestructor<absl::Status> registration_status(
+      []() { return crypto::tink::SignatureConfig::Register(); }());
+  ABSL_CHECK_OK(*registration_status);
+  absl::StatusOr<KeysetHandle> keyset_handle =
+      LoadEcdsaPrivateKeyFromPem(key_pem);
   ABSL_CHECK_OK(keyset_handle);
-  auto signer = (*keyset_handle)
-                    ->GetPrimitive<crypto::tink::PublicKeySign>(
-                        crypto::tink::ConfigSignatureV0());
+  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);
 
diff --git a/tsp/timestamp_parsing_test.cc b/tsp/timestamp_parsing_test.cc
index 46c985e..438124f 100644
--- a/tsp/timestamp_parsing_test.cc
+++ b/tsp/timestamp_parsing_test.cc
@@ -20,7 +20,7 @@
 #include <string>
 
 #include "absl/base/no_destructor.h"
-#include "absl/log/check.h"
+#include "absl/log/absl_check.h"
 #include "absl/status/status.h"
 #include "absl/status/status_matchers.h"
 #include "absl/strings/str_cat.h"
@@ -53,7 +53,7 @@
 
 constexpr absl::string_view kMessageImprintHash = "fake message imprint";
 
-constexpr absl::string_view kTestKeysDir = "c2pa/testing/testdata/keys/";
+constexpr absl::string_view kTestKeysDir = "credentio/testing/testdata/keys/";
 
 absl::string_view ValidNonce() {
   static absl::NoDestructor<std::string> nonce(EncodeDerInteger(0xf00dbabe));
@@ -63,14 +63,14 @@
 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());
+  ABSL_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))
+  ABSL_CHECK(absl::ParseTime(kGenTimeFormat, timestamp, &time, &parse_error))
       << "ParseTime(\"" << kGenTimeFormat << "\", \"" << timestamp
       << "\"): " << parse_error;
   return time;
@@ -145,13 +145,13 @@
 
 TEST(ParseTimeStampRespTest, InvalidPkiStatusInfo) {
   CBB cbb;
-  CHECK(CBB_init(&cbb, 1024));
+  ABSL_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));
+  ABSL_CHECK(CBB_add_asn1(&cbb, &req, CBS_ASN1_SEQUENCE));
+  ABSL_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));
+  ABSL_CHECK(CBB_finish(&cbb, &cbb_data, &cbb_len));
   bssl::UniquePtr<uint8_t> cbb_data_uniq(cbb_data);
 
   EXPECT_THAT(ParseTimestampResp(absl::string_view(
@@ -162,16 +162,16 @@
 
 TEST(ParseTimeStampRespTest, InvalidStatusCode) {
   CBB cbb;
-  CHECK(CBB_init(&cbb, 1024));
+  ABSL_CHECK(CBB_init(&cbb, 1024));
   CBB req;
-  CHECK(CBB_add_asn1(&cbb, &req, CBS_ASN1_SEQUENCE));
+  ABSL_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));
+  ABSL_CHECK(CBB_add_asn1(&req, &pki_status_info, CBS_ASN1_SEQUENCE));
+  ABSL_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));
+  ABSL_CHECK(CBB_finish(&cbb, &cbb_data, &cbb_len));
   bssl::UniquePtr<uint8_t> cbb_data_uniq(cbb_data);
 
   EXPECT_THAT(ParseTimestampResp(absl::string_view(
@@ -182,17 +182,17 @@
 
 TEST(ParseTimeStampRespTest, InvalidTimestampToken) {
   CBB cbb;
-  CHECK(CBB_init(&cbb, 1024));
+  ABSL_CHECK(CBB_init(&cbb, 1024));
   CBB req;
-  CHECK(CBB_add_asn1(&cbb, &req, CBS_ASN1_SEQUENCE));
+  ABSL_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(
+  ABSL_CHECK(CBB_add_asn1(&req, &pki_status_info, CBS_ASN1_SEQUENCE));
+  ABSL_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));
+  ABSL_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));
+  ABSL_CHECK(CBB_finish(&cbb, &cbb_data, &cbb_len));
   bssl::UniquePtr<uint8_t> cbb_data_uniq(cbb_data);
 
   EXPECT_THAT(ParseTimestampResp(absl::string_view(
@@ -284,15 +284,15 @@
 
 TEST(ParseTstInfoTest, InvalidVersion) {
   CBB cbb;
-  CHECK(CBB_init(&cbb, 1024));
+  ABSL_CHECK(CBB_init(&cbb, 1024));
   CBB tst_info;
-  CHECK(CBB_add_asn1(&cbb, &tst_info, CBS_ASN1_SEQUENCE));
+  ABSL_CHECK(CBB_add_asn1(&cbb, &tst_info, CBS_ASN1_SEQUENCE));
   // Version - should be INTEGER
-  CHECK(CBB_add_asn1_octet_string(&tst_info, nullptr, 0));
+  ABSL_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));
+  ABSL_CHECK(CBB_finish(&cbb, &cbb_data, &cbb_len));
   bssl::UniquePtr<uint8_t> cbb_data_uniq(cbb_data);
 
   absl::Time time;
@@ -309,17 +309,17 @@
 
 TEST(ParseTstInfoTest, InvalidPolicy) {
   CBB cbb;
-  CHECK(CBB_init(&cbb, 1024));
+  ABSL_CHECK(CBB_init(&cbb, 1024));
   CBB tst_info;
-  CHECK(CBB_add_asn1(&cbb, &tst_info, CBS_ASN1_SEQUENCE));
+  ABSL_CHECK(CBB_add_asn1(&cbb, &tst_info, CBS_ASN1_SEQUENCE));
   // Version
-  CHECK(CBB_add_asn1_int64(&tst_info, 1));
+  ABSL_CHECK(CBB_add_asn1_int64(&tst_info, 1));
   // Policy - should be OBJECT IDENTIFIER
-  CHECK(CBB_add_asn1_int64(&tst_info, 12345));
+  ABSL_CHECK(CBB_add_asn1_int64(&tst_info, 12345));
 
   uint8_t* cbb_data;
   size_t cbb_len;
-  CHECK(CBB_finish(&cbb, &cbb_data, &cbb_len));
+  ABSL_CHECK(CBB_finish(&cbb, &cbb_data, &cbb_len));
   bssl::UniquePtr<uint8_t> cbb_data_uniq(cbb_data);
 
   absl::Time time;
diff --git a/utils/BUILD b/utils/BUILD
index a8d25b0..3d84b03 100644
--- a/utils/BUILD
+++ b/utils/BUILD
@@ -324,9 +324,11 @@
         "//proto:validation_result_cc_proto",
         "//proto:validation_status_cc_proto",
         "//tsp:timestamp_verifier",
+        "//validator:result",
         "@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",
         "@abseil-cpp//absl/strings:string_view",
@@ -371,3 +373,38 @@
         "@nlohmann_json//:json",
     ],
 )
+
+cc_library(
+    name = "cbor_padding",
+    srcs = ["cbor_padding.cc"],
+    hdrs = ["cbor_padding.h"],
+    deps = [
+        "//cose:sig_structure",
+        "//proto:boxes_hash_assertion_cc_proto",
+        "//proto:data_hash_assertion_cc_proto",
+        "//proto:multi_asset_hash_assertion_cc_proto",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/strings",
+        "@libcppbor",
+    ],
+)
+
+cc_test(
+    name = "cbor_padding_test",
+    srcs = ["cbor_padding_test.cc"],
+    deps = [
+        ":cbor_padding",
+        "//cose:sig_structure",
+        "//crypto:algorithms",
+        "//proto:boxes_hash_assertion_cc_proto",
+        "//proto:data_hash_assertion_cbor_proto",
+        "//proto:data_hash_assertion_cc_proto",
+        "//testing:proto_test_utils",
+        "@abseil-cpp//absl/log:absl_check",
+        "@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/utils/byte_instruction.cc b/utils/byte_instruction.cc
index c04b707..030f3c7 100644
--- a/utils/byte_instruction.cc
+++ b/utils/byte_instruction.cc
@@ -17,7 +17,9 @@
 
 #include <algorithm>
 #include <cstdint>
+#include <optional>
 #include <string>
+#include <vector>
 
 #include "absl/base/nullability.h"
 #include "absl/status/status.h"
@@ -283,4 +285,41 @@
   return absl::OkStatus();
 }
 
+uint64_t CalculateBytesWritten(
+    const std::vector<ByteInstruction>& byte_instructions, uint64_t file_size,
+    bool is_copy) {
+  uint64_t sum_replace_before_insert = 0;
+  uint64_t sum_insert = 0;
+  std::optional<uint64_t> first_insert_offset =
+      is_copy ? std::make_optional<uint64_t>(0) : std::nullopt;
+
+  for (const auto& byte_instruction : byte_instructions) {
+    if (byte_instruction.offset >= file_size) {
+      // Instructions at or past the end of the original file do not replace
+      // any existing bytes; they are simply appended to the destination.
+      sum_insert += byte_instruction.bytes.size();
+    } else if (byte_instruction.operation ==
+               ByteInstruction::Operation::kReplace) {
+      if (!first_insert_offset.has_value()) {
+        sum_replace_before_insert += byte_instruction.bytes.size();
+      }
+    } else if (byte_instruction.operation ==
+               ByteInstruction::Operation::kInsert) {
+      sum_insert += byte_instruction.bytes.size();
+      if (!first_insert_offset.has_value()) {
+        first_insert_offset = byte_instruction.offset;
+      }
+    }
+  }
+
+  uint64_t bytes_shifted = 0;
+  if (!first_insert_offset.has_value() || *first_insert_offset >= file_size) {
+    bytes_shifted = 0;
+  } else {
+    bytes_shifted = file_size - *first_insert_offset;
+  }
+
+  return bytes_shifted + sum_insert + sum_replace_before_insert;
+}
+
 }  // namespace credentio
diff --git a/utils/byte_instruction.h b/utils/byte_instruction.h
index d72f3e5..8afcab8 100644
--- a/utils/byte_instruction.h
+++ b/utils/byte_instruction.h
@@ -18,6 +18,7 @@
 
 #include <cstdint>
 #include <string>
+#include <vector>
 
 #include "absl/base/nullability.h"
 #include "absl/status/status.h"
@@ -73,6 +74,11 @@
     absl::Span<const credentio::ByteInstruction> instructions,
     riegeli::Writer* absl_nonnull writer);
 
+// Calculates the number of bytes that will be written by ApplyByteInstructions.
+uint64_t CalculateBytesWritten(
+    const std::vector<ByteInstruction>& byte_instructions, uint64_t file_size,
+    bool is_copy);
+
 }  // namespace credentio
 
 #endif  // THIRD_PARTY_CREDENTIO_UTILS_BYTE_INSTRUCTION_H_
diff --git a/utils/byte_instruction_test.cc b/utils/byte_instruction_test.cc
index 5d0c0b0..5ea9922 100644
--- a/utils/byte_instruction_test.cc
+++ b/utils/byte_instruction_test.cc
@@ -212,9 +212,9 @@
 
 TEST_P(SuccessfulApplicationTest, AppliesByteInstructions) {
   riegeli::StringReader<std::string> reader(GetParam().input);
-  ASSERT_THAT(reader.status(), IsOk());
+  ABSL_ASSERT_OK(reader.status());
   riegeli::StringWriter<std::string> writer;
-  ASSERT_THAT(writer.status(), IsOk());
+  ABSL_ASSERT_OK(writer.status());
 
   EXPECT_THAT(ApplyByteInstructions(&reader, GetParam().instructions, &writer),
               IsOk());
@@ -228,7 +228,7 @@
 TEST_P(SuccessfulApplicationTest, AppliesByteInstructionsInPlace) {
   riegeli::StringWriter<std::string> writer(
       GetParam().input, riegeli::StringWriterBase::Options().set_append(true));
-  ASSERT_THAT(writer.status(), IsOk());
+  ABSL_ASSERT_OK(writer.status());
 
   EXPECT_THAT(ApplyByteInstructions(GetParam().instructions, &writer), IsOk());
 
@@ -239,11 +239,11 @@
 
 TEST(SuccessfulApplicationTest, ReaderNotAtStart) {
   riegeli::StringReader<> reader("hello world");
-  ASSERT_THAT(reader.status(), IsOk());
+  ABSL_ASSERT_OK(reader.status());
   ASSERT_TRUE(reader.Seek(8));
 
   riegeli::StringWriter<std::string> writer;
-  ASSERT_THAT(writer.status(), IsOk());
+  ABSL_ASSERT_OK(writer.status());
 
   EXPECT_THAT(
       ApplyByteInstructions(&reader,
@@ -262,7 +262,7 @@
 TEST(SuccessfulApplicationTest, WriterNotAtStart) {
   riegeli::StringWriter<std::string> writer(
       "hello world", riegeli::StringWriterBase::Options().set_append(true));
-  ASSERT_THAT(writer.status(), IsOk());
+  ABSL_ASSERT_OK(writer.status());
   ASSERT_TRUE(writer.Seek(8));
 
   EXPECT_THAT(
@@ -279,11 +279,11 @@
 
 TEST(FailedApplicationTest, ReaderClosed) {
   riegeli::StringReader<> reader("hello world");
-  ASSERT_THAT(reader.status(), IsOk());
+  ABSL_ASSERT_OK(reader.status());
   reader.Close();
 
   riegeli::StringWriter<std::string> writer;
-  ASSERT_THAT(writer.status(), IsOk());
+  ABSL_ASSERT_OK(writer.status());
 
   EXPECT_THAT(ApplyByteInstructions(&reader, {}, &writer),
               StatusIs(absl::StatusCode::kFailedPrecondition,
@@ -292,10 +292,10 @@
 
 TEST(FailedApplicationTest, WriterClosed) {
   riegeli::StringReader<> reader("hello world");
-  ASSERT_THAT(reader.status(), IsOk());
+  ABSL_ASSERT_OK(reader.status());
 
   riegeli::StringWriter<std::string> writer;
-  ASSERT_THAT(writer.status(), IsOk());
+  ABSL_ASSERT_OK(writer.status());
   writer.Close();
 
   EXPECT_THAT(ApplyByteInstructions(&reader, {}, &writer),
@@ -305,10 +305,10 @@
 
 TEST(FailedApplicationTest, InstructionOutOfOrder) {
   riegeli::StringReader<> reader("hello world");
-  ASSERT_THAT(reader.status(), IsOk());
+  ABSL_ASSERT_OK(reader.status());
 
   riegeli::StringWriter<std::string> writer;
-  ASSERT_THAT(writer.status(), IsOk());
+  ABSL_ASSERT_OK(writer.status());
 
   EXPECT_THAT(
       ApplyByteInstructions(&reader,
@@ -336,10 +336,10 @@
 
 TEST(FailedApplicationTest, UnexpectedEofDuringCopy) {
   PrematureEofReader reader("hello", 100);
-  ASSERT_THAT(reader.status(), IsOk());
+  ABSL_ASSERT_OK(reader.status());
 
   riegeli::StringWriter<std::string> writer;
-  ASSERT_THAT(writer.status(), IsOk());
+  ABSL_ASSERT_OK(writer.status());
 
   EXPECT_THAT(
       ApplyByteInstructions(&reader,
@@ -353,10 +353,10 @@
 
 TEST(FailedApplicationTest, UnexpectedEofDuringSkip) {
   PrematureEofReader reader("hello", 100);
-  ASSERT_THAT(reader.status(), IsOk());
+  ABSL_ASSERT_OK(reader.status());
 
   riegeli::StringWriter<std::string> writer;
-  ASSERT_THAT(writer.status(), IsOk());
+  ABSL_ASSERT_OK(writer.status());
 
   EXPECT_THAT(
       ApplyByteInstructions(&reader,
@@ -371,7 +371,7 @@
 TEST(FailedApplicationInPlaceTest, WriterClosed) {
   riegeli::StringWriter<std::string> writer(
       "hello world", riegeli::StringWriterBase::Options().set_append(true));
-  ASSERT_THAT(writer.status(), IsOk());
+  ABSL_ASSERT_OK(writer.status());
   writer.Close();
 
   EXPECT_THAT(ApplyByteInstructions({}, &writer),
@@ -382,7 +382,7 @@
 TEST(FailedApplicationInPlaceTest, InstructionOutOfOrder) {
   riegeli::StringWriter<std::string> writer(
       "hello world", riegeli::StringWriterBase::Options().set_append(true));
-  ASSERT_THAT(writer.status(), IsOk());
+  ABSL_ASSERT_OK(writer.status());
 
   EXPECT_THAT(
       ApplyByteInstructions({{.operation = ByteInstruction::Operation::kReplace,
@@ -396,5 +396,281 @@
                HasSubstr("must be in ascending order")));
 }
 
+TEST(CalculateBytesWrittenTest, IsCopyEmptyFile) {
+  EXPECT_EQ(CalculateBytesWritten({}, /*file_size=*/0, /*is_copy=*/true), 0);
+  EXPECT_EQ(
+      CalculateBytesWritten({{.operation = ByteInstruction::Operation::kInsert,
+                              .offset = 0,
+                              .bytes = "hello"}},
+                            /*file_size=*/0, /*is_copy=*/true),
+      5);
+}
+
+TEST(CalculateBytesWrittenTest, IsCopyNoInstructions) {
+  EXPECT_EQ(CalculateBytesWritten({}, /*file_size=*/100, /*is_copy=*/true),
+            100);
+}
+
+TEST(CalculateBytesWrittenTest, IsCopyOnlyInserts) {
+  std::vector<ByteInstruction> instructions = {
+      {.operation = ByteInstruction::Operation::kInsert,
+       .offset = 10,
+       .bytes = "hello"},
+      {.operation = ByteInstruction::Operation::kInsert,
+       .offset = 50,
+       .bytes = "world!"},
+  };
+  EXPECT_EQ(
+      CalculateBytesWritten(instructions, /*file_size=*/100, /*is_copy=*/true),
+      100 + 5 + 6);
+}
+
+TEST(CalculateBytesWrittenTest, IsCopyOnlyReplaces) {
+  std::vector<ByteInstruction> instructions = {
+      {.operation = ByteInstruction::Operation::kReplace,
+       .offset = 10,
+       .bytes = "hello"},
+      {.operation = ByteInstruction::Operation::kReplace,
+       .offset = 50,
+       .bytes = "world!"},
+  };
+  EXPECT_EQ(
+      CalculateBytesWritten(instructions, /*file_size=*/100, /*is_copy=*/true),
+      100);
+}
+
+TEST(CalculateBytesWrittenTest, IsCopyMixed) {
+  std::vector<ByteInstruction> instructions = {
+      {.operation = ByteInstruction::Operation::kReplace,
+       .offset = 10,
+       .bytes = "replace"},
+      {.operation = ByteInstruction::Operation::kInsert,
+       .offset = 40,
+       .bytes = "insert_data"},
+  };
+  EXPECT_EQ(
+      CalculateBytesWritten(instructions, /*file_size=*/100, /*is_copy=*/true),
+      100 + 11);
+}
+
+TEST(CalculateBytesWrittenTest, InPlaceEmptyFile) {
+  EXPECT_EQ(CalculateBytesWritten({}, /*file_size=*/0, /*is_copy=*/false), 0);
+  EXPECT_EQ(
+      CalculateBytesWritten({{.operation = ByteInstruction::Operation::kInsert,
+                              .offset = 0,
+                              .bytes = "hello"}},
+                            /*file_size=*/0, /*is_copy=*/false),
+      5);
+  EXPECT_EQ(
+      CalculateBytesWritten({{.operation = ByteInstruction::Operation::kReplace,
+                              .offset = 0,
+                              .bytes = "hello"}},
+                            /*file_size=*/0, /*is_copy=*/false),
+      5);
+}
+
+TEST(CalculateBytesWrittenTest, InPlaceNoInstructions) {
+  EXPECT_EQ(CalculateBytesWritten({}, /*file_size=*/100, /*is_copy=*/false), 0);
+}
+
+TEST(CalculateBytesWrittenTest, InPlaceOnlyReplaces) {
+  std::vector<ByteInstruction> instructions = {
+      {.operation = ByteInstruction::Operation::kReplace,
+       .offset = 5,
+       .bytes = "abc"},
+      {.operation = ByteInstruction::Operation::kReplace,
+       .offset = 50,
+       .bytes = "12345"},
+  };
+  EXPECT_EQ(
+      CalculateBytesWritten(instructions, /*file_size=*/100, /*is_copy=*/false),
+      3 + 5);
+}
+
+TEST(CalculateBytesWrittenTest, InPlaceOnlyInsertsSingle) {
+  std::vector<ByteInstruction> instructions = {
+      {.operation = ByteInstruction::Operation::kInsert,
+       .offset = 40,
+       .bytes = "12345"},
+  };
+  // Shifted bytes: (100 - 40) = 60, inserted bytes: 5 -> total 65
+  EXPECT_EQ(
+      CalculateBytesWritten(instructions, /*file_size=*/100, /*is_copy=*/false),
+      65);
+}
+
+TEST(CalculateBytesWrittenTest, InPlaceOnlyInsertsMultiple) {
+  std::vector<ByteInstruction> instructions = {
+      {.operation = ByteInstruction::Operation::kInsert,
+       .offset = 20,
+       .bytes = "12345"},
+      {.operation = ByteInstruction::Operation::kInsert,
+       .offset = 50,
+       .bytes = "abc"},
+  };
+  // First insert offset: 20 -> Shifted bytes: 80, inserts: 5 + 3 = 8 -> total
+  // 88
+  EXPECT_EQ(
+      CalculateBytesWritten(instructions, /*file_size=*/100, /*is_copy=*/false),
+      88);
+}
+
+TEST(CalculateBytesWrittenTest, InPlaceReplaceBeforeInsert) {
+  std::vector<ByteInstruction> instructions = {
+      {.operation = ByteInstruction::Operation::kReplace,
+       .offset = 5,
+       .bytes = "1234567890"},
+      {.operation = ByteInstruction::Operation::kInsert,
+       .offset = 40,
+       .bytes = "abcdefghij"},
+  };
+  // Replace before insert: 10, first insert offset: 40 -> shifted: 60, insert:
+  // 10 -> total 10 + 60 + 10 = 80
+  EXPECT_EQ(
+      CalculateBytesWritten(instructions, /*file_size=*/100, /*is_copy=*/false),
+      80);
+}
+
+TEST(CalculateBytesWrittenTest, InPlaceReplaceAfterInsert) {
+  std::vector<ByteInstruction> instructions = {
+      {.operation = ByteInstruction::Operation::kInsert,
+       .offset = 10,
+       .bytes = "12345"},
+      {.operation = ByteInstruction::Operation::kReplace,
+       .offset = 50,
+       .bytes = "abcdefghij"},
+  };
+  // First insert offset: 10 -> shifted: 90, insert: 5, replace before insert: 0
+  // -> total 95
+  EXPECT_EQ(
+      CalculateBytesWritten(instructions, /*file_size=*/100, /*is_copy=*/false),
+      95);
+}
+
+TEST(CalculateBytesWrittenTest, InPlaceReplaceBeforeAndAfterInsert) {
+  std::vector<ByteInstruction> instructions = {
+      {.operation = ByteInstruction::Operation::kReplace,
+       .offset = 2,
+       .bytes = "12345678"},
+      {.operation = ByteInstruction::Operation::kInsert,
+       .offset = 20,
+       .bytes = "123456789012345"},
+      {.operation = ByteInstruction::Operation::kReplace,
+       .offset = 60,
+       .bytes = "123456789012"},
+  };
+  // Replace before insert: 8, first insert offset: 20 -> shifted: 80, insert:
+  // 15 -> total 8 + 80 + 15 = 103
+  EXPECT_EQ(
+      CalculateBytesWritten(instructions, /*file_size=*/100, /*is_copy=*/false),
+      103);
+}
+
+TEST(CalculateBytesWrittenTest, InPlaceInsertPastEndOfFile) {
+  std::vector<ByteInstruction> instructions = {
+      {.operation = ByteInstruction::Operation::kInsert,
+       .offset = 120,
+       .bytes = "1234567890"},  // 10 bytes past EOF (file_size = 100)
+  };
+  // Offset 120 >= 100: no shifted original bytes -> bytes_shifted = 0, insert =
+  // 10 -> total 10
+  EXPECT_EQ(
+      CalculateBytesWritten(instructions, /*file_size=*/100, /*is_copy=*/false),
+      10);
+}
+
+TEST(CalculateBytesWrittenTest, InPlaceInsertPastEndOfFileWithPriorReplace) {
+  std::vector<ByteInstruction> instructions = {
+      {.operation = ByteInstruction::Operation::kReplace,
+       .offset = 10,
+       .bytes = "12345"},  // 5 bytes replaced before insert
+      {.operation = ByteInstruction::Operation::kInsert,
+       .offset = 120,
+       .bytes = "1234567890"},  // 10 bytes past EOF
+  };
+  // Replace before insert = 5, offset 120 >= 100: bytes_shifted = 0, insert =
+  // 10 -> total 15
+  EXPECT_EQ(
+      CalculateBytesWritten(instructions, /*file_size=*/100, /*is_copy=*/false),
+      15);
+}
+
+TEST(CalculateBytesWrittenTest, InPlaceInsertAtEndOfFileBoundary) {
+  std::vector<ByteInstruction> instructions = {
+      {.operation = ByteInstruction::Operation::kInsert,
+       .offset = 100,
+       .bytes = "1234567890"},  // 10 bytes at exact EOF (file_size = 100)
+  };
+  // Offset 100 >= 100: bytes_shifted = 0, insert = 10 -> total 10
+  EXPECT_EQ(
+      CalculateBytesWritten(instructions, /*file_size=*/100, /*is_copy=*/false),
+      10);
+}
+
+TEST(CalculateBytesWrittenTest, InPlaceInsertJustBeforeEndOfFile) {
+  std::vector<ByteInstruction> instructions = {
+      {.operation = ByteInstruction::Operation::kInsert,
+       .offset = 99,
+       .bytes = "1234567890"},  // 10 bytes at offset 99 (file_size = 100)
+  };
+  // Offset 99 < 100: bytes_shifted = 100 - 99 = 1, insert = 10 -> total 11
+  EXPECT_EQ(
+      CalculateBytesWritten(instructions, /*file_size=*/100, /*is_copy=*/false),
+      11);
+}
+
+TEST(CalculateBytesWrittenTest, IsCopyInsertPastEndOfFile) {
+  std::vector<ByteInstruction> instructions = {
+      {.operation = ByteInstruction::Operation::kInsert,
+       .offset = 120,
+       .bytes = "1234567890"},  // 10 bytes past EOF (file_size = 100)
+  };
+  // In copy mode, entire original file is written -> bytes_shifted = 100,
+  // insert = 10 -> total 110
+  EXPECT_EQ(
+      CalculateBytesWritten(instructions, /*file_size=*/100, /*is_copy=*/true),
+      110);
+}
+
+TEST(CalculateBytesWrittenTest, IsCopyEmptyFileWithReplace) {
+  std::vector<ByteInstruction> instructions = {
+      {.operation = ByteInstruction::Operation::kReplace,
+       .offset = 0,
+       .bytes = "12345"},  // Replace 5 bytes on file_size = 0
+  };
+  // file_size = 0, replace 5 bytes: instruction is written as append -> total 5
+  EXPECT_EQ(
+      CalculateBytesWritten(instructions, /*file_size=*/0, /*is_copy=*/true),
+      5);
+}
+
+TEST(CalculateBytesWrittenTest, InPlaceReplacePastEndOfFileNoInsert) {
+  std::vector<ByteInstruction> instructions = {
+      {.operation = ByteInstruction::Operation::kReplace,
+       .offset = 120,
+       .bytes = "12345"},  // 5 bytes replace past EOF (file_size = 100)
+  };
+  // No insert: bytes_shifted = 0, sum_replace_before_insert = 5 -> total 5
+  EXPECT_EQ(
+      CalculateBytesWritten(instructions, /*file_size=*/100, /*is_copy=*/false),
+      5);
+}
+
+TEST(CalculateBytesWrittenTest, InPlaceMultipleInsertsFirstPastEOF) {
+  std::vector<ByteInstruction> instructions = {
+      {.operation = ByteInstruction::Operation::kInsert,
+       .offset = 120,
+       .bytes = "12345"},  // 5 bytes at offset 120
+      {.operation = ByteInstruction::Operation::kInsert,
+       .offset = 150,
+       .bytes = "1234567890"},  // 10 bytes at offset 150
+  };
+  // First insert offset 120 >= 100: bytes_shifted = 0, sum_insert = 15 -> total
+  // 15
+  EXPECT_EQ(
+      CalculateBytesWritten(instructions, /*file_size=*/100, /*is_copy=*/false),
+      15);
+}
+
 }  // namespace
 }  // namespace credentio
diff --git a/utils/cbor_padding.cc b/utils/cbor_padding.cc
new file mode 100644
index 0000000..383ccf3
--- /dev/null
+++ b/utils/cbor_padding.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 "utils/cbor_padding.h"
+
+#include <cstddef>
+#include <cstdint>
+#include <optional>
+#include <string>
+
+#include "absl/status/status.h"
+#include "absl/strings/str_cat.h"
+#include "cose/sig_structure.h"
+#include "cppbor/cppbor.h"
+#include "proto/boxes_hash_assertion.pb.h"
+#include "proto/data_hash_assertion.pb.h"
+#include "proto/multi_asset_hash_assertion.pb.h"
+
+namespace credentio {
+namespace {
+// See headerSize() function in cppbor.cpp
+constexpr size_t kIntMaxEncodeByteSize = 9;
+// The header byte size of a CBOR-encoded `TstContainer` that includes only
+// one token of length 0.
+constexpr size_t kTstContainerHeaderByteSize = 26;
+// This is a value used to pad the empty COSE_Sign1 structure to the expected
+// overall size. It is the initial size of pad2 to use when padding a COSE_Sign1
+// message and can be decremented to get us to the desired size. This is set to
+// 8 because any increase in pad to the expected size can at most result in an
+// overshoot of 8 bytes. This occurs when the expected size is 4,294,967,296
+// bytes greater than the initial marshalled sign1 size. headerSize(4294967296)
+// = 9 and headerSize(0) = 1, so 9 - 1 = 8.
+constexpr int kInitialPad2Size = 8;
+
+size_t EncodeByteSize(int64_t v) {
+  if (v < 0) {
+    return cppbor::Nint(v).encodedSize();
+  }
+  return cppbor::Uint(v).encodedSize();
+}
+}  // namespace
+
+void PadDataHashAssertion(DataHashAssertion& data_hash) {
+  data_hash.set_pad("");  // https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html
+                          // requires its presence.
+  data_hash.clear_pad2();
+  if (data_hash.exclusions_size() == 0) {
+    return;
+  }
+  size_t pad_size = 0;
+  for (const auto& exclusion : data_hash.exclusions()) {
+    pad_size += kIntMaxEncodeByteSize - EncodeByteSize(exclusion.start());
+    pad_size += kIntMaxEncodeByteSize - EncodeByteSize(exclusion.length());
+  }
+  data_hash.set_pad(std::string(pad_size, '\0'));
+  // `pad` is a Bstr, and its header encodes the length as an Uint, which itself
+  // is a variable of encoded sizes. We use an optional `pad2` to count for this
+  // variable.
+  size_t max_pad_size =
+      (kIntMaxEncodeByteSize - 1) * 2 * data_hash.exclusions_size();
+  if (cppbor::Uint(max_pad_size).encodedSize() > 1) {
+    // The pad_size now becomes a variable of byte sizes, since it can be
+    // encoded as 1, 2, 3, 5 or 9 bytes, depending on the value.
+    size_t header_delta = cppbor::Uint(max_pad_size).encodedSize() -
+                          cppbor::Uint(pad_size).encodedSize();
+    data_hash.set_pad2(std::string(header_delta, '\0'));
+  }
+}
+
+void PadBoxesHashAssertion(BoxesHashAssertion& boxes_hash) {
+  for (auto& box : *boxes_hash.mutable_boxes()) {
+    // https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html
+    // requires `pad` to be set.
+    box.set_pad("");
+    // Optional, but set it in case some third-party validators require it.
+    box.set_pad2("");
+  }
+}
+
+void PadMultiAssetHashAssertion(MultiAssetHashAssertion& multi_asset_hash) {
+  multi_asset_hash.set_pad("");  // https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html
+                                 // requires its presence.
+  multi_asset_hash.clear_pad2();
+  if (multi_asset_hash.parts_size() == 0) {
+    return;
+  }
+  size_t pad_size = 0;
+  for (const auto& part : multi_asset_hash.parts()) {
+    pad_size +=
+        kIntMaxEncodeByteSize - EncodeByteSize(part.location().byte_offset());
+    pad_size +=
+        kIntMaxEncodeByteSize - EncodeByteSize(part.location().length());
+  }
+  multi_asset_hash.set_pad(std::string(pad_size, '\0'));
+  // `pad` is a Bstr, and its header encodes the length as an Uint, which itself
+  // is a variable of encoded sizes. We use an optional `pad2` to count for this
+  // variable.
+  size_t max_pad_size =
+      (kIntMaxEncodeByteSize - 1) * 2 * multi_asset_hash.parts_size();
+  if (cppbor::Uint(max_pad_size).encodedSize() > 1) {
+    // The pad_size now becomes a variable of byte sizes, since it can be
+    // encoded as 1, 2, 3, 5 or 9 bytes, depending on the value.
+    size_t header_delta = cppbor::Uint(max_pad_size).encodedSize() -
+                          cppbor::Uint(pad_size).encodedSize();
+    multi_asset_hash.set_pad2(std::string(header_delta, '\0'));
+  }
+}
+
+absl::Status PadCoseSign1TaggedStructure(CoseSign1TaggedStructure& cose_sign1,
+                                         int64_t max_signature_size,
+                                         int64_t max_timestamp_size) {
+  if (cose_sign1.unprotected_header.sig_tst2.has_value() &&
+      cose_sign1.unprotected_header.sig_tst2->tst_tokens.size() > 1) {
+    return absl::InternalError("multiple timestamp tokens");
+  }
+  if (cose_sign1.signature.size() > max_signature_size) {
+    return absl::InternalError(
+        "signature size is larger than the maximum allowed.");
+  }
+  if (cose_sign1.unprotected_header.sig_tst2.has_value() &&
+      cose_sign1.unprotected_header.sig_tst2->tst_tokens[0].val.size() >
+          max_timestamp_size) {
+    return absl::InternalError(
+        "timestamp size is larger than the maximum allowed.");
+  }
+
+  // Clear paddings.
+  cose_sign1.unprotected_header.pad = std::nullopt;
+  cose_sign1.unprotected_header.pad2 = std::nullopt;
+
+  // Pad signature.
+  int64_t pad_size = max_signature_size - cose_sign1.signature.size();
+  // Header size overhead when encoding the signature bstr.
+  int64_t header_size = EncodeByteSize(max_signature_size) -
+                        EncodeByteSize(cose_sign1.signature.size());
+
+  // Pad timestamp.
+  if (cose_sign1.unprotected_header.sig_tst2.has_value()) {
+    int64_t timestamp_size =
+        cose_sign1.unprotected_header.sig_tst2->tst_tokens[0].val.size();
+    pad_size += max_timestamp_size - timestamp_size;
+    // Header size overhead when encoding the timestamp bstr.
+    header_size +=
+        EncodeByteSize(max_timestamp_size) - EncodeByteSize(timestamp_size);
+  } else {
+    pad_size += max_timestamp_size + kTstContainerHeaderByteSize;
+    // Header size overhead when encoding the timestamp bstr.
+    header_size += EncodeByteSize(max_timestamp_size) - EncodeByteSize(0);
+  }
+
+  // Write padding in the `pad` field.
+  cose_sign1.unprotected_header.pad = std::string(pad_size, '\0');
+
+  // Header size overhead when encoding the `pad` bstr.
+  int64_t max_pad_size =
+      max_signature_size + max_timestamp_size + kTstContainerHeaderByteSize;
+  header_size += EncodeByteSize(max_pad_size) - EncodeByteSize(pad_size);
+
+  // Write header overhead padding in the `pad2` field.
+  cose_sign1.unprotected_header.pad2 = std::string(header_size, '\0');
+  if (EncodeByteSize(header_size) != 1) {
+    return absl::InternalError(
+        "the additional encoded length of the pad2 header does "
+        "not match the assumption of 1.");
+  }
+  return absl::OkStatus();
+}
+
+absl::Status PadCoseSign1TaggedStructure(CoseSign1TaggedStructure& cose_sign1,
+                                         size_t target_size) {
+  cose_sign1.unprotected_header.pad = "";
+  cose_sign1.unprotected_header.pad2 = std::string(kInitialPad2Size, '\0');
+  size_t current_size = EncodeCoseSign1TaggedStructure(cose_sign1).size();
+  if (target_size < current_size) {
+    // This should never actually occur as long as target_size is
+    // set to be larger than the 25 bytes (Sign1 structure initial size).
+    return absl::InternalError(absl::StrCat("Expected size ", target_size,
+                                            " is smaller than base size ",
+                                            current_size));
+  }
+  size_t pad_size = target_size - current_size;
+  cose_sign1.unprotected_header.pad = std::string(pad_size, '\0');
+  int pad_prefix_length_delta =
+      cppbor::Uint(pad_size).encodedSize() - cppbor::Uint(0).encodedSize();
+  int new_pad2_size = kInitialPad2Size - pad_prefix_length_delta;
+  cose_sign1.unprotected_header.pad2 = std::string(new_pad2_size, '\0');
+  return absl::OkStatus();
+}
+
+}  // namespace credentio
diff --git a/utils/cbor_padding.h b/utils/cbor_padding.h
new file mode 100644
index 0000000..7ce218b
--- /dev/null
+++ b/utils/cbor_padding.h
@@ -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.
+//
+
+#ifndef THIRD_PARTY_CREDENTIO_UTILS_CBOR_PADDING_H_
+#define THIRD_PARTY_CREDENTIO_UTILS_CBOR_PADDING_H_
+
+#include <cstddef>
+#include <cstdint>
+
+#include "absl/status/status.h"
+#include "cose/sig_structure.h"
+#include "proto/boxes_hash_assertion.pb.h"
+#include "proto/data_hash_assertion.pb.h"
+#include "proto/multi_asset_hash_assertion.pb.h"
+
+namespace credentio {
+// Adds padding to DataHashAssertion to account for the variable encoding sizes
+// of integers when serializing to CBOR. Given any two DataHashAssertion(s) that
+// are identical except for the specific lengths/offsets in each exclusion,
+// their CBOR-serialized sizes after applying this padding will be the same.
+void PadDataHashAssertion(DataHashAssertion& data_hash);
+
+// Set an empty `pad` field for compatibility with other third-party validators.
+void PadBoxesHashAssertion(BoxesHashAssertion& boxes_hash);
+
+// Adds padding to MultiAssetHashAssertion to account for the variable encoding
+// sizes of integers when serializing to CBOR. Given any two
+// MultiAssetHashAssertion(s) that are identical except for the specific
+// lengths/offsets in each part location, their CBOR-serialized sizes after
+// applying this padding will be the same.
+void PadMultiAssetHashAssertion(MultiAssetHashAssertion& multi_asset_hash);
+
+// Adds padding to CoseSign1TaggedStructure to account for the variable
+// encoding sizes of signature and timestamp bstr. It ensures that the
+// encoded size is a function only of `max_signature_size`,
+// `max_timestamp_size`, and the contents of `cose_sign1` other than the
+// signature, timestamp, and padding.
+//
+// Returns an error if there is more than one timestamp or the signature or
+// timestamp is too big.
+absl::Status PadCoseSign1TaggedStructure(CoseSign1TaggedStructure& cose_sign1,
+                                         int64_t max_signature_size,
+                                         int64_t max_timestamp_size);
+
+// Pads the structure to an overall target size by adjusting the pad and pad2
+// fields. This handles cases where pad crosses the length prefix threshold
+// (e.g. 23->24, 255->256, 65535->65536, etc.)
+//
+// Returns an error if the target size is smaller than the current size of the
+// encoded structure.
+absl::Status PadCoseSign1TaggedStructure(CoseSign1TaggedStructure& cose_sign1,
+                                         size_t target_size);
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_UTILS_CBOR_PADDING_H_
diff --git a/utils/cbor_padding_test.cc b/utils/cbor_padding_test.cc
new file mode 100644
index 0000000..0411c88
--- /dev/null
+++ b/utils/cbor_padding_test.cc
@@ -0,0 +1,485 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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/cbor_padding.h"
+
+#include <cstddef>
+#include <cstdint>
+#include <string>
+
+#include "absl/log/absl_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 "cose/sig_structure.h"
+#include "crypto/algorithms.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "proto/boxes_hash_assertion.pb.h"
+#include "proto/data_hash_assertion.cbor.h"
+#include "proto/data_hash_assertion.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::HasSubstr;
+using ::testing::Le;
+using ::testing::SizeIs;
+using ::testing::TestWithParam;
+using ::testing::Values;
+using ::testing::ValuesIn;
+
+constexpr size_t kTstContainerHeaderByteSize = 26;
+
+struct Range {
+  int64_t start;
+  int64_t length;
+};
+
+size_t EncodeByteSize(const DataHashAssertion& data_hash) {
+  auto res = cbor::FromProto(data_hash, {});
+  ABSL_CHECK_OK(res);
+  return (*res)->toString().size();
+}
+
+TEST(CborPaddingTest, PadDataHashAssertion) {
+  DataHashAssertion data_hash;
+  data_hash.set_alg("sha256");
+  data_hash.set_hash(std::string(HashSizeBytes(HashAlgorithm::kSha256), '\0'));
+  auto exclusion = data_hash.add_exclusions();
+  exclusion->set_start(0);
+  exclusion->set_length(0);
+  data_hash.set_pad("to be replaced");
+  data_hash.set_pad2("to be cleared");
+  PadDataHashAssertion(data_hash);
+  DataHashAssertion expected = ParseTextProtoOrDie<DataHashAssertion>(R"pb(
+    exclusions { start: 0 length: 0 }
+    alg: "sha256"
+    hash: "\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000"
+    pad: "\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000"
+  )pb");
+  EXPECT_THAT(data_hash, EqualsProto(expected));
+}
+
+TEST(CborPaddingTest, PadDataHashAssertionEmptyPadding) {
+  DataHashAssertion data_hash;
+  data_hash.set_alg("sha256");
+  data_hash.set_hash(std::string(HashSizeBytes(HashAlgorithm::kSha256), '\0'));
+  data_hash.set_pad("to be cleared");
+  data_hash.set_pad2("to be cleared");
+  PadDataHashAssertion(data_hash);
+  DataHashAssertion expected = ParseTextProtoOrDie<DataHashAssertion>(R"pb(
+    alg: "sha256"
+    hash: "\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000"
+    pad: ""
+  )pb");
+  EXPECT_THAT(data_hash, EqualsProto(expected));
+}
+
+TEST(CborPaddingTest, PadDataHashAssertionWithPad2) {
+  DataHashAssertion data_hash;
+  data_hash.set_alg("sha256");
+  data_hash.set_hash(std::string(HashSizeBytes(HashAlgorithm::kSha256), '\0'));
+  for (int i = 0; i < 2; i++) {
+    auto exclusion = data_hash.add_exclusions();
+    exclusion->set_start(0);
+    exclusion->set_length(0);
+  }
+  PadDataHashAssertion(data_hash);
+  DataHashAssertion expected = ParseTextProtoOrDie<DataHashAssertion>(R"pb(
+    exclusions { start: 0 length: 0 }
+    exclusions { start: 0 length: 0 }
+    alg: "sha256"
+    hash: "\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000"
+    pad: "\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000"
+    pad2: ""
+  )pb");
+  EXPECT_THAT(data_hash, EqualsProto(expected));
+}
+
+TEST(CborPaddingTest, PadDataHashAssertionWithPad2Filled) {
+  DataHashAssertion data_hash;
+  data_hash.set_alg("sha256");
+  data_hash.set_hash(std::string(HashSizeBytes(HashAlgorithm::kSha256), '\0'));
+  for (int i = 0; i < 2; i++) {
+    auto exclusion = data_hash.add_exclusions();
+    exclusion->set_start(0);
+    exclusion->set_length(4294967296);
+  }
+  PadDataHashAssertion(data_hash);
+  DataHashAssertion expected = ParseTextProtoOrDie<DataHashAssertion>(R"pb(
+    exclusions { start: 0 length: 4294967296 }
+    exclusions { start: 0 length: 4294967296 }
+    alg: "sha256"
+    hash: "\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000"
+    pad: "\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000"
+    pad2: "\000"
+  )pb");
+  EXPECT_THAT(data_hash, EqualsProto(expected));
+}
+
+class PadDataHashTest : public TestWithParam<Range> {};
+
+TEST_P(PadDataHashTest, PadDataHashAssertionTestWithParam) {
+  credentio::DataHashAssertion data_hash;
+  data_hash.set_alg("sha256");
+  data_hash.set_hash(std::string(HashSizeBytes(HashAlgorithm::kSha256), '\0'));
+  auto exclusion = data_hash.add_exclusions();
+  exclusion->set_start(GetParam().start);
+  exclusion->set_length(GetParam().length);
+  PadDataHashAssertion(data_hash);
+  auto size_1 = EncodeByteSize(data_hash);
+  EXPECT_THAT(data_hash.pad().size() + data_hash.pad2().size(), Le(16));
+
+  data_hash.mutable_exclusions(0)->set_start(0);
+  data_hash.mutable_exclusions(0)->set_length(0);
+  PadDataHashAssertion(data_hash);
+  auto size_2 = EncodeByteSize(data_hash);
+
+  EXPECT_EQ(size_1, size_2);
+}
+
+constexpr Range kRanges[] = {
+    {.start = 0, .length = 0},
+    {.start = 0, .length = 23},
+    {.start = 0, .length = 24},
+    {.start = 23, .length = 24},
+    {.start = 24, .length = 255},
+    {.start = 255, .length = 255},
+    {.start = 255, .length = 256},
+    {.start = 256, .length = 256},
+    {.start = 256, .length = 65535},
+    {.start = 256, .length = 65536},
+    {.start = 65535, .length = 65536},
+    {.start = 65536, .length = 65536},
+    {.start = 65536, .length = 4294967295},
+    {.start = 65536, .length = 4294967296},
+    {.start = 4294967295, .length = 4294967296},
+    {.start = 4294967296, .length = 4294967296},
+    {.start = 9223372036854775807, .length = 9223372036854775807},
+};
+
+INSTANTIATE_TEST_SUITE_P(PadDataHashAssertionTestWithParam, PadDataHashTest,
+                         ValuesIn(kRanges));
+
+TEST(CborPaddingTest, PadBoxesHashAssertion) {
+  BoxesHashAssertion boxes_hash = ParseTextProtoOrDie<BoxesHashAssertion>(R"pb(
+    boxes { names: "name1" alg: "sha256" hash: "\000" }
+    boxes { names: "name2" alg: "sha256" hash: "\000" }
+    alg: "sha256"
+  )pb");
+  PadBoxesHashAssertion(boxes_hash);
+  BoxesHashAssertion expected = ParseTextProtoOrDie<BoxesHashAssertion>(R"pb(
+    boxes { names: "name1" alg: "sha256" hash: "\000" pad: "" pad2: "" }
+    boxes { names: "name2" alg: "sha256" hash: "\000" pad: "" pad2: "" }
+    alg: "sha256"
+  )pb");
+  EXPECT_THAT(boxes_hash, EqualsProto(expected));
+}
+
+TEST(CborPaddingTest, PadCoseSign1TaggedStructureNoTimestamp) {
+  int64_t max_signature_size = 10;
+  int64_t max_timestamp_size = 10;
+  CoseSign1TaggedStructure cose_sign1;
+  cose_sign1.signature = std::string(max_signature_size, '\0');
+
+  CoseSign1TaggedStructure expected = cose_sign1;
+  expected.unprotected_header.pad =
+      std::string(max_timestamp_size + kTstContainerHeaderByteSize, '\0');
+  expected.unprotected_header.pad2 = "";
+
+  ABSL_ASSERT_OK(PadCoseSign1TaggedStructure(cose_sign1, max_signature_size,
+                                             max_timestamp_size));
+  EXPECT_EQ(cose_sign1, expected);
+}
+
+TEST(CborPaddingTest, PadCoseSign1TaggedStructureEmptySignature) {
+  int64_t max_signature_size = 10;
+  int64_t max_timestamp_size = 10;
+  CoseSign1TaggedStructure cose_sign1;
+  cose_sign1.signature = "";
+  cose_sign1.unprotected_header.sig_tst2 = TstContainer{
+      .tst_tokens = {{.val = std::string(max_timestamp_size, '\0')}}};
+
+  CoseSign1TaggedStructure expected = cose_sign1;
+  expected.unprotected_header.pad = std::string(max_signature_size, '\0');
+  expected.unprotected_header.pad2 = std::string(1, '\0');
+
+  ABSL_ASSERT_OK(PadCoseSign1TaggedStructure(cose_sign1, max_signature_size,
+                                             max_timestamp_size));
+  EXPECT_EQ(cose_sign1, expected);
+}
+
+TEST(CborPaddingTest, PadCoseSign1TaggedStructureEmptyTimestamp) {
+  int64_t max_signature_size = 10;
+  int64_t max_timestamp_size = 10;
+  CoseSign1TaggedStructure cose_sign1;
+  cose_sign1.signature = std::string(max_signature_size, '\0');
+  cose_sign1.unprotected_header.sig_tst2 =
+      TstContainer{.tst_tokens = {{.val = ""}}};
+
+  CoseSign1TaggedStructure expected = cose_sign1;
+  expected.unprotected_header.pad = std::string(max_timestamp_size, '\0');
+  expected.unprotected_header.pad2 = std::string(1, '\0');
+
+  ABSL_ASSERT_OK(PadCoseSign1TaggedStructure(cose_sign1, max_signature_size,
+                                             max_timestamp_size));
+  EXPECT_EQ(cose_sign1, expected);
+}
+
+TEST(CborPaddingTest, PadCoseSign1TaggedStructureEmptySignatureAndTimestamp) {
+  int64_t max_signature_size = 10;
+  int64_t max_timestamp_size = 10;
+  CoseSign1TaggedStructure cose_sign1;
+  cose_sign1.signature = "";
+  cose_sign1.unprotected_header.sig_tst2 =
+      TstContainer{.tst_tokens = {{.val = ""}}};
+
+  CoseSign1TaggedStructure expected = cose_sign1;
+  expected.unprotected_header.pad =
+      std::string(max_signature_size + max_timestamp_size, '\0');
+  expected.unprotected_header.pad2 = std::string(1, '\0');
+
+  ABSL_ASSERT_OK(PadCoseSign1TaggedStructure(cose_sign1, max_signature_size,
+                                             max_timestamp_size));
+  EXPECT_EQ(cose_sign1, expected);
+}
+
+TEST(CborPaddingTest, PadCoseSign1TaggedStructureFullSignatureAndTimestamp) {
+  int64_t max_signature_size = 10;
+  int64_t max_timestamp_size = 10;
+  CoseSign1TaggedStructure cose_sign1;
+  cose_sign1.signature = std::string(max_signature_size, '\0');
+  cose_sign1.unprotected_header.sig_tst2 = TstContainer{
+      .tst_tokens = {{.val = std::string(max_timestamp_size, '\0')}}};
+
+  CoseSign1TaggedStructure expected = cose_sign1;
+  expected.unprotected_header.pad = "";
+  expected.unprotected_header.pad2 = std::string(1, '\0');
+
+  ABSL_ASSERT_OK(PadCoseSign1TaggedStructure(cose_sign1, max_signature_size,
+                                             max_timestamp_size));
+  EXPECT_EQ(cose_sign1, expected);
+}
+
+TEST(CborPaddingTest,
+     PadCoseSign1TaggedStructureWithSignatureAndTimestampDeltas) {
+  int64_t max_signature_size = 20;
+  int64_t max_timestamp_size = 200;
+
+  CoseSign1TaggedStructure cose_sign1;
+  cose_sign1.signature = std::string(10, '\0');
+  cose_sign1.unprotected_header.sig_tst2 =
+      TstContainer{.tst_tokens = {{.val = std::string(10, '\0')}}};
+
+  CoseSign1TaggedStructure expected = cose_sign1;
+  expected.unprotected_header.pad =
+      std::string(max_signature_size - 10 + max_timestamp_size - 10, '\0');
+  expected.unprotected_header.pad2 = std::string(1, '\0');
+
+  ABSL_ASSERT_OK(PadCoseSign1TaggedStructure(cose_sign1, max_signature_size,
+                                             max_timestamp_size));
+  EXPECT_EQ(cose_sign1, expected);
+}
+
+TEST(CborPaddingTest, PadCoseSign1TaggedStructureSignatureTooBig) {
+  int64_t max_signature_size = 10;
+  int64_t max_timestamp_size = 10;
+  CoseSign1TaggedStructure cose_sign1;
+  cose_sign1.signature = std::string(max_signature_size + 1, '\0');
+  cose_sign1.unprotected_header.sig_tst2 = TstContainer{
+      .tst_tokens = {{.val = std::string(max_timestamp_size, '\0')}}};
+  EXPECT_THAT(PadCoseSign1TaggedStructure(cose_sign1, max_signature_size,
+                                          max_timestamp_size),
+              StatusIs(absl::StatusCode::kInternal, HasSubstr("signature")));
+}
+
+TEST(CborPaddingTest, PadCoseSign1TaggedStructureTimestampTooBig) {
+  int64_t max_signature_size = 10;
+  int64_t max_timestamp_size = 10;
+  CoseSign1TaggedStructure cose_sign1;
+  cose_sign1.signature = std::string(max_signature_size, '\0');
+  cose_sign1.unprotected_header.sig_tst2 = TstContainer{
+      .tst_tokens = {{.val = std::string(max_timestamp_size + 1, '\0')}}};
+  EXPECT_THAT(
+      PadCoseSign1TaggedStructure(cose_sign1, max_signature_size,
+                                  max_timestamp_size),
+      StatusIs(absl::StatusCode::kInternal, HasSubstr("timestamp size")));
+}
+
+TEST(CborPaddingTest, PadCoseSign1TaggedStructureMultipleTimestampTokens) {
+  int64_t max_signature_size = 10;
+  int64_t max_timestamp_size = 10;
+  CoseSign1TaggedStructure cose_sign1;
+  cose_sign1.signature = std::string(max_signature_size, '\0');
+  cose_sign1.unprotected_header.sig_tst2 = TstContainer{
+      .tst_tokens = {{.val = std::string(max_timestamp_size, '\0')},
+                     {.val = std::string(max_timestamp_size, '\0')}}};
+  EXPECT_THAT(
+      PadCoseSign1TaggedStructure(cose_sign1, max_signature_size,
+                                  max_timestamp_size),
+      StatusIs(absl::StatusCode::kInternal, HasSubstr("multiple timestamp")));
+}
+
+struct CoseSign1TaggedStructureInput {
+  int64_t signature_size;
+  int64_t timestamp_size;
+  int64_t signature_max_size;
+  int64_t timestamp_max_size;
+};
+
+class PadCoseSign1TaggedStructureTest
+    : public TestWithParam<CoseSign1TaggedStructureInput> {};
+
+TEST_P(PadCoseSign1TaggedStructureTest, PadCoseSign1TaggedStructureWithParams) {
+  CoseSign1TaggedStructure cose_sign1;
+  cose_sign1.signature = std::string(GetParam().signature_size, '\0');
+  if (GetParam().timestamp_size > 0) {
+    cose_sign1.unprotected_header.sig_tst2 = TstContainer{
+        .tst_tokens = {{.val = std::string(GetParam().timestamp_size, '\0')}}};
+  }
+  int64_t max_signature_size = GetParam().signature_max_size;
+  int64_t max_timestamp_size = GetParam().timestamp_max_size;
+  ABSL_ASSERT_OK(PadCoseSign1TaggedStructure(cose_sign1, max_signature_size,
+                                             max_timestamp_size));
+
+  CoseSign1TaggedStructure cose_sign2;
+  cose_sign2.signature = std::string(max_signature_size, '\0');
+  ABSL_ASSERT_OK(PadCoseSign1TaggedStructure(cose_sign2, max_signature_size,
+                                             max_timestamp_size));
+
+  EXPECT_EQ(EncodeCoseSign1TaggedStructure(cose_sign1).size(),
+            EncodeCoseSign1TaggedStructure(cose_sign2).size());
+}
+constexpr CoseSign1TaggedStructureInput kCoseSign1TaggedInputs[] = {
+    {
+        .signature_size = 10,
+        .timestamp_size = 0,
+        .signature_max_size = 10,
+        .timestamp_max_size = 10,
+    },
+    {
+        .signature_size = 10,
+        .timestamp_size = 0,
+        .signature_max_size = 100,
+        .timestamp_max_size = 100,
+    },
+    {
+        .signature_size = 10,
+        .timestamp_size = 0,
+        .signature_max_size = 1000,
+        .timestamp_max_size = 1000,
+    },
+    {
+        .signature_size = 10,
+        .timestamp_size = 10,
+        .signature_max_size = 10,
+        .timestamp_max_size = 10,
+    },
+    {
+        .signature_size = 10,
+        .timestamp_size = 10,
+        .signature_max_size = 100,
+        .timestamp_max_size = 100,
+    },
+    {
+        .signature_size = 10,
+        .timestamp_size = 10,
+        .signature_max_size = 1000,
+        .timestamp_max_size = 1000,
+    },
+    {
+        .signature_size = 10,
+        .timestamp_size = 10,
+        .signature_max_size = 10000,
+        .timestamp_max_size = 10000,
+    },
+    {
+        .signature_size = 100,
+        .timestamp_size = 100,
+        .signature_max_size = 100,
+        .timestamp_max_size = 100,
+    },
+    {
+        .signature_size = 1000,
+        .timestamp_size = 1000,
+        .signature_max_size = 1000,
+        .timestamp_max_size = 1000,
+    },
+    {
+        .signature_size = 10000,
+        .timestamp_size = 10000,
+        .signature_max_size = 10000,
+        .timestamp_max_size = 10000,
+    },
+};
+INSTANTIATE_TEST_SUITE_P(
+    PadCoseSign1TaggedStructureWithParams, PadCoseSign1TaggedStructureTest,
+    ValuesIn(kCoseSign1TaggedInputs),
+    [](const testing::TestParamInfo<CoseSign1TaggedStructureInput>& info) {
+      return absl::StrCat("signature_", info.param.signature_size,
+                          "_timestamp_", info.param.timestamp_size,
+                          "_signature_max_", info.param.signature_max_size,
+                          "_timestamp_max_", info.param.timestamp_max_size);
+    });
+
+TEST(CborPaddingTest,
+     PadCoseSign1TaggedStructureSmallExpectedSizeReturnsError) {
+  CoseSign1TaggedStructure cose_sign1;
+  cose_sign1.unprotected_header.pad = "";
+  cose_sign1.unprotected_header.pad2 = std::string(8, '\0');
+  int initial_size = EncodeCoseSign1TaggedStructure(cose_sign1).size();
+  EXPECT_THAT(PadCoseSign1TaggedStructure(cose_sign1, initial_size - 1),
+              StatusIs(absl::StatusCode::kInternal,
+                       HasSubstr(absl::StrCat(
+                           "Expected size ", initial_size - 1,
+                           " is smaller than base size ", initial_size))));
+}
+
+// Returns the size of structure that PadCoseSign1TaggedStructure expects as
+// initial size to pad from.
+int GetInitialSizeForPadCoseSign1TaggedStructure() {
+  CoseSign1TaggedStructure cose_sign1;
+  cose_sign1.unprotected_header.pad = "";
+  cose_sign1.unprotected_header.pad2 = std::string(8, '\0');
+  return EncodeCoseSign1TaggedStructure(cose_sign1).size();
+}
+
+class PadOverallCoseSign1TaggedStructureTest : public TestWithParam<size_t> {};
+
+TEST_P(PadOverallCoseSign1TaggedStructureTest, PadCoseSign1TaggedStructure) {
+  CoseSign1TaggedStructure cose_sign1;
+  size_t target_size = GetParam();
+
+  ABSL_ASSERT_OK(PadCoseSign1TaggedStructure(cose_sign1, target_size));
+
+  EXPECT_THAT(EncodeCoseSign1TaggedStructure(cose_sign1), SizeIs(target_size));
+}
+
+INSTANTIATE_TEST_SUITE_P(
+    PadOverallCoseSign1TaggedStructureTest,
+    PadOverallCoseSign1TaggedStructureTest,
+    Values(GetInitialSizeForPadCoseSign1TaggedStructure(),
+           GetInitialSizeForPadCoseSign1TaggedStructure() + 24,
+           GetInitialSizeForPadCoseSign1TaggedStructure() + 256,
+           GetInitialSizeForPadCoseSign1TaggedStructure() + 65536,
+           GetInitialSizeForPadCoseSign1TaggedStructure() + 1000000));
+}  // namespace
+}  // namespace credentio
diff --git a/utils/crjson.cc b/utils/crjson.cc
index 1b2c24c..22d87fa 100644
--- a/utils/crjson.cc
+++ b/utils/crjson.cc
@@ -26,6 +26,7 @@
 #include "absl/base/nullability.h"
 #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/numbers.h"
@@ -57,6 +58,7 @@
 #include "proto/validation_status.pb.h"
 #include "tsp/timestamp_verifier.h"
 #include "utils/crjson_utils.h"
+#include "validator/result.h"
 
 namespace credentio {
 
@@ -90,24 +92,17 @@
 };
 
 absl::StatusOr<ParsedCbor<cbor::MapView>> ParseCborMap(absl::string_view cbor) {
-  auto parse_result = cbor::Parse(cbor);
-  if (!parse_result.ok()) {
-    return parse_result.status();
-  }
-  auto cbor_map = (*parse_result)->AsMap();
-  if (!cbor_map.ok()) {
-    return cbor_map.status();
-  }
-  return ParsedCbor<cbor::MapView>{std::move(*parse_result), *cbor_map};
+  ABSL_ASSIGN_OR_RETURN(std::unique_ptr<cbor::ParseResult> parse_result,
+                        cbor::Parse(cbor));
+  ABSL_ASSIGN_OR_RETURN(cbor::MapView cbor_map, parse_result->AsMap());
+  return ParsedCbor<cbor::MapView>{std::move(parse_result), cbor_map};
 }
 
 absl::StatusOr<ParsedCbor<cbor::MapView>> GetCborMap(
     const jumbf::SuperBox& box) {
-  auto cbor_box = EnsureAndGetSingleBox<jumbf::CborBox>(box);
-  if (!cbor_box.ok()) {
-    return cbor_box.status();
-  }
-  return ParseCborMap(cbor_box->payload);
+  ABSL_ASSIGN_OR_RETURN(const jumbf::CborBox& cbor_box,
+                        EnsureAndGetSingleBox<jumbf::CborBox>(box));
+  return ParseCborMap(cbor_box.payload);
 }
 
 enum class ManifestType {
@@ -735,11 +730,9 @@
     }
   }
 
-  auto manifest_store_box =
-      jumbf::ConsumeSuperBox(&raw_manifest_store, /*recursion_limit=*/-1);
-  if (!manifest_store_box.ok()) {
-    return manifest_store_box.status();
-  }
+  ABSL_ASSIGN_OR_RETURN(
+      jumbf::SuperBox manifest_store_box,
+      jumbf::ConsumeSuperBox(&raw_manifest_store, /*recursion_limit=*/-1));
 
   // Build the crJSON.
   Json crjson = Json::object();
@@ -751,8 +744,8 @@
   // Add the manifests.
   crjson["manifests"] = Json::array();
   bool is_active_manifest = true;
-  for (auto it = manifest_store_box->contents.rbegin();
-       it != manifest_store_box->contents.rend(); ++it) {
+  for (auto it = manifest_store_box.contents.rbegin();
+       it != manifest_store_box.contents.rend(); ++it) {
     const jumbf::ContentBox& content_box = *it;
     if (!content_box.Holds<jumbf::SuperBox>()) {
       crjson["_error"] = "Manifest store contains non-SuperBox content";
@@ -797,17 +790,17 @@
 }
 
 absl::StatusOr<Json> ConvertToCrJson(
-    absl::string_view raw_manifest_store,
-    const ValidationResultProto& validation_result) {
-  return ConvertManifestStoreToCrJsonInternal(raw_manifest_store,
-                                              &validation_result, nullptr);
+    const ValidationResult& validation_result) {
+  return ConvertManifestStoreToCrJsonInternal(
+      validation_result.manifest_store_bytes(), &validation_result.proto(),
+      nullptr);
 }
 
 absl::StatusOr<Json> ConvertToCrJson(
-    absl::string_view raw_manifest_store,
     const IngredientValidationResultProto& ingredient_validation_result) {
-  return ConvertManifestStoreToCrJsonInternal(raw_manifest_store, nullptr,
-                                              &ingredient_validation_result);
+  return ConvertManifestStoreToCrJsonInternal(
+      ingredient_validation_result.manifest_store_bytes(), nullptr,
+      &ingredient_validation_result);
 }
 
 }  // namespace credentio
diff --git a/utils/crjson.h b/utils/crjson.h
index 7665cd0..31d8a78 100644
--- a/utils/crjson.h
+++ b/utils/crjson.h
@@ -20,7 +20,7 @@
 #include "absl/strings/string_view.h"
 #include "nlohmann/json.hpp"
 #include "proto/ingredient_validation_result.pb.h"
-#include "proto/validation_result.pb.h"
+#include "validator/result.h"
 
 namespace credentio {
 
@@ -29,14 +29,13 @@
     absl::string_view raw_manifest_store);
 
 // Convert a raw manifest store to crJSON and merge validation results.
+// Requires that `validation_result` includes the manifest store bytes.
 absl::StatusOr<::nlohmann::json> ConvertToCrJson(
-    absl::string_view raw_manifest_store,
-    const ValidationResultProto& validation_result);
+    const ValidationResult& validation_result);
 
 // Convert a raw manifest store to crJSON and merge ingredient validation
 // results.
 absl::StatusOr<::nlohmann::json> ConvertToCrJson(
-    absl::string_view raw_manifest_store,
     const IngredientValidationResultProto& ingredient_validation_result);
 
 }  // namespace credentio
diff --git a/utils/crjson_test.cc b/utils/crjson_test.cc
index f21b17c..8265058 100644
--- a/utils/crjson_test.cc
+++ b/utils/crjson_test.cc
@@ -41,8 +41,6 @@
 namespace credentio {
 namespace {
 
-using ::absl_testing::IsOk;
-
 TEST(ConvertManifestStoreToCrJsonTest, ByteStringsAreBase64Encoded) {
   // 1. Construct a CBOR map containing a byte string.
   cppbor::Map map;
@@ -75,7 +73,7 @@
   // 7. Call ConvertManifestStoreToCrJsonWithoutValidationResults.
   absl::StatusOr<nlohmann::json> j =
       ConvertToCrJsonWithoutValidationResults(manifest_store_box);
-  ASSERT_THAT(j, IsOk());
+  ABSL_ASSERT_OK(j);
 
   // 8. Verify that the output JSON contains the base64 encoded byte string.
   // The expected format is "b64'<base64_data>'".
@@ -123,7 +121,7 @@
   // 7. Call ConvertManifestStoreToCrJsonWithoutValidationResults.
   absl::StatusOr<nlohmann::json> j =
       ConvertToCrJsonWithoutValidationResults(manifest_store_box);
-  ASSERT_THAT(j, IsOk());
+  ABSL_ASSERT_OK(j);
 
   // 8. Verify that the output JSON contains the unrecognized assertion with
   // empty object.
@@ -156,7 +154,7 @@
   // 5. Call ConvertManifestStoreToCrJsonWithoutValidationResults.
   absl::StatusOr<nlohmann::json> j =
       ConvertToCrJsonWithoutValidationResults(manifest_store_box);
-  ASSERT_THAT(j, IsOk());
+  ABSL_ASSERT_OK(j);
 
   // 6. Verify that the output JSON contains the error message.
   ASSERT_TRUE(j->contains("manifests"));
@@ -214,7 +212,7 @@
   // 8. Call ConvertManifestStoreToCrJsonWithoutValidationResults.
   absl::StatusOr<nlohmann::json> j =
       ConvertToCrJsonWithoutValidationResults(manifest_store_box);
-  ASSERT_THAT(j, IsOk());
+  ABSL_ASSERT_OK(j);
 
   // 9. Verify claim.v2 fields in output JSON.
   ASSERT_TRUE(j->contains("manifests"));
@@ -317,7 +315,7 @@
   // 9. Call ConvertManifestStoreToCrJsonWithoutValidationResults.
   absl::StatusOr<nlohmann::json> j =
       ConvertToCrJsonWithoutValidationResults(manifest_store_box);
-  ASSERT_THAT(j, IsOk());
+  ABSL_ASSERT_OK(j);
 
   // 10. Verify metadata fields in output JSON.
   ASSERT_TRUE(j->contains("manifests"));
diff --git a/validator/BUILD b/validator/BUILD
index fcdc5cc..59fcb77 100644
--- a/validator/BUILD
+++ b/validator/BUILD
@@ -129,6 +129,16 @@
 )
 
 cc_library(
+    name = "manifest_consumer",
+    hdrs = ["manifest_consumer.h"],
+    deps = [
+        "//proto:ingredient_validation_result_cc_proto",
+        "//proto:manifest_cc_proto",
+        "@abseil-cpp//absl/status",
+    ],
+)
+
+cc_library(
     name = "validator_options",
     hdrs = ["validator_options.h"],
     deps = [
@@ -156,6 +166,59 @@
 )
 
 cc_library(
+    name = "ingredient_result_converter",
+    srcs = ["ingredient_result_converter.cc"],
+    hdrs = ["ingredient_result_converter.h"],
+    deps = [
+        ":ingredient_result",
+        ":manifest_consumer",
+        ":result",
+        "//proto:assertion_cc_proto",
+        "//proto:generator_info_cc_proto",
+        "//proto:hashed_uri_cc_proto",
+        "//proto:ingredient_assertion_cc_proto",
+        "//proto:ingredient_validation_result_cc_proto",
+        "//proto:manifest_cc_proto",
+        "//proto:signature_info_cc_proto",
+        "//proto:validation_result_cc_proto",
+        "//proto:validation_status_cc_proto",
+        "@abseil-cpp//absl/container:flat_hash_map",
+        "@abseil-cpp//absl/container:flat_hash_set",
+        "@abseil-cpp//absl/hash",
+        "@abseil-cpp//absl/status:status_macros",
+        "@abseil-cpp//absl/status:statusor",
+        "@abseil-cpp//absl/strings",
+        "@abseil-cpp//absl/strings:string_view",
+        "@protobuf//:protobuf_lite",
+    ],
+)
+
+cc_test(
+    name = "ingredient_result_converter_test",
+    srcs = ["ingredient_result_converter_test.cc"],
+    deps = [
+        ":ingredient_result",
+        ":ingredient_result_converter",
+        ":manifest_consumer",
+        ":result",
+        "//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:ingredient_validation_result_cc_proto",
+        "//proto:manifest_cc_proto",
+        "//proto:signature_info_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"],
@@ -315,3 +378,9 @@
         "@riegeli//riegeli/bytes:reader",
     ],
 )
+
+cc_library(
+    name = "ingredient_result",
+    hdrs = ["ingredient_result.h"],
+    deps = ["//proto:ingredient_validation_result_cc_proto"],
+)
diff --git a/validator/ingredient_result.h b/validator/ingredient_result.h
new file mode 100644
index 0000000..835f9b1
--- /dev/null
+++ b/validator/ingredient_result.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_INGREDIENT_RESULT_H_
+#define THIRD_PARTY_CREDENTIO_VALIDATOR_INGREDIENT_RESULT_H_
+
+#include <memory>
+#include <utility>
+
+#include "proto/ingredient_validation_result.pb.h"
+
+namespace credentio {
+
+// Result of validating a C2PA ingredient.
+//
+// For now, this is just a wrapper for `IngredientValidationResultProto`.
+class IngredientResult {
+ public:
+  explicit IngredientResult(
+      std::unique_ptr<IngredientValidationResultProto> proto)
+      : proto_(std::move(proto)) {}
+
+  explicit IngredientResult(IngredientValidationResultProto&& proto)
+      : proto_(std::make_unique<IngredientValidationResultProto>(
+            std::move(proto))) {}
+
+  const IngredientValidationResultProto& proto() const { return *proto_; }
+
+  std::unique_ptr<IngredientValidationResultProto> release_proto() && {
+    return std::move(proto_);
+  }
+
+ private:
+  std::unique_ptr<IngredientValidationResultProto> proto_;
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_VALIDATOR_INGREDIENT_RESULT_H_
diff --git a/validator/ingredient_result_converter.cc b/validator/ingredient_result_converter.cc
new file mode 100644
index 0000000..83d042c
--- /dev/null
+++ b/validator/ingredient_result_converter.cc
@@ -0,0 +1,268 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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/ingredient_result_converter.h"
+
+#include <cstddef>
+#include <memory>
+#include <optional>
+#include <string>
+#include <utility>
+#include <vector>
+
+#include "absl/container/flat_hash_map.h"
+#include "absl/container/flat_hash_set.h"
+#include "absl/hash/hash.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 "absl/strings/strip.h"
+#include "google/protobuf/repeated_ptr_field.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/ingredient_validation_result.pb.h"
+#include "proto/manifest.pb.h"
+#include "proto/signature_info.pb.h"
+#include "proto/validation_result.pb.h"
+#include "proto/validation_status.pb.h"
+#include "validator/ingredient_result.h"
+#include "validator/manifest_consumer.h"
+#include "validator/result.h"
+
+namespace credentio {
+namespace {
+
+bool IsLegacyManifestLabel(absl::string_view manifest_label) {
+  return !absl::StartsWith(manifest_label, "urn:c2pa");
+}
+
+std::string GetAssertionUri(absl::string_view manifest_label,
+                            const Assertion& assertion) {
+  return absl::StrCat("self#jumbf=/c2pa/", manifest_label, "/c2pa.assertions/",
+                      assertion.label());
+}
+
+std::optional<std::string> ExtractTargetManifestLabel(
+    const IngredientAssertionV3& ingredient_v3) {
+  if (ingredient_v3.has_active_manifest()) {
+    absl::string_view url = ingredient_v3.active_manifest().url();
+    if (absl::ConsumePrefix(&url, "self#jumbf=/c2pa/")) {
+      return std::string(url);
+    }
+  }
+  return std::nullopt;
+}
+
+struct ValidationStatusHash {
+  size_t operator()(const ValidationStatus& status) const {
+    return absl::HashOf(status.code(), status.url());
+  }
+};
+struct ValidationStatusEqual {
+  bool operator()(const ValidationStatus& lhs,
+                  const ValidationStatus& rhs) const {
+    return lhs.code() == rhs.code() && lhs.url() == rhs.url();
+  }
+};
+
+using ValidationStatusHashSet =
+    absl::flat_hash_set<ValidationStatus, ValidationStatusHash,
+                        ValidationStatusEqual>;
+
+ValidationStatusHashSet GetIngredientAssertionValidationStatus(
+    const Assertion& assertion) {
+  ValidationStatusHashSet statuses;
+  if (assertion.has_ingredient_v3()) {
+    const ValidationStatusSet& assertion_results =
+        assertion.ingredient_v3().validation_results().active_manifest();
+    statuses.insert(assertion_results.successes().begin(),
+                    assertion_results.successes().end());
+    statuses.insert(assertion_results.failures().begin(),
+                    assertion_results.failures().end());
+    statuses.insert(assertion_results.informationals().begin(),
+                    assertion_results.informationals().end());
+  }
+  return statuses;
+}
+
+bool IsHardBindingSuccessCode(absl::string_view code) {
+  return code == "com.google.assertion.dataHash.match" ||
+         code == "com.google.assertion.bmffHash.match" ||
+         code == "com.google.assertion.boxesHash.match" ||
+         code == "com.google.assertion.collectionHash.match" ||
+         code == "assertion.dataHash.match" ||
+         code == "assertion.bmffHash.match" ||
+         code == "assertion.boxesHash.match" ||
+         code == "assertion.collectionHash.match";
+}
+
+google::protobuf::RepeatedPtrField<ValidationStatus> ComputeStatusDeltas(
+    const google::protobuf::RepeatedPtrField<ValidationStatus>& current,
+    const ValidationStatusHashSet& assertion) {
+  google::protobuf::RepeatedPtrField<ValidationStatus> deltas;
+  for (const auto& status : current) {
+    if (!assertion.contains(status) ||
+        IsHardBindingSuccessCode(status.code())) {
+      ValidationStatus* delta = deltas.Add();
+      delta->set_code(status.code());
+      delta->set_url(status.url());
+    }
+  }
+  return deltas;
+}
+
+ValidationStatusSet ComputeValidationDeltas(
+    const ValidationStatusSet& current_results,
+    const ValidationStatusHashSet& assertion_results) {
+  ValidationStatusSet status_set_deltas;
+  *status_set_deltas.mutable_successes() =
+      ComputeStatusDeltas(current_results.successes(), assertion_results);
+  *status_set_deltas.mutable_failures() =
+      ComputeStatusDeltas(current_results.failures(), assertion_results);
+  *status_set_deltas.mutable_informationals() =
+      ComputeStatusDeltas(current_results.informationals(), assertion_results);
+  return status_set_deltas;
+}
+
+struct ReferencingIngredientAssertion {
+  const Assertion* assertion;
+  std::string assertion_uri;
+};
+
+}  // namespace
+
+absl::StatusOr<std::unique_ptr<IngredientValidationResultProto>>
+IngredientResultConverter::ConvertProto(
+    const ValidationResultProto& validation_result) const {
+  auto result = std::make_unique<IngredientValidationResultProto>();
+
+  if (!validation_result.media_type().empty()) {
+    result->set_media_type(validation_result.media_type());
+  }
+
+  if (validation_result.active_manifest().has_validation()) {
+    *result->mutable_ingredient_validation_results()
+         ->mutable_active_manifest() =
+        validation_result.active_manifest().validation();
+  }
+
+  if (!validation_result.spec_version().empty()) {
+    result->mutable_ingredient_validation_results()->set_spec_version(
+        validation_result.spec_version());
+  }
+  if (!validation_result.trust_list_uri().empty()) {
+    result->mutable_ingredient_validation_results()->set_trust_list_uri(
+        validation_result.trust_list_uri());
+  }
+
+  // Populate logging_metadata.
+  auto* active_manifest_info =
+      result->mutable_logging_metadata()->mutable_active_manifest();
+  if (validation_result.active_manifest().claim().has_claim_generator_info()) {
+    *active_manifest_info->mutable_claim_generator() =
+        validation_result.active_manifest().claim().claim_generator_info();
+  }
+  if (validation_result.active_manifest().claim().has_signature_info()) {
+    *active_manifest_info->mutable_signature() =
+        validation_result.active_manifest().claim().signature_info();
+  }
+
+  // Populate legacy_ingredient_metadata.
+  if (IsLegacyManifestLabel(validation_result.active_manifest().label())) {
+    auto* legacy_metadata = result->mutable_legacy_ingredient_metadata();
+    legacy_metadata->set_is_legacy_ingredient(true);
+    legacy_metadata->add_unsupported_manifests(
+        validation_result.active_manifest().label());
+  }
+
+  // Populate ingredient_deltas.
+  absl::flat_hash_map<std::string, std::vector<ReferencingIngredientAssertion>>
+      referencing_assertions;
+
+  auto process_manifest = [&](const Manifest& m) {
+    for (const auto& a : m.assertions()) {
+      if (a.has_ingredient_v3()) {
+        auto target_label = ExtractTargetManifestLabel(a.ingredient_v3());
+        if (target_label.has_value()) {
+          referencing_assertions[*target_label].push_back(
+              {.assertion = &a,
+               .assertion_uri = GetAssertionUri(m.label(), a)});
+        }
+      }
+    }
+  };
+
+  process_manifest(validation_result.active_manifest());
+  for (const auto& m : validation_result.ingredient_manifests()) {
+    process_manifest(m);
+  }
+
+  for (const auto& m : validation_result.ingredient_manifests()) {
+    auto it = referencing_assertions.find(m.label());
+    if (it != referencing_assertions.end()) {
+      for (const auto& ref : it->second) {
+        auto* delta = result->mutable_ingredient_validation_results()
+                          ->add_ingredient_deltas();
+        delta->set_ingredient_assertion_uri(ref.assertion_uri);
+        *delta->mutable_validation_deltas() = ComputeValidationDeltas(
+            m.validation(),
+            GetIngredientAssertionValidationStatus(*ref.assertion));
+        if (IsLegacyManifestLabel(m.label())) {
+          auto* legacy_metadata = result->mutable_legacy_ingredient_metadata();
+          legacy_metadata->add_unsupported_manifests(m.label());
+          legacy_metadata->add_unsupported_ingredient_assertion_uris(
+              ref.assertion_uri);
+        }
+      }
+    } else {
+      if (IsLegacyManifestLabel(m.label())) {
+        auto* legacy_metadata = result->mutable_legacy_ingredient_metadata();
+        legacy_metadata->add_unsupported_manifests(m.label());
+      }
+    }
+  }
+
+  // Let the consumer populate the fields the converter does not derive itself,
+  // e.g. digital_source_type.
+  if (consumer_ != nullptr) {
+    ABSL_RETURN_IF_ERROR(consumer_->OnManifestValidated(
+        validation_result.active_manifest(), /*is_active_manifest=*/true));
+    for (const auto& m : validation_result.ingredient_manifests()) {
+      ABSL_RETURN_IF_ERROR(
+          consumer_->OnManifestValidated(m, /*is_active_manifest=*/false));
+    }
+    ABSL_RETURN_IF_ERROR(
+        consumer_->OnIngredientValidationResultComplete(result.get()));
+  }
+
+  return result;
+}
+
+absl::StatusOr<std::unique_ptr<IngredientResult>>
+IngredientResultConverter::Convert(ValidationResult validation_result) const {
+  ABSL_ASSIGN_OR_RETURN(std::unique_ptr<IngredientValidationResultProto> proto,
+                        ConvertProto(validation_result.proto()));
+  if (!validation_result.manifest_store_bytes().empty()) {
+    proto->set_manifest_store_bytes(
+        validation_result.release_manifest_store_bytes());
+  }
+  return std::make_unique<IngredientResult>(std::move(proto));
+}
+
+}  // namespace credentio
diff --git a/validator/ingredient_result_converter.h b/validator/ingredient_result_converter.h
new file mode 100644
index 0000000..2bcff8f
--- /dev/null
+++ b/validator/ingredient_result_converter.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_VALIDATOR_INGREDIENT_RESULT_CONVERTER_H_
+#define THIRD_PARTY_CREDENTIO_VALIDATOR_INGREDIENT_RESULT_CONVERTER_H_
+
+#include <memory>
+#include <utility>
+
+#include "absl/status/statusor.h"
+#include "proto/ingredient_validation_result.pb.h"
+#include "proto/validation_result.pb.h"
+#include "validator/ingredient_result.h"
+#include "validator/manifest_consumer.h"
+#include "validator/result.h"
+
+namespace credentio {
+
+// Converts a validation result into the ingredient validation result that
+// describes it.
+class IngredientResultConverter {
+ public:
+  IngredientResultConverter() = default;
+
+  // The consumer is notified of every validated manifest and then of the
+  // completed result, which is how callers add information the converter does
+  // not derive itself, such as the digital source type.
+  explicit IngredientResultConverter(std::unique_ptr<ManifestConsumer> consumer)
+      : consumer_(std::move(consumer)) {}
+
+  absl::StatusOr<std::unique_ptr<IngredientResult>> Convert(
+      ValidationResult validation_result) const;
+
+ private:
+  absl::StatusOr<std::unique_ptr<IngredientValidationResultProto>> ConvertProto(
+      const ValidationResultProto& validation_result) const;
+
+  std::unique_ptr<ManifestConsumer> consumer_;
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_VALIDATOR_INGREDIENT_RESULT_CONVERTER_H_
diff --git a/validator/ingredient_result_converter_test.cc b/validator/ingredient_result_converter_test.cc
new file mode 100644
index 0000000..e779f92
--- /dev/null
+++ b/validator/ingredient_result_converter_test.cc
@@ -0,0 +1,692 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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/ingredient_result_converter.h"
+
+#include <memory>
+#include <string>
+#include <utility>
+#include <vector>
+
+#include "absl/status/status.h"
+#include "absl/status/status_matchers.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.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/ingredient_validation_result.pb.h"
+#include "proto/manifest.pb.h"
+#include "proto/signature_info.pb.h"
+#include "proto/validation_result.pb.h"
+#include "proto/validation_status.pb.h"
+#include "testing/proto_test_utils.h"
+#include "validator/ingredient_result.h"
+#include "validator/manifest_consumer.h"
+#include "validator/result.h"
+
+namespace credentio {
+namespace {
+
+using ::absl_testing::StatusIs;
+using ::credentio_testing::EqualsProto;
+using ::credentio_testing::ParseTextProtoOrDie;
+using ::testing::ElementsAre;
+
+TEST(IngredientResultTest, Accessors) {
+  auto proto = std::make_unique<IngredientValidationResultProto>();
+  proto->set_media_type("image/png");
+  IngredientResult result(std::move(proto));
+  EXPECT_EQ(result.proto().media_type(), "image/png");
+
+  std::unique_ptr<IngredientValidationResultProto> released =
+      std::move(result).release_proto();
+  ASSERT_NE(released, nullptr);
+  EXPECT_EQ(released->media_type(), "image/png");
+}
+
+TEST(IngredientResultTest, FromRvalueProto) {
+  IngredientValidationResultProto proto;
+  proto.set_media_type("image/png");
+  IngredientResult result(std::move(proto));
+  EXPECT_EQ(result.proto().media_type(), "image/png");
+}
+
+TEST(IngredientResultConverterTest, ConvertBasic) {
+  ValidationResultProto input = ParseTextProtoOrDie(R"pb(
+    media_type: "image/jpeg"
+    active_manifest {
+      label: "urn:c2pa:active"
+      validation { successes { code: "success" } }
+    }
+  )pb");
+
+  auto result =
+      IngredientResultConverter().Convert(ValidationResult(std::move(input)));
+  ABSL_ASSERT_OK(result);
+
+  EXPECT_THAT((*result)->proto(), EqualsProto(R"pb(
+                media_type: "image/jpeg"
+                ingredient_validation_results {
+                  active_manifest { successes { code: "success" } }
+                }
+                logging_metadata { active_manifest {} }
+              )pb"));
+}
+
+TEST(IngredientResultConverterTest, ConvertLoggingMetadata) {
+  ValidationResultProto input = ParseTextProtoOrDie(R"pb(
+    active_manifest {
+      label: "urn:c2pa:test"
+      claim {
+        claim_generator_info { name: "test_generator" }
+        signature_info { issuer: "test_issuer" }
+      }
+    }
+  )pb");
+
+  auto result =
+      IngredientResultConverter().Convert(ValidationResult(std::move(input)));
+  ABSL_ASSERT_OK(result);
+
+  EXPECT_THAT((*result)->proto(), EqualsProto(R"pb(
+                logging_metadata {
+                  active_manifest {
+                    claim_generator { name: "test_generator" }
+                    signature { issuer: "test_issuer" }
+                  }
+                }
+              )pb"));
+}
+
+TEST(IngredientResultConverterTest, ConvertLegacyActiveManifest) {
+  ValidationResultProto input = ParseTextProtoOrDie(R"pb(
+    active_manifest { label: "legacy_label" }
+  )pb");
+
+  auto result =
+      IngredientResultConverter().Convert(ValidationResult(std::move(input)));
+  ABSL_ASSERT_OK(result);
+
+  EXPECT_THAT((*result)->proto(), EqualsProto(R"pb(
+                legacy_ingredient_metadata {
+                  is_legacy_ingredient: true
+                  unsupported_manifests: "legacy_label"
+                }
+                logging_metadata { active_manifest {} }
+              )pb"));
+}
+
+TEST(IngredientResultConverterTest, ConvertIngredientDeltas) {
+  ValidationResultProto input = ParseTextProtoOrDie(R"pb(
+    active_manifest {
+      label: "urn:c2pa:active"
+      assertions {
+        label: "ingredient_assertion"
+        ingredient_v3 {
+          active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:ingredient1" }
+          validation_results {
+            active_manifest { successes { code: "ingredient_success" } }
+          }
+        }
+      }
+    }
+    ingredient_manifests {
+      label: "urn:c2pa:ingredient1"
+      validation {
+        successes { code: "ingredient_success" }
+        failures { code: "ingredient_failure" url: "test://failure" }
+      }
+    }
+  )pb");
+
+  auto result =
+      IngredientResultConverter().Convert(ValidationResult(std::move(input)));
+  ABSL_ASSERT_OK(result);
+
+  EXPECT_THAT(
+      (*result)->proto(), EqualsProto(R"pb(
+        ingredient_validation_results {
+          ingredient_deltas {
+            ingredient_assertion_uri: "self#jumbf=/c2pa/urn:c2pa:active/c2pa.assertions/ingredient_assertion"
+            validation_deltas {
+              failures { code: "ingredient_failure" url: "test://failure" }
+            }
+          }
+        }
+        logging_metadata { active_manifest {} }
+      )pb"));
+}
+
+TEST(IngredientResultConverterTest, ConvertIngredientDeltasHardBinding) {
+  ValidationResultProto input = ParseTextProtoOrDie(R"pb(
+    active_manifest {
+      label: "urn:c2pa:active"
+      assertions {
+        label: "ingredient_assertion"
+        ingredient_v3 {
+          active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:ingredient1" }
+          validation_results {
+            active_manifest {
+              successes { code: "com.google.assertion.dataHash.match" }
+            }
+          }
+        }
+      }
+    }
+    ingredient_manifests {
+      label: "urn:c2pa:ingredient1"
+      validation { successes { code: "com.google.assertion.dataHash.match" } }
+    }
+  )pb");
+
+  auto result =
+      IngredientResultConverter().Convert(ValidationResult(std::move(input)));
+  ABSL_ASSERT_OK(result);
+
+  EXPECT_THAT(
+      (*result)->proto(), EqualsProto(R"pb(
+        ingredient_validation_results {
+          ingredient_deltas {
+            ingredient_assertion_uri: "self#jumbf=/c2pa/urn:c2pa:active/c2pa.assertions/ingredient_assertion"
+            validation_deltas {
+              successes { code: "com.google.assertion.dataHash.match" url: "" }
+            }
+          }
+        }
+        logging_metadata { active_manifest {} }
+      )pb"));
+}
+
+TEST(IngredientResultConverterTest, ConvertLegacyIngredientDeltas) {
+  ValidationResultProto input = ParseTextProtoOrDie(R"pb(
+    active_manifest {
+      label: "urn:c2pa:active"
+      assertions {
+        label: "ingredient_assertion"
+        ingredient_v3 {
+          active_manifest { url: "self#jumbf=/c2pa/legacy_ingredient" }
+        }
+      }
+    }
+    ingredient_manifests { label: "legacy_ingredient" }
+  )pb");
+
+  auto result =
+      IngredientResultConverter().Convert(ValidationResult(std::move(input)));
+  ABSL_ASSERT_OK(result);
+
+  EXPECT_THAT(
+      (*result)->proto(), EqualsProto(R"pb(
+        ingredient_validation_results {
+          ingredient_deltas {
+            ingredient_assertion_uri: "self#jumbf=/c2pa/urn:c2pa:active/c2pa.assertions/ingredient_assertion"
+            validation_deltas {}
+          }
+        }
+        legacy_ingredient_metadata {
+          unsupported_manifests: "legacy_ingredient"
+          unsupported_ingredient_assertion_uris: "self#jumbf=/c2pa/urn:c2pa:active/c2pa.assertions/ingredient_assertion"
+        }
+        logging_metadata { active_manifest {} }
+      )pb"));
+}
+
+TEST(IngredientResultConverterTest, ConvertUnreferencedLegacyIngredient) {
+  ValidationResultProto input = ParseTextProtoOrDie(R"pb(
+    active_manifest { label: "urn:c2pa:active" }
+    ingredient_manifests { label: "legacy_ingredient" }
+  )pb");
+
+  auto result =
+      IngredientResultConverter().Convert(ValidationResult(std::move(input)));
+  ABSL_ASSERT_OK(result);
+
+  EXPECT_THAT((*result)->proto(), EqualsProto(R"pb(
+                legacy_ingredient_metadata {
+                  unsupported_manifests: "legacy_ingredient"
+                }
+                logging_metadata { active_manifest {} }
+              )pb"));
+}
+
+TEST(IngredientResultConverterTest,
+     ConvertActiveManifestFailuresAndInformationals) {
+  ValidationResultProto input = ParseTextProtoOrDie(R"pb(
+    active_manifest {
+      label: "urn:c2pa:active"
+      validation {
+        failures {
+          code: "general.error"
+          url: "self#jumbf=/c2pa/urn:c2pa:active"
+        }
+        informationals {
+          code: "com.google.timeStamp.outOfRange"
+          url: "self#jumbf=/c2pa/urn:c2pa:active"
+        }
+      }
+    }
+  )pb");
+
+  auto result =
+      IngredientResultConverter().Convert(ValidationResult(std::move(input)));
+  ABSL_ASSERT_OK(result);
+
+  EXPECT_THAT((*result)->proto(), EqualsProto(R"pb(
+                ingredient_validation_results {
+                  active_manifest {
+                    failures {
+                      code: "general.error"
+                      url: "self#jumbf=/c2pa/urn:c2pa:active"
+                    }
+                    informationals {
+                      code: "com.google.timeStamp.outOfRange"
+                      url: "self#jumbf=/c2pa/urn:c2pa:active"
+                    }
+                  }
+                }
+                logging_metadata { active_manifest {} }
+              )pb"));
+}
+
+TEST(IngredientResultConverterTest,
+     ConvertMultipleAssertionsReferencingSameIngredient) {
+  ValidationResultProto input = ParseTextProtoOrDie(R"pb(
+    active_manifest {
+      label: "urn:c2pa:active"
+      assertions {
+        label: "ingredient_assertion1"
+        ingredient_v3 {
+          active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:ingredient1" }
+          validation_results {
+            active_manifest { successes { code: "claimSignature.validated" } }
+          }
+        }
+      }
+      assertions {
+        label: "ingredient_assertion2"
+        ingredient_v3 {
+          active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:ingredient1" }
+          validation_results {
+            active_manifest { successes { code: "claimSignature.validated" } }
+          }
+        }
+      }
+    }
+    ingredient_manifests {
+      label: "urn:c2pa:ingredient1"
+      validation {
+        successes { code: "claimSignature.validated" }
+        failures { code: "ingredient_failure" url: "test://failure" }
+      }
+    }
+  )pb");
+
+  auto result =
+      IngredientResultConverter().Convert(ValidationResult(std::move(input)));
+  ABSL_ASSERT_OK(result);
+
+  EXPECT_THAT(
+      (*result)->proto(), EqualsProto(R"pb(
+        ingredient_validation_results {
+          ingredient_deltas {
+            ingredient_assertion_uri: "self#jumbf=/c2pa/urn:c2pa:active/c2pa.assertions/ingredient_assertion1"
+            validation_deltas {
+              failures { code: "ingredient_failure" url: "test://failure" }
+            }
+          }
+          ingredient_deltas {
+            ingredient_assertion_uri: "self#jumbf=/c2pa/urn:c2pa:active/c2pa.assertions/ingredient_assertion2"
+            validation_deltas {
+              failures { code: "ingredient_failure" url: "test://failure" }
+            }
+          }
+        }
+        logging_metadata { active_manifest {} }
+      )pb"));
+}
+
+TEST(IngredientResultConverterTest, ConvertChainedIngredientManifests) {
+  ValidationResultProto input = ParseTextProtoOrDie(R"pb(
+    active_manifest {
+      label: "urn:c2pa:active"
+      assertions {
+        label: "assertion_for_ingredient1"
+        ingredient_v3 {
+          active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:ingredient1" }
+          validation_results {
+            active_manifest { successes { code: "claimSignature.validated" } }
+          }
+        }
+      }
+    }
+    ingredient_manifests {
+      label: "urn:c2pa:ingredient1"
+      assertions {
+        label: "assertion_for_ingredient2"
+        ingredient_v3 {
+          active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:ingredient2" }
+          validation_results {
+            active_manifest { successes { code: "claimSignature.validated" } }
+          }
+        }
+      }
+      validation { successes { code: "claimSignature.validated" } }
+    }
+    ingredient_manifests {
+      label: "urn:c2pa:ingredient2"
+      validation {
+        successes { code: "claimSignature.validated" }
+        failures { code: "chained_failure" url: "test://chained" }
+      }
+    }
+  )pb");
+
+  auto result =
+      IngredientResultConverter().Convert(ValidationResult(std::move(input)));
+  ABSL_ASSERT_OK(result);
+
+  EXPECT_THAT(
+      (*result)->proto(), EqualsProto(R"pb(
+        ingredient_validation_results {
+          ingredient_deltas {
+            ingredient_assertion_uri: "self#jumbf=/c2pa/urn:c2pa:active/c2pa.assertions/assertion_for_ingredient1"
+            validation_deltas {}
+          }
+          ingredient_deltas {
+            ingredient_assertion_uri: "self#jumbf=/c2pa/urn:c2pa:ingredient1/c2pa.assertions/assertion_for_ingredient2"
+            validation_deltas {
+              failures { code: "chained_failure" url: "test://chained" }
+            }
+          }
+        }
+        logging_metadata { active_manifest {} }
+      )pb"));
+}
+
+TEST(IngredientResultConverterTest, ConvertIngredientDeltasStatusVarieties) {
+  ValidationResultProto input = ParseTextProtoOrDie(R"pb(
+    active_manifest {
+      label: "urn:c2pa:active"
+      assertions {
+        label: "ingredient_assertion"
+        ingredient_v3 {
+          active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:ingredient1" }
+          validation_results {
+            active_manifest { successes { code: "known_success" } }
+          }
+        }
+      }
+    }
+    ingredient_manifests {
+      label: "urn:c2pa:ingredient1"
+      validation {
+        successes { code: "known_success" }
+        successes { code: "new_success" url: "test://success" }
+        informationals { code: "info_code" url: "test://info" }
+      }
+    }
+  )pb");
+
+  auto result =
+      IngredientResultConverter().Convert(ValidationResult(std::move(input)));
+  ABSL_ASSERT_OK(result);
+
+  EXPECT_THAT(
+      (*result)->proto(), EqualsProto(R"pb(
+        ingredient_validation_results {
+          ingredient_deltas {
+            ingredient_assertion_uri: "self#jumbf=/c2pa/urn:c2pa:active/c2pa.assertions/ingredient_assertion"
+            validation_deltas {
+              successes { code: "new_success" url: "test://success" }
+              informationals { code: "info_code" url: "test://info" }
+            }
+          }
+        }
+        logging_metadata { active_manifest {} }
+      )pb"));
+}
+
+TEST(IngredientResultConverterTest, ConvertUnreferencedStandardIngredient) {
+  ValidationResultProto input = ParseTextProtoOrDie(R"pb(
+    active_manifest { label: "urn:c2pa:active" }
+    ingredient_manifests { label: "urn:c2pa:unreferenced" }
+  )pb");
+
+  auto result =
+      IngredientResultConverter().Convert(ValidationResult(std::move(input)));
+  ABSL_ASSERT_OK(result);
+
+  EXPECT_THAT((*result)->proto(), EqualsProto(R"pb(
+                logging_metadata { active_manifest {} }
+              )pb"));
+}
+
+TEST(IngredientResultConverterTest,
+     ConvertMissingReferencedIngredientManifest) {
+  ValidationResultProto input = ParseTextProtoOrDie(R"pb(
+    active_manifest {
+      label: "urn:c2pa:active"
+      validation {
+        informationals {
+          code: "ingredient.unknownProvenance"
+          url: "self#jumbf=/c2pa/urn:c2pa:active/c2pa.assertions/ingredient_v3"
+        }
+      }
+      assertions {
+        label: "ingredient_v3"
+        ingredient_v3 {
+          active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:missing" }
+        }
+      }
+    }
+  )pb");
+
+  auto result =
+      IngredientResultConverter().Convert(ValidationResult(std::move(input)));
+  ABSL_ASSERT_OK(result);
+
+  EXPECT_THAT(
+      (*result)->proto(), EqualsProto(R"pb(
+        ingredient_validation_results {
+          active_manifest {
+            informationals {
+              code: "ingredient.unknownProvenance"
+              url: "self#jumbf=/c2pa/urn:c2pa:active/c2pa.assertions/ingredient_v3"
+            }
+          }
+        }
+        logging_metadata { active_manifest {} }
+      )pb"));
+}
+
+TEST(IngredientResultConverterTest, ConvertMultipleLegacyIngredientManifests) {
+  ValidationResultProto input = ParseTextProtoOrDie(R"pb(
+    active_manifest {
+      label: "urn:c2pa:active"
+      assertions {
+        label: "std_assertion"
+        ingredient_v3 {
+          active_manifest { url: "self#jumbf=/c2pa/urn:c2pa:std" }
+        }
+      }
+      assertions {
+        label: "legacy_assertion"
+        ingredient_v3 {
+          active_manifest { url: "self#jumbf=/c2pa/legacy_label" }
+        }
+      }
+    }
+    ingredient_manifests { label: "urn:c2pa:std" }
+    ingredient_manifests { label: "legacy_label" }
+  )pb");
+
+  auto result =
+      IngredientResultConverter().Convert(ValidationResult(std::move(input)));
+  ABSL_ASSERT_OK(result);
+
+  EXPECT_THAT(
+      (*result)->proto(), EqualsProto(R"pb(
+        ingredient_validation_results {
+          ingredient_deltas {
+            ingredient_assertion_uri: "self#jumbf=/c2pa/urn:c2pa:active/c2pa.assertions/std_assertion"
+            validation_deltas {}
+          }
+          ingredient_deltas {
+            ingredient_assertion_uri: "self#jumbf=/c2pa/urn:c2pa:active/c2pa.assertions/legacy_assertion"
+            validation_deltas {}
+          }
+        }
+        legacy_ingredient_metadata {
+          unsupported_manifests: "legacy_label"
+          unsupported_ingredient_assertion_uris: "self#jumbf=/c2pa/urn:c2pa:active/c2pa.assertions/legacy_assertion"
+        }
+        logging_metadata { active_manifest {} }
+      )pb"));
+}
+
+TEST(IngredientResultConverterTest, ConvertCopiesSpecVersion) {
+  ValidationResultProto validation_result;
+  validation_result.set_spec_version("2.2.0");
+  validation_result.mutable_active_manifest()
+      ->mutable_validation()
+      ->add_successes()
+      ->set_code("code");
+
+  auto result = IngredientResultConverter().Convert(
+      ValidationResult(std::move(validation_result)));
+  ABSL_ASSERT_OK(result);
+  EXPECT_EQ((*result)->proto().ingredient_validation_results().spec_version(),
+            "2.2.0");
+}
+
+TEST(IngredientResultConverterTest, ConvertCopiesTrustListUri) {
+  ValidationResultProto validation_result;
+  validation_result.set_trust_list_uri("urn:test");
+  validation_result.mutable_active_manifest()
+      ->mutable_validation()
+      ->add_successes()
+      ->set_code("code");
+
+  auto result = IngredientResultConverter().Convert(
+      ValidationResult(std::move(validation_result)));
+  ABSL_ASSERT_OK(result);
+  EXPECT_EQ((*result)->proto().ingredient_validation_results().trust_list_uri(),
+            "urn:test");
+}
+
+TEST(IngredientResultConverterTest, ConvertCopiesManifestStoreBytes) {
+  ValidationResult validation_result(ValidationResultProto(),
+                                     "manifest_store_bytes");
+
+  auto result =
+      IngredientResultConverter().Convert(std::move(validation_result));
+  ABSL_ASSERT_OK(result);
+  EXPECT_EQ((*result)->proto().manifest_store_bytes(), "manifest_store_bytes");
+}
+
+// Records the manifests it is given and stamps a digital source type, standing
+// in for enrichment that the converter deliberately does not implement.
+class RecordingConsumer : public ManifestConsumer {
+ public:
+  absl::Status OnManifestValidated(const Manifest& manifest,
+                                   bool is_active_manifest) override {
+    labels_.push_back(manifest.label());
+    active_manifest_labels_.push_back(is_active_manifest);
+    return absl::OkStatus();
+  }
+
+  absl::Status OnIngredientValidationResultComplete(
+      IngredientValidationResultProto* ingredient_result) override {
+    ingredient_result->set_digital_source_type("test://digital-source-type");
+    return absl::OkStatus();
+  }
+
+  const std::vector<std::string>& labels() const { return labels_; }
+  const std::vector<bool>& active_manifest_labels() const {
+    return active_manifest_labels_;
+  }
+
+ private:
+  std::vector<std::string> labels_;
+  std::vector<bool> active_manifest_labels_;
+};
+
+TEST(IngredientResultConverterTest, ConvertNotifiesTheConsumerOfEveryManifest) {
+  ValidationResultProto input = ParseTextProtoOrDie(R"pb(
+    active_manifest { label: "urn:c2pa:active" }
+    ingredient_manifests { label: "urn:c2pa:first" }
+    ingredient_manifests { label: "urn:c2pa:second" }
+  )pb");
+  auto consumer = std::make_unique<RecordingConsumer>();
+  RecordingConsumer* consumer_ptr = consumer.get();
+  // The converter owns the consumer, so it has to outlive the expectations.
+  IngredientResultConverter converter(std::move(consumer));
+
+  auto result = converter.Convert(ValidationResult(std::move(input)));
+  ABSL_ASSERT_OK(result);
+
+  EXPECT_THAT(
+      consumer_ptr->labels(),
+      ElementsAre("urn:c2pa:active", "urn:c2pa:first", "urn:c2pa:second"));
+  EXPECT_THAT(consumer_ptr->active_manifest_labels(),
+              ElementsAre(true, false, false));
+  EXPECT_EQ((*result)->proto().digital_source_type(),
+            "test://digital-source-type");
+}
+
+TEST(IngredientResultConverterTest,
+     ConvertWithoutAConsumerLeavesDigitalSourceTypeUnset) {
+  ValidationResultProto input = ParseTextProtoOrDie(R"pb(
+    active_manifest {
+      label: "urn:c2pa:active"
+      assertions {
+        actions {
+          actions {
+            digital_source_type: "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia"
+          }
+        }
+      }
+    }
+  )pb");
+
+  auto result =
+      IngredientResultConverter().Convert(ValidationResult(std::move(input)));
+  ABSL_ASSERT_OK(result);
+
+  EXPECT_FALSE((*result)->proto().has_digital_source_type());
+}
+
+TEST(IngredientResultConverterTest, ConvertPropagatesConsumerError) {
+  class FailingConsumer : public ManifestConsumer {
+   public:
+    absl::Status OnManifestValidated(const Manifest& manifest,
+                                     bool is_active_manifest) override {
+      return absl::InternalError("consumer failed");
+    }
+  };
+  ValidationResultProto input = ParseTextProtoOrDie(R"pb(
+    active_manifest { label: "urn:c2pa:active" }
+  )pb");
+
+  EXPECT_THAT(IngredientResultConverter(std::make_unique<FailingConsumer>())
+                  .Convert(ValidationResult(std::move(input))),
+              StatusIs(absl::StatusCode::kInternal));
+}
+
+}  // namespace
+}  // namespace credentio
diff --git a/validator/manifest_consumer.h b/validator/manifest_consumer.h
new file mode 100644
index 0000000..c8172c8
--- /dev/null
+++ b/validator/manifest_consumer.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_CONSUMER_H_
+#define THIRD_PARTY_CREDENTIO_VALIDATOR_MANIFEST_CONSUMER_H_
+
+#include "absl/status/status.h"
+#include "proto/ingredient_validation_result.pb.h"
+#include "proto/manifest.pb.h"
+
+namespace credentio {
+
+// Interface for consumers that observe manifest validation events during
+// manifest graph traversal.
+//
+// Consumers are the extension point for enrichment that the validator itself
+// does not implement, e.g. deriving a digital source type from the actions
+// recorded in the manifests. Validation results are identical with and without
+// consumers; only the enriched fields differ.
+class ManifestConsumer {
+ public:
+  virtual ~ManifestConsumer() = default;
+
+  // Called when a manifest node has been successfully validated.
+  // `is_active_manifest` indicates if this is the root active manifest.
+  virtual absl::Status OnManifestValidated(const Manifest& manifest,
+                                           bool is_active_manifest) = 0;
+
+  // Called after a complete ingredient validation result has been converted
+  // from a validation result.
+  virtual absl::Status OnIngredientValidationResultComplete(
+      IngredientValidationResultProto* ingredient_result) {
+    return absl::OkStatus();
+  }
+};
+
+}  // namespace credentio
+
+#endif  // THIRD_PARTY_CREDENTIO_VALIDATOR_MANIFEST_CONSUMER_H_
diff --git a/validator/result.h b/validator/result.h
index 6af0a57..14dbb07 100644
--- a/validator/result.h
+++ b/validator/result.h
@@ -37,6 +37,11 @@
       : proto_(std::move(proto)),
         manifest_store_bytes_(std::move(manifest_store_bytes)) {}
 
+  explicit ValidationResult(ValidationResultProto&& proto,
+                            std::string manifest_store_bytes = "")
+      : proto_(std::make_unique<ValidationResultProto>(std::move(proto))),
+        manifest_store_bytes_(std::move(manifest_store_bytes)) {}
+
   const ValidationResultProto& proto() const { return *proto_; }
   ValidationResultProto& mutable_proto() { return *proto_; }
 
@@ -44,10 +49,20 @@
     return std::move(proto_);
   }
 
+  // Returns the raw manifest store bytes extracted from the asset. May be empty
+  // if the `ValidationResult` was constructed without them or after
+  // `release_manifest_store_bytes()` is called.
   absl::string_view manifest_store_bytes() const {
     return manifest_store_bytes_;
   }
 
+  // Releases ownership of the raw manifest store bytes. May be empty if the
+  // `ValidationResult` was constructed without them or if called more than
+  // once.
+  std::string release_manifest_store_bytes() {
+    return std::move(manifest_store_bytes_);
+  }
+
  private:
   std::unique_ptr<ValidationResultProto> proto_;
   std::string manifest_store_bytes_;
diff --git a/validator/result_test.cc b/validator/result_test.cc
index 37b49b1..2dd0c7c 100644
--- a/validator/result_test.cc
+++ b/validator/result_test.cc
@@ -42,6 +42,22 @@
   EXPECT_THAT(result.proto(), EqualsProto(expected));
 }
 
+TEST(ValidationResultTest, FromRvalueProto) {
+  ValidationResultProto proto;
+  proto.mutable_active_manifest()->set_label("test");
+  ValidationResult result(std::move(proto), "manifest_store_bytes");
+  ValidationResultProto expected;
+  expected.mutable_active_manifest()->set_label("test");
+  EXPECT_THAT(result.proto(), EqualsProto(expected));
+  EXPECT_EQ(result.manifest_store_bytes(), "manifest_store_bytes");
+}
+
+TEST(ValidationResultTest, ReleaseManifestStoreBytes) {
+  ValidationResult result(ValidationResultProto(), "manifest_store_bytes");
+  EXPECT_EQ(result.manifest_store_bytes(), "manifest_store_bytes");
+  EXPECT_EQ(result.release_manifest_store_bytes(), "manifest_store_bytes");
+}
+
 TEST(GetAssertionTest, ReturnsAssertionFromActiveManifest) {
   PartialValidationResultProto result;
   result.mutable_active_manifest()->set_label("active_manifest");