Public release:

  - Remove redundant `SigningAlgorithmToString` helper in `cr...
  - Validate BMFF manifest box ordering during C2PA extraction.
  - Accept CRLF and extra white-space in the PDF startxref tr...
  - Add in-memory TestTracer and TestTracerFactory for tracin...
  - Propagate TracerFactory via AsyncContext and thread-local...
  - Reject PDF objects whose header does not match the xref e...
  - Extract BMFF box location recording into a helper function
  - Align relocated TIFF IFDs to a word boundary in TiffWriter.
  - Pin Java runtime and toolchain to remotejdk_25 in .bazelrc.
  - Report `algorithm.unsupported` instead of `signingCredent...
  - Refactor BMFF extractor to separate box discovery from pa...
  - Limit PDF manifest stores to the size the reader accepts
  - Add PDF page and annotation dictionary constants
  - Make PDF indirect references hashable
  - Add BMFF extractor test cases for box ordering and reader...
  - Omit CMS signingTime attribute from test timestamp tokens...
  - Test fix: Sort CMS SignedAttributes into canonical DER SE...
  - Annotate `CreateDefaultCryptoReadHandler` return value as...
  - Remove Android steps from the Ubuntu Kokoro build
  - Reject ECDSA claim signatures with wrong encoding (DER in...
  - Add a Kokoro build config for the Android SDK
  - Bound BMFF offset box parsing by the available data
  - Rename VerifySignature to VerifyCoseSignature in ParsedCe...
  - Enable on-device TSA proxy integration tests in open-sour...

GitOrigin-RevId: 04b22f0a436b583efe410bf7dd099f78ed8719e0
Change-Id: I1af9885f84666d797b1a10530daab2c856a5f226
diff --git a/.bazelrc b/.bazelrc
index a9dda58..ba480ff 100644
--- a/.bazelrc
+++ b/.bazelrc
@@ -10,4 +10,8 @@
 
 # Use clang for C++ compilation
 build --repo_env=CC=clang
-build --repo_env=CXX=clang++
\ No newline at end of file
+build --repo_env=CXX=clang++
+
+# Pin the Java runtime to a hermetic remote JDK to prevent host JDK version skew.
+build --java_runtime_version=remotejdk_25
+build --tool_java_runtime_version=remotejdk_25
diff --git a/async/BUILD b/async/BUILD
index 12227c3..70a6845 100644
--- a/async/BUILD
+++ b/async/BUILD
@@ -33,6 +33,7 @@
         ":cancellation_token",
         ":executor",
         "@abseil-cpp//absl/base:nullability",
+        "@abseil-cpp//absl/container:flat_hash_map",
         "@abseil-cpp//absl/functional:any_invocable",
         "@abseil-cpp//absl/status",
         "@abseil-cpp//absl/status:statusor",
diff --git a/async/async_context.h b/async/async_context.h
index c25eef2..d6e179c 100644
--- a/async/async_context.h
+++ b/async/async_context.h
@@ -15,9 +15,12 @@
 
 #ifndef THIRD_PARTY_CREDENTIO_ASYNC_ASYNC_CONTEXT_H_
 #define THIRD_PARTY_CREDENTIO_ASYNC_ASYNC_CONTEXT_H_
+#include <memory>
+#include <type_traits>
 #include <utility>
 
 #include "absl/base/nullability.h"
+#include "absl/container/flat_hash_map.h"
 #include "absl/functional/any_invocable.h"
 #include "absl/status/status.h"
 #include "absl/status/statusor.h"
@@ -38,6 +41,38 @@
 
   Executor* absl_nullable executor() const { return executor_; }
 
+  // Attaches an extension of type `T` to this context, replacing any existing
+  // extension of the same type. Passing nullptr removes the extension.
+  //
+  // Because `AsyncContext` is threaded through asynchronous calls and captured
+  // into continuations, attached extensions survive executor hops.
+  //
+  // Note: Lookup in `GetExtension<T>()` is keyed by the exact type `T`. When
+  // attaching an implementation of an interface, specify the interface type
+  // explicitly (e.g. `context.SetExtension<MyInterface>(impl)`) so consumers
+  // can retrieve it via `context.GetExtension<MyInterface>()`.
+  template <typename T>
+  void SetExtension(absl_nullable std::shared_ptr<T> extension) {
+    using KeyType = std::remove_cv_t<T>;
+    if (extension == nullptr) {
+      extensions_.erase(ExtensionKey<KeyType>());
+      return;
+    }
+    extensions_[ExtensionKey<KeyType>()] = std::move(extension);
+  }
+
+  // Returns the extension of type `T` attached to this context, or nullptr if
+  // none is present.
+  template <typename T>
+  absl_nullable std::shared_ptr<T> GetExtension() const {
+    using KeyType = std::remove_cv_t<T>;
+    auto it = extensions_.find(ExtensionKey<KeyType>());
+    if (it == extensions_.end()) {
+      return nullptr;
+    }
+    return std::static_pointer_cast<T>(it->second);
+  }
+
   void Cancel() { cancellation_source_.Cancel(); }
 
   CancellationToken GetCancellationToken() const {
@@ -45,8 +80,15 @@
   }
 
  private:
+  template <typename T>
+  static const void* ExtensionKey() {
+    static const char kKey = 0;
+    return &kKey;
+  }
+
   Executor* absl_nullable executor_ = nullptr;
   CancellationSource cancellation_source_;
+  absl::flat_hash_map<const void*, std::shared_ptr<void>> extensions_;
 };
 
 // Helper class to resolve a callback on the given AsyncContext using a
diff --git a/async/async_context_test.cc b/async/async_context_test.cc
index bb9e123..7b2cf32 100644
--- a/async/async_context_test.cc
+++ b/async/async_context_test.cc
@@ -15,6 +15,8 @@
 
 #include "async/async_context.h"
 
+#include <memory>
+#include <string>
 #include <utility>
 
 #include "absl/functional/any_invocable.h"
@@ -153,5 +155,64 @@
   EXPECT_TRUE(task_run);
 }
 
+struct IntExtension {
+  int value = 0;
+};
+
+struct StringExtension {
+  std::string value;
+};
+
+class BaseExtension {
+ public:
+  virtual ~BaseExtension() = default;
+  virtual int id() const = 0;
+};
+
+class DerivedExtension : public BaseExtension {
+ public:
+  int id() const override { return 99; }
+};
+
+TEST(AsyncContextTest, ExtensionsStoreRetrieveOverwriteAndClear) {
+  AsyncContext context;
+  EXPECT_EQ(context.GetExtension<IntExtension>(), nullptr);
+  EXPECT_EQ(context.GetExtension<StringExtension>(), nullptr);
+
+  auto int_ext = std::make_shared<IntExtension>(IntExtension{42});
+  auto str_ext = std::make_shared<StringExtension>(StringExtension{"hello"});
+  context.SetExtension(int_ext);
+  context.SetExtension(str_ext);
+
+  EXPECT_EQ(context.GetExtension<IntExtension>(), int_ext);
+  EXPECT_EQ(context.GetExtension<StringExtension>(), str_ext);
+
+  // Copying the context preserves attached extensions.
+  AsyncContext copy = context;
+  EXPECT_EQ(copy.GetExtension<IntExtension>(), int_ext);
+  EXPECT_EQ(copy.GetExtension<StringExtension>(), str_ext);
+
+  // Overwriting and clearing on the original does not mutate the copy's map.
+  auto new_int_ext = std::make_shared<IntExtension>(IntExtension{7});
+  context.SetExtension(new_int_ext);
+  EXPECT_EQ(context.GetExtension<IntExtension>(), new_int_ext);
+  EXPECT_EQ(copy.GetExtension<IntExtension>(), int_ext);
+
+  context.SetExtension<IntExtension>(nullptr);
+  EXPECT_EQ(context.GetExtension<IntExtension>(), nullptr);
+  EXPECT_EQ(context.GetExtension<StringExtension>(), str_ext);
+}
+
+TEST(AsyncContextTest, InterfaceExtensionKeyedByExplicitBaseType) {
+  AsyncContext context;
+  auto derived = std::make_shared<DerivedExtension>();
+  context.SetExtension<BaseExtension>(derived);
+
+  std::shared_ptr<BaseExtension> retrieved =
+      context.GetExtension<BaseExtension>();
+  ASSERT_NE(retrieved, nullptr);
+  EXPECT_EQ(retrieved->id(), 99);
+}
+
 }  // namespace
 }  // namespace credentio
diff --git a/cose/cose_sign1.cc b/cose/cose_sign1.cc
index 0ef7d4c..3036e05 100644
--- a/cose/cose_sign1.cc
+++ b/cose/cose_sign1.cc
@@ -306,6 +306,12 @@
   // STEP 0: Get the certificate chain and parse the certificates.
   auto protected_headers = DecodeProtectedHeader(cose_sign1.protected_header);
   if (!protected_headers.ok()) {
+    if (absl::IsUnimplemented(protected_headers.status())) {
+      RecordStatus(status_set, FailureStatusCode::kAlgorithmUnsupported,
+                   {.url = kClaimSignatureLabel,
+                    .explanation = protected_headers.status().message()});
+      return std::nullopt;
+    }
     return RecordInvalidSigningCredential(
         absl::StrCat("cannot decode protected header: ",
                      protected_headers.status().message()),
@@ -414,7 +420,7 @@
   // STEP 4: Verify the signature.
   if (options_.verify_signature) {
     auto signing_algorithm = protected_headers->alg;
-    if (absl::Status status = (*certs)->VerifySignature(
+    if (absl::Status status = (*certs)->VerifyCoseSignature(
             cose_sign1.signature,
             ConstructSigStructure(cose_sign1.protected_header, external_payload,
                                   "Signature1"),
diff --git a/cose/sig_structure.cc b/cose/sig_structure.cc
index 055da02..734939a 100644
--- a/cose/sig_structure.cc
+++ b/cose/sig_structure.cc
@@ -104,7 +104,7 @@
     case -8:
       return SigningAlgorithm::kEdDsa;
   }
-  return absl::InvalidArgumentError(
+  return absl::UnimplementedError(
       absl::StrCat("unsupported COSE algorithm: ", *alg));
 }
 
diff --git a/cose/sig_structure_test.cc b/cose/sig_structure_test.cc
index f9d87f0..4b43bb9 100644
--- a/cose/sig_structure_test.cc
+++ b/cose/sig_structure_test.cc
@@ -202,7 +202,7 @@
   // hex for {1: -100, 33: "abcd"}
   auto cbor = absl::HexStringToBytes("a2013863214461626364");
   EXPECT_THAT(DecodeProtectedHeader(cbor),
-              StatusIs(absl::StatusCode::kInvalidArgument,
+              StatusIs(absl::StatusCode::kUnimplemented,
                        "unsupported COSE algorithm: -100"));
 }
 
diff --git a/crypto/crypto_read_handler.h b/crypto/crypto_read_handler.h
index fdeb26c..2327000 100644
--- a/crypto/crypto_read_handler.h
+++ b/crypto/crypto_read_handler.h
@@ -36,15 +36,16 @@
  public:
   virtual ~ParsedCertificates() = default;
 
-  // Verifies that `signature` applies to `data`, using the public key from the
-  // leaf certificate. This function can be used for both claim and OCSP
-  // response signatures. Performs no trust checks.
+  // Verifies a raw signature extracted from a COSE_Sign1 message using the
+  // public key from the leaf certificate. This is for use with COSE signatures
+  // only, so ECDSA signatures must be in IEEE P1363 (not DER) format. Returns
+  // an OK status if the verification is successful. Performs no trust checks.
   //
   // Returns:
   //   - INVALID_ARGUMENT if the signature is invalid.
-  virtual absl::Status VerifySignature(absl::string_view signature,
-                                       absl::string_view data,
-                                       SigningAlgorithm algorithm) const = 0;
+  virtual absl::Status VerifyCoseSignature(
+      absl::string_view signature, absl::string_view data,
+      SigningAlgorithm algorithm) const = 0;
 
   // Verifies the trustworthiness of the leaf certificate for claim signing, by
   // finding an ordered chain of valid certificates to a trusted root,
diff --git a/crypto/default/default_crypto_read_handler.cc b/crypto/default/default_crypto_read_handler.cc
index bbd4ff3..afc9409 100644
--- a/crypto/default/default_crypto_read_handler.cc
+++ b/crypto/default/default_crypto_read_handler.cc
@@ -198,10 +198,10 @@
     return *chain;
   }
 
-  absl::Status VerifySignature(absl::string_view signature,
-                               absl::string_view data,
-                               SigningAlgorithm algorithm) const override {
-    return leaf().VerifySignature(signature, data, algorithm);
+  absl::Status VerifyCoseSignature(absl::string_view signature,
+                                   absl::string_view data,
+                                   SigningAlgorithm algorithm) const override {
+    return leaf().VerifyCoseSignature(signature, data, algorithm);
   }
 
   size_t GetCertificateCount() const override {
@@ -412,7 +412,7 @@
       all_claim_signer_roots_.get(), legacy_claim_signer_roots_.get());
 }
 
-absl::StatusOr<std::unique_ptr<CryptoReadHandler>>
+absl::StatusOr<std::unique_ptr<CryptoReadHandler> absl_nonnull>
 CreateDefaultCryptoReadHandler(const DefaultCryptoReadHandlerOptions& options) {
   if (options.clock == nullptr) {
     return absl::InvalidArgumentError("clock must not be null");
diff --git a/crypto/default/default_crypto_read_handler.h b/crypto/default/default_crypto_read_handler.h
index 63edcf6..645ddb0 100644
--- a/crypto/default/default_crypto_read_handler.h
+++ b/crypto/default/default_crypto_read_handler.h
@@ -18,6 +18,7 @@
 
 #include <memory>
 
+#include "absl/base/nullability.h"
 #include "absl/status/statusor.h"
 #include "absl/strings/string_view.h"
 #include "absl/time/clock_interface.h"
@@ -62,7 +63,7 @@
 
 // Creates an implementation of CryptoReadHandler intended for use in
 // production. It uses Tink, BoringSSL, and SimpleCmsParser.
-absl::StatusOr<std::unique_ptr<CryptoReadHandler>>
+absl::StatusOr<std::unique_ptr<CryptoReadHandler> absl_nonnull>
 CreateDefaultCryptoReadHandler(const DefaultCryptoReadHandlerOptions& options);
 
 }  // namespace credentio
diff --git a/crypto/default/x509_certificate.cc b/crypto/default/x509_certificate.cc
index d976b5c..f1f4154 100644
--- a/crypto/default/x509_certificate.cc
+++ b/crypto/default/x509_certificate.cc
@@ -206,7 +206,7 @@
   return ComplianceChecker::IsValidC2paCertificate(cert_.get(), is_leaf);
 }
 
-absl::Status X509Certificate::VerifySignature(
+absl::Status X509Certificate::VerifyCoseSignature(
     absl::string_view signature, absl::string_view data,
     SigningAlgorithm algorithm) const {
   static const absl::NoDestructor<absl::Status> registration_status(
@@ -373,8 +373,7 @@
   return BigInteger(e_bytes);
 }
 
-absl::StatusOr<std::pair<EcdsaParameters, EcdsaParameters>> GetEcdsaParameters(
-    SigningAlgorithm algorithm) {
+absl::StatusOr<EcdsaParameters> GetEcdsaParameters(SigningAlgorithm algorithm) {
   EcdsaParameters::HashType hash_type;
   EcdsaParameters::CurveType curve_type;
   switch (algorithm) {
@@ -394,23 +393,13 @@
       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);
+  // COSE requires IEEE P1363 encoding.
+  return EcdsaParameters::Builder()
+      .SetCurveType(curve_type)
+      .SetHashType(hash_type)
+      .SetSignatureEncoding(EcdsaParameters::SignatureEncoding::kIeeeP1363)
+      .SetVariant(EcdsaParameters::Variant::kNoPrefix)
+      .Build();
 }
 
 }  // namespace
@@ -443,9 +432,9 @@
     case SigningAlgorithm::kEs256:
     case SigningAlgorithm::kEs384:
     case SigningAlgorithm::kEs512: {
-      ABSL_ASSIGN_OR_RETURN(auto params, GetEcdsaParameters(algorithm));
-      return std::vector<X509Certificate::TinkKeyParameters>{params.first,
-                                                             params.second};
+      ABSL_ASSIGN_OR_RETURN(EcdsaParameters params,
+                            GetEcdsaParameters(algorithm));
+      return std::vector<X509Certificate::TinkKeyParameters>{params};
     }
     case SigningAlgorithm::kEdDsa: {
       ABSL_ASSIGN_OR_RETURN(
diff --git a/crypto/default/x509_certificate.h b/crypto/default/x509_certificate.h
index cda0521..6249d2d 100644
--- a/crypto/default/x509_certificate.h
+++ b/crypto/default/x509_certificate.h
@@ -50,12 +50,13 @@
   static absl::StatusOr<std::unique_ptr<X509Certificate>> Create(
       absl::string_view der);
   absl::Status IsValidC2paCertificate(bool is_leaf) const;
-  // Verifies a signature using the public key encapsulated by this
-  // `X509Certificate` instance.
-  // Returns an OK status if the verification is successful.
-  absl::Status VerifySignature(absl::string_view signature,
-                               absl::string_view data,
-                               SigningAlgorithm algorithm) const;
+  // Verifies a raw signature extracted from a COSE_Sign1 message using the
+  // public key encapsulated by this `X509Certificate` instance. This is for use
+  // with COSE signatures only, so ECDSA signatures must be in IEEE P1363 (not
+  // DER) format. Returns an OK status if the verification is successful.
+  absl::Status VerifyCoseSignature(absl::string_view signature,
+                                   absl::string_view data,
+                                   SigningAlgorithm algorithm) const;
   absl::StatusOr<absl::Time> StartTime() const;
   absl::StatusOr<absl::Time> EndTime() const;
   absl::StatusOr<std::vector<TinkKeyParameters>> GetPemKeyParams(
diff --git a/crypto/default/x509_certificate_test.cc b/crypto/default/x509_certificate_test.cc
index d970024..9201007 100644
--- a/crypto/default/x509_certificate_test.cc
+++ b/crypto/default/x509_certificate_test.cc
@@ -216,14 +216,6 @@
   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)
@@ -232,20 +224,11 @@
           .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));
+  EXPECT_THAT(*params_es256, ElementsAre(*want_p256_ieee_ecdsa_params));
 
   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)
@@ -254,20 +237,11 @@
           .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));
+  EXPECT_THAT(*params_es384, ElementsAre(*want_p384_ieee_ecdsa_params));
 
   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)
@@ -276,8 +250,7 @@
           .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(*params_es521, ElementsAre(*want_p521_ieee_ecdsa_params));
 
   EXPECT_THAT((*x509)->GetPemKeyParams(SigningAlgorithm::kPs256),
               StatusIs(absl::StatusCode::kInvalidArgument,
@@ -309,8 +282,8 @@
   auto x509 = X509Certificate::Create(Base64Unescape(kDerEdBase64));
   ABSL_ASSERT_OK(x509.status());
   EXPECT_THAT(
-      (*x509)->VerifySignature(Base64Unescape(kEdSignatureBase64),
-                               "this is a test", SigningAlgorithm::kEdDsa),
+      (*x509)->VerifyCoseSignature(Base64Unescape(kEdSignatureBase64),
+                                   "this is a test", SigningAlgorithm::kEdDsa),
       IsOk());
 }
 
@@ -320,8 +293,8 @@
   // The actual signature and data don't matter for covering the lines in
   // MaybeConvertPssToRsa, as the key conversion happens before signature
   // verification. We expect a failure because the signature is invalid.
-  EXPECT_THAT((*x509)->VerifySignature("fake_signature", "fake_data",
-                                       SigningAlgorithm::kPs256),
+  EXPECT_THAT((*x509)->VerifyCoseSignature("fake_signature", "fake_data",
+                                           SigningAlgorithm::kPs256),
               StatusIs(absl::StatusCode::kInvalidArgument,
                        HasSubstr("Tink verification failed")));
 }
@@ -455,7 +428,7 @@
   ASSERT_NE(x509, nullptr);
   X509Certificate cert(std::move(x509));
   EXPECT_THAT(
-      cert.VerifySignature("sig", "data", SigningAlgorithm::kPs256),
+      cert.VerifyCoseSignature("sig", "data", SigningAlgorithm::kPs256),
       StatusIs(absl::StatusCode::kInvalidArgument,
                HasSubstr("Certificate does not have a valid public key")));
   EXPECT_THAT(
diff --git a/formats/bmff/BUILD b/formats/bmff/BUILD
index 4ab750b..ba545da 100644
--- a/formats/bmff/BUILD
+++ b/formats/bmff/BUILD
@@ -171,6 +171,7 @@
         "@abseil-cpp//absl/strings",
         "@abseil-cpp//absl/strings:string_view",
         "@googletest//:gtest_main",
+        "@riegeli//riegeli/base:types",
         "@riegeli//riegeli/bytes:string_reader",
     ],
 )
@@ -225,6 +226,7 @@
         "@abseil-cpp//absl/status:status_macros",
         "@abseil-cpp//absl/status:statusor",
         "@abseil-cpp//absl/strings",
+        "@riegeli//riegeli/base:types",
         "@riegeli//riegeli/bytes:reader",
         "@riegeli//riegeli/bytes:writer",
         "@riegeli//riegeli/endian:endian_reading",
@@ -243,6 +245,7 @@
         "@abseil-cpp//absl/status:status_matchers",
         "@abseil-cpp//absl/strings",
         "@googletest//:gtest_main",
+        "@riegeli//riegeli/base:types",
         "@riegeli//riegeli/bytes:string_reader",
         "@riegeli//riegeli/bytes:string_writer",
     ],
diff --git a/formats/bmff/extractor.cc b/formats/bmff/extractor.cc
index bbe8c73..63925d1 100644
--- a/formats/bmff/extractor.cc
+++ b/formats/bmff/extractor.cc
@@ -18,6 +18,7 @@
 #include <cstdint>
 #include <optional>
 #include <string>
+#include <utility>
 
 #include "absl/status/status.h"
 #include "absl/status/status_macros.h"
@@ -38,6 +39,8 @@
 
 constexpr absl::string_view kZeroFlags("\x00\x00\x00", 3);
 constexpr uint64_t kMaxPayloadSize = 1024 * 1024 * 10;  // 10 MiB
+constexpr uint64_t kNullTerminatorSize = 1;
+constexpr uint64_t kMerkleOffsetSize = sizeof(uint64_t);
 
 absl::StatusOr<std::string> ReadPurpose(riegeli::Reader& contents) {
   std::string purpose;
@@ -52,13 +55,141 @@
   std::string manifest;
 };
 
+// The first ftyp, moov and mdat boxes, and the C2PA manifest box, found among
+// the top-level boxes of a BMFF file.
+struct BmffC2paBoxLocations {
+  std::optional<BmffBoxHeader> ftyp_box;
+  std::optional<BmffBoxHeader> moov_box;
+  std::optional<BmffBoxHeader> mdat_box;
+
+  std::optional<BmffBoxHeader> manifest;
+};
+
+absl::StatusOr<std::string> ReadC2paBoxPayload(riegeli::Reader& input,
+                                               const BmffBoxHeader& box,
+                                               absl::string_view purpose) {
+  const uint64_t box_end = box.start + box.box_size;
+  const uint64_t payload_start = box.start + box.header_size +
+                                 box.version_and_flags_size + purpose.size() +
+                                 kNullTerminatorSize + kMerkleOffsetSize;
+  // Guard against boxes too small to hold the purpose and merkle offset, which
+  // would otherwise underflow `payload_size` below.
+  if (payload_start > box_end) {
+    return absl::NotFoundError(
+        "No manifest store found; kUnexpectedEof; C2PA box too small for "
+        "merkle offset");
+  }
+  if (!input.Seek(payload_start)) {
+    return input.StatusOrAnnotate(
+        absl::NotFoundError("No manifest store found; kUnexpectedEof; failed "
+                            "to seek to C2PA payload"));
+  }
+  const uint64_t payload_size = box_end - payload_start;
+  if (payload_size > kMaxPayloadSize) {
+    return absl::NotFoundError(
+        absl::Substitute("No manifest store found; BMFF C2PA box is too large "
+                         "to extract ($0 > $1)",
+                         payload_size, kMaxPayloadSize));
+  }
+
+  std::string payload;
+  if (!input.Read(payload_size, payload)) {
+    return input.StatusOrAnnotate(absl::NotFoundError(
+        "No manifest store found; failed to read C2PA box payload"));
+  }
+  return payload;
+}
+
+absl::Status RecordHeadersForC2pa(riegeli::Reader& input,
+                                  const BmffBoxHeader& header,
+                                  BmffC2paBoxLocations& c2pa_boxes) {
+  if (header.type == "ftyp") {
+    c2pa_boxes.ftyp_box = header;
+    return absl::OkStatus();
+  }
+
+  if (header.type == "mdat") {
+    if (!c2pa_boxes.mdat_box.has_value()) {
+      c2pa_boxes.mdat_box = header;
+    }
+    return absl::OkStatus();
+  }
+
+  if (header.type == "moov") {
+    if (!c2pa_boxes.moov_box.has_value()) {
+      c2pa_boxes.moov_box = header;
+    }
+    return absl::OkStatus();
+  }
+
+  if (header.type == "uuid" && header.user_type == kC2paBmffBoxUuid) {
+    if (header.version != 0) {
+      return absl::InvalidArgumentError(absl::Substitute(
+          "unsupported C2PA box version ($0)", header.version));
+    }
+    if (header.flags != kZeroFlags) {
+      return absl::InvalidArgumentError(
+          absl::Substitute("unsupported C2PA box flags ($0)",
+                           absl::BytesToHexString(header.flags)));
+    }
+
+    ABSL_ASSIGN_OR_RETURN(auto purpose, ReadPurpose(input));
+    if (purpose == "manifest") {
+      if (c2pa_boxes.manifest.has_value()) {
+        return absl::NotFoundError("multiple manifest boxes found");
+      }
+      c2pa_boxes.manifest = header;
+      return absl::OkStatus();
+    }
+    if (purpose == "original" || purpose == "update") {
+      return absl::InvalidArgumentError(
+          absl::Substitute("unsupported C2PA metadata purpose ($0)", purpose));
+    }
+    return absl::OkStatus();
+  }
+
+  return absl::OkStatus();
+}
+
+absl::StatusOr<Result> ValidateLocationAndExtractManifest(
+    riegeli::Reader& input, const BmffC2paBoxLocations& c2pa_boxes) {
+  if (!c2pa_boxes.manifest.has_value()) {
+    return absl::NotFoundError("No manifest store found");
+  }
+
+  // Box must be after ftyp.
+  if (c2pa_boxes.ftyp_box.has_value() &&
+      c2pa_boxes.ftyp_box->start > c2pa_boxes.manifest->start) {
+    return absl::NotFoundError(
+        "No manifest store found; manifest resides before ftyp");
+  }
+  // Box must be before moov.
+  if (c2pa_boxes.moov_box.has_value() &&
+      c2pa_boxes.moov_box->start < c2pa_boxes.manifest->start) {
+    return absl::NotFoundError(
+        "No manifest store found; manifest resides after moov");
+  }
+  // Box must be before mdat.
+  if (c2pa_boxes.mdat_box.has_value() &&
+      c2pa_boxes.mdat_box->start < c2pa_boxes.manifest->start) {
+    return absl::NotFoundError(
+        "No manifest store found; manifest resides after mdat");
+  }
+
+  ABSL_ASSIGN_OR_RETURN(
+      auto manifest,
+      ReadC2paBoxPayload(input, *c2pa_boxes.manifest, "manifest"));
+  if (manifest.empty()) {
+    return absl::NotFoundError(
+        "No manifest store found; manifest store is empty");
+  }
+  return Result{.location = {.offset = c2pa_boxes.manifest->start,
+                             .length = c2pa_boxes.manifest->box_size},
+                .manifest = std::move(manifest)};
+}
+
 absl::StatusOr<Result> ExtractManifest(riegeli::Reader& input,
                                        int64_t end_offset) {
-  Result result{
-      .location{.offset = 0, .length = 0},
-      .manifest = "",
-  };
-
   if (!input.SupportsSize() || !input.Size().has_value()) {
     return absl::InvalidArgumentError(
         "manifest store not embedded: reader size cannot be determined");
@@ -67,91 +198,35 @@
   uint64_t eof_offset =
       end_offset < 0 ? *input.Size() : static_cast<uint64_t>(end_offset);
 
-  absl::Status iteration_status = IterateOverBmffBoxes(
+  BmffC2paBoxLocations c2pa_boxes;
+  auto status = IterateOverBmffBoxes(
       input,
-      [&input, &eof_offset,
-       &result](const BmffBoxHeader& header) -> absl::StatusOr<bool> {
-        // Validate that the end of the current box is within the end offset.
-        if (header.start + header.box_size > eof_offset) {
-          return absl::DataLossError("kUnexpectedEof; truncated BMFF box");
-        }
+      [&input, eof_offset,
+       &c2pa_boxes](const BmffBoxHeader& header) -> absl::StatusOr<bool> {
         uint64_t box_end = header.start + header.box_size;
 
-        if (header.type == "mdat" || header.type == "moov") {
+        if (box_end > eof_offset) {
+          return absl::DataLossError("kUnexpectedEof; truncated BMFF box");
+        }
+
+        ABSL_RETURN_IF_ERROR(RecordHeadersForC2pa(input, header, c2pa_boxes));
+
+        if (box_end == eof_offset) {
           return false;  // Terminate loop
         }
 
-        if (header.type != "uuid" || header.user_type != kC2paBmffBoxUuid) {
-          // Skip irrelevant box.
-          if (!input.Seek(box_end)) {
-            return input.StatusOrAnnotate(
-                absl::InternalError("failed to seek to the end of the atom"));
-          }
-          return true;  // Continue
-        }
-
-        // C2PA box
-        if (!result.manifest.empty()) {
-          return absl::NotFoundError("Multiple manifest stores found");
-        }
-        if (header.version != 0) {
-          return absl::InvalidArgumentError(absl::Substitute(
-              "unsupported C2PA box version ($0)", header.version));
-        }
-        if (header.flags != kZeroFlags) {
-          return absl::InvalidArgumentError(
-              absl::Substitute("unsupported C2PA box flags ($0)",
-                               absl::BytesToHexString(header.flags)));
-        }
-
-        ABSL_ASSIGN_OR_RETURN(auto purpose, ReadPurpose(input));
-        if (purpose != "manifest") {
-          // Ignore non-manifest C2PA boxes.
-          if (!input.Seek(box_end)) {
-            return input.StatusOrAnnotate(
-                absl::InternalError("failed to seek to the end of the atom"));
-          }
-          return true;  // Continue
-        }
-
-        // Skip the merkle offset.
-        if (!input.Skip(sizeof(uint64_t))) {
-          return input.StatusOrAnnotate(
-              absl::DataLossError("kUnexpectedEof; merkle offset"));
-        }
-
-        if (box_end < input.pos()) {
-          return input.StatusOrAnnotate(
-              absl::DataLossError("kUnexpectedEof; payload"));
-        }
-
-        uint64_t payload_size = box_end - input.pos();
-        if (payload_size > kMaxPayloadSize) {
-          return absl::InvalidArgumentError(absl::Substitute(
-              "BMFF C2PA box is too large to extract ($0 > $1)", payload_size,
-              kMaxPayloadSize));
-        }
-        result.location = {.offset = header.start,
-                           .length = box_end - header.start};
-        if (!input.Read(payload_size, result.manifest)) {
-          return input.StatusOrAnnotate(
-              absl::InternalError("failed to read payload"));
-        }
-
         if (!input.Seek(box_end)) {
           return input.StatusOrAnnotate(
               absl::InternalError("failed to seek to the end of the atom"));
         }
         return true;  // Continue
       });
-  if (!iteration_status.ok()) {
+  if (!status.ok()) {
     return absl::NotFoundError(
-        absl::StrCat("No manifest store found; ", iteration_status.message()));
+        absl::StrCat("No manifest store found; ", status.message()));
   }
-  if (result.manifest.empty()) {
-    return absl::NotFoundError("No manifest store found");
-  }
-  return result;
+
+  return ValidateLocationAndExtractManifest(input, c2pa_boxes);
 }
 
 }  // namespace
diff --git a/formats/bmff/extractor_test.cc b/formats/bmff/extractor_test.cc
index 5672bef..398122e 100644
--- a/formats/bmff/extractor_test.cc
+++ b/formats/bmff/extractor_test.cc
@@ -35,6 +35,7 @@
 #include "formats/extractor_result.h"
 #include "gmock/gmock.h"
 #include "gtest/gtest.h"
+#include "riegeli/base/types.h"
 #include "riegeli/bytes/string_reader.h"
 #include "testing/jumbf_utils.h"
 #include "testing/test_string_utils.h"
@@ -42,12 +43,15 @@
 namespace credentio {
 namespace {
 
+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::C2paBoxPayload;
+using ::credentio_testing::C2paUuidBox;
 using ::credentio_testing::ManifestBigBox;
 using ::credentio_testing::ManifestBox;
 using ::credentio_testing::Uint32Str;
@@ -55,6 +59,7 @@
 using ::credentio_testing::UuidBoxPayload;
 using ::testing::Eq;
 using ::testing::HasSubstr;
+using ::testing::Optional;
 
 constexpr int64_t kMaxChunkBytes = 10 * 1024 * 1024;  // 2 MiB
 
@@ -110,21 +115,293 @@
                                      BigBox("zxcv", "whatever")),
             .result = absl::NotFoundError("No manifest store found"),
         },
+        // --- Manifest box placement. ---
         TestCase{
-            .name = "C2paBoxAfterMoovIgnored",
-            .contents = absl::StrCat(Box("blah", "irrelevant content"),
-                                     Box("moov", "also irrelevant"),
-                                     Box("uuid", absl::StrCat(kC2paBmffBoxUuid,
-                                                              "foo"))),
-            .result = absl::NotFoundError("No manifest store found"),
+            .name = "ManifestBetweenFtypAndMoov",
+            .contents = absl::StrCat(
+                Box("ftyp", "isomisom"), C2paUuidBox("foo"),
+                Box("moov", "movie metadata"), Box("mdat", "media data")),
+            .result = ExtractorResult{.manifest_store = "foo",
+                                      .asset_byte_info =
+                                          {.manifest_store_location =
+                                               ByteRange{.offset = 16,
+                                                         .length = 48}}},
         },
         TestCase{
-            .name = "C2paBoxAfterMdatIgnored",
-            .contents = absl::StrCat(Box("blah", "irrelevant content"),
-                                     Box("mdat", "also irrelevant"),
-                                     Box("uuid", absl::StrCat(kC2paBmffBoxUuid,
-                                                              "foo"))),
-            .result = absl::NotFoundError("No manifest store found"),
+            .name = "ManifestBetweenFtypAndMdat",
+            .contents = absl::StrCat(
+                Box("ftyp", "isomisom"), C2paUuidBox("foo"),
+                Box("mdat", "media data"), Box("moov", "movie metadata")),
+            .result =
+                ExtractorResult{
+                    .manifest_store = "foo",
+                    .asset_byte_info = {.manifest_store_location =
+                                            ByteRange{
+                                                .offset = 16, .length = 48}}},
+        },
+        TestCase{
+            .name = "ManifestAfterFreeBeforeMoov",
+            .contents = absl::StrCat(Box("ftyp", "isomisom"),
+                                     Box("free", "padding"), C2paUuidBox("foo"),
+                                     Box("moov", "movie metadata"),
+                                     Box("mdat", "media data")),
+            .result =
+                ExtractorResult{
+                    .manifest_store = "foo",
+                    .asset_byte_info = {.manifest_store_location =
+                                            ByteRange{
+                                                .offset = 31, .length = 48}}},
+        },
+        TestCase{
+            .name = "ManifestWithoutFtyp",
+            .contents = absl::StrCat(C2paUuidBox("foo"),
+                                     Box("moov", "movie metadata"),
+                                     Box("mdat", "media data")),
+            .result =
+                ExtractorResult{.manifest_store = "foo",
+                                .asset_byte_info =
+                                    {.manifest_store_location =
+                                         ByteRange{.offset = 0, .length = 48}}},
+        },
+        TestCase{
+            .name = "MerkleBoxAfterMdatIgnored",
+            .contents = absl::StrCat(
+                Box("ftyp", "isomisom"), C2paUuidBox("foo"),
+                Box("moov", "movie metadata"), Box("mdat", "media data"),
+                Box("uuid", C2paBoxPayload("merkle", "merkle data"))),
+            .result =
+                ExtractorResult{
+                    .manifest_store = "foo",
+                    .asset_byte_info = {.manifest_store_location =
+                                            ByteRange{
+                                                .offset = 16, .length = 48}}},
+        },
+        TestCase{
+            .name = "UnknownC2paPurposeAfterMdatIgnored",
+            .contents = absl::StrCat(
+                Box("ftyp", "isomisom"), C2paUuidBox("foo"),
+                Box("mdat", "media data"), C2paUuidBox("bar", "unknown")),
+            .result =
+                ExtractorResult{
+                    .manifest_store = "foo",
+                    .asset_byte_info = {.manifest_store_location =
+                                            ByteRange{
+                                                .offset = 16, .length = 48}}},
+        },
+        TestCase{
+            .name = "NonC2paUuidBoxAfterMdatIgnored",
+            .contents = absl::StrCat(Box("ftyp", "isomisom"),
+                                     C2paUuidBox("foo"),
+                                     Box("mdat", "media data"),
+                                     Box("uuid", "abcdefghijklmnop data")),
+            .result =
+                ExtractorResult{
+                    .manifest_store = "foo",
+                    .asset_byte_info = {.manifest_store_location =
+                                            ByteRange{
+                                                .offset = 16, .length = 48}}},
+        },
+        TestCase{
+            .name = "ManifestBeforeFtyp",
+            .contents = absl::StrCat(
+                C2paUuidBox("foo"), Box("ftyp", "isomisom"),
+                Box("moov", "movie metadata"), Box("mdat", "media data")),
+            .result = absl::NotFoundError("manifest resides before ftyp"),
+        },
+        TestCase{
+            .name = "ManifestAfterMoov",
+            .contents = absl::StrCat(
+                Box("ftyp", "isomisom"), Box("moov", "movie metadata"),
+                C2paUuidBox("foo"), Box("mdat", "media data")),
+            .result = absl::NotFoundError("manifest resides after moov"),
+        },
+        TestCase{
+            .name = "ManifestAfterMdat",
+            .contents = absl::StrCat(Box("ftyp", "isomisom"),
+                                     Box("mdat", "media data"),
+                                     C2paUuidBox("foo")),
+            .result = absl::NotFoundError("manifest resides after mdat"),
+        },
+        TestCase{
+            .name = "ManifestBetweenMdatAndMoov",
+            .contents =
+                absl::StrCat(Box("ftyp", "isomisom"), Box("mdat", "media data"),
+                             C2paUuidBox("foo"), Box("moov", "movie metadata")),
+            .result = absl::NotFoundError("manifest resides after mdat"),
+        },
+        TestCase{
+            .name = "ManifestAfterMoovWithoutFtyp",
+            .contents = absl::StrCat(Box("moov", "movie metadata"),
+                                     C2paUuidBox("foo")),
+            .result = absl::NotFoundError("manifest resides after moov"),
+        },
+
+        // --- Whole-file scanning. ---
+        TestCase{
+            .name = "SecondManifestAfterMdat",
+            .contents =
+                absl::StrCat(Box("ftyp", "isomisom"), C2paUuidBox("foo"),
+                             Box("mdat", "media data"), C2paUuidBox("bar")),
+            .result = absl::NotFoundError("multiple manifest boxes found"),
+        },
+        TestCase{
+            .name = "TruncatedBoxAfterMdat",
+            .contents = absl::StrCat(
+                Box("ftyp", "isomisom"), C2paUuidBox("foo"),
+                Box("mdat", "media data"), BadBox("free", "padding", 16)),
+            .result = absl::NotFoundError("truncated BMFF box"),
+        },
+        TestCase{
+            .name = "MalformedC2paBoxAfterMoov",
+            .contents = absl::StrCat(
+                Box("ftyp", "isomisom"), C2paUuidBox("foo"),
+                Box("moov", "movie metadata"),
+                Box("uuid", UuidBoxPayload(kC2paBmffBoxUuid, BoxVersion(1),
+                                           BoxFlags(0), ""))),
+            .result = absl::NotFoundError("unsupported C2PA box version"),
+        },
+
+        // --- Manifest payload bounds. ---
+        TestCase{
+            .name = "EmptyManifestPayload",
+            .contents = absl::StrCat(Box("ftyp", "isomisom"), C2paUuidBox(""),
+                                     Box("mdat", "media data")),
+            .result = absl::NotFoundError("manifest store is empty"),
+        },
+        // Regression: a manifest box too small for its merkle offset followed
+        // by another box used to underflow the payload size.
+        TestCase{
+            .name = "ManifestTooSmallForMerkleOffsetFollowedByBox",
+            .contents = absl::StrCat(Box("uuid",
+                                         C2paBoxPayload("manifest", "abc")),
+                                     Box("mdat", "media data")),
+            .result = absl::NotFoundError("kUnexpectedEof"),
+        },
+
+        // --- Manifest vs. original/update coexistence. ---
+        TestCase{
+            .name = "ManifestAndOriginalCoexist",
+            .contents = absl::StrCat(
+                Box("ftyp", "isomisom"), C2paUuidBox("foo"),
+                C2paUuidBox("bar", "original"), Box("mdat", "media data")),
+            .result = absl::NotFoundError(
+                "unsupported C2PA metadata purpose (original)"),
+        },
+        TestCase{
+            .name = "ManifestAndUpdateCoexist",
+            .contents =
+                absl::StrCat(Box("ftyp", "isomisom"), C2paUuidBox("foo"),
+                             Box("mdat", "media data"),
+                             Box("uuid", C2paBoxPayload("update", "bar"))),
+            .result = absl::NotFoundError(
+                "unsupported C2PA metadata purpose (update)"),
+        },
+        TestCase{
+            .name = "ManifestOriginalAndUpdateCoexist",
+            .contents = absl::StrCat(
+                Box("ftyp", "isomisom"), C2paUuidBox("foo", "original"),
+                C2paUuidBox("bar"), Box("mdat", "media data"),
+                Box("uuid", C2paBoxPayload("update", "baz"))),
+            .result = absl::NotFoundError(
+                "unsupported C2PA metadata purpose (original)"),
+        },
+
+        // --- Original + update boxes. ---
+        TestCase{
+            .name = "OriginalAndUpdateUnsupported",
+            .contents = absl::StrCat(
+                Box("ftyp", "isomisom"), C2paUuidBox("orig", "original"),
+                Box("moov", "movie metadata"), Box("mdat", "media data"),
+                Box("uuid", C2paBoxPayload("update", "upd"))),
+            .result = absl::NotFoundError(
+                "unsupported C2PA metadata purpose (original)"),
+        },
+        TestCase{
+            .name = "OriginalDirectlyFollowedByUpdateUnsupported",
+            .contents = absl::StrCat(C2paUuidBox("orig", "original"),
+                                     Box("uuid",
+                                         C2paBoxPayload("update", "upd"))),
+            .result = absl::NotFoundError(
+                "unsupported C2PA metadata purpose (original)"),
+        },
+        TestCase{
+            .name = "OriginalAndBigBoxUpdateUnsupported",
+            .contents = absl::StrCat(
+                Box("ftyp", "isomisom"), C2paUuidBox("orig", "original"),
+                Box("mdat", "media data"),
+                BigBox("uuid", C2paBoxPayload("update", "upd"))),
+            .result = absl::NotFoundError(
+                "unsupported C2PA metadata purpose (original)"),
+        },
+        TestCase{
+            .name = "OriginalWithoutUpdate",
+            .contents = absl::StrCat(Box("ftyp", "isomisom"),
+                                     C2paUuidBox("orig", "original"),
+                                     Box("mdat", "media data")),
+            .result = absl::NotFoundError(
+                "unsupported C2PA metadata purpose (original)"),
+        },
+        TestCase{
+            .name = "UpdateWithoutOriginal",
+            .contents =
+                absl::StrCat(Box("ftyp", "isomisom"), Box("mdat", "media data"),
+                             Box("uuid", C2paBoxPayload("update", "upd"))),
+            .result = absl::NotFoundError(
+                "unsupported C2PA metadata purpose (update)"),
+        },
+        TestCase{
+            .name = "UpdateNotLastBox",
+            .contents = absl::StrCat(
+                Box("ftyp", "isomisom"), C2paUuidBox("orig", "original"),
+                Box("mdat", "media data"),
+                Box("uuid", C2paBoxPayload("update", "upd")),
+                Box("free", "padding")),
+            .result = absl::NotFoundError(
+                "unsupported C2PA metadata purpose (original)"),
+        },
+        TestCase{
+            .name = "OriginalBeforeFtyp",
+            .contents =
+                absl::StrCat(C2paUuidBox("orig", "original"),
+                             Box("ftyp", "isomisom"), Box("mdat", "media data"),
+                             Box("uuid", C2paBoxPayload("update", "upd"))),
+            .result = absl::NotFoundError(
+                "unsupported C2PA metadata purpose (original)"),
+        },
+        TestCase{
+            .name = "OriginalAfterMoov",
+            .contents = absl::StrCat(
+                Box("ftyp", "isomisom"), Box("moov", "movie metadata"),
+                Box("free", "padding"), C2paUuidBox("orig", "original"),
+                Box("uuid", C2paBoxPayload("update", "upd"))),
+            .result = absl::NotFoundError(
+                "unsupported C2PA metadata purpose (original)"),
+        },
+        TestCase{
+            .name = "OriginalAfterMdat",
+            .contents =
+                absl::StrCat(Box("ftyp", "isomisom"), Box("mdat", "media data"),
+                             C2paUuidBox("orig", "original"),
+                             Box("uuid", C2paBoxPayload("update", "upd"))),
+            .result = absl::NotFoundError(
+                "unsupported C2PA metadata purpose (original)"),
+        },
+        TestCase{
+            .name = "MultipleOriginalBoxes",
+            .contents = absl::StrCat(
+                C2paUuidBox("a", "original"), C2paUuidBox("b", "original"),
+                Box("uuid", C2paBoxPayload("update", "upd"))),
+            .result = absl::NotFoundError(
+                "unsupported C2PA metadata purpose (original)"),
+        },
+        TestCase{
+            .name = "MultipleUpdateBoxes",
+            .contents = absl::StrCat(C2paUuidBox("orig", "original"),
+                                     Box("uuid", C2paBoxPayload("update", "a")),
+                                     Box("uuid",
+                                         C2paBoxPayload("update", "b"))),
+            .result = absl::NotFoundError(
+                "unsupported C2PA metadata purpose (original)"),
         },
         TestCase{
             // Quoting
@@ -135,7 +412,7 @@
             .name = "MultipleC2paBoxesIgnored",
             .contents = absl::StrCat(ManifestBox(/*merkle_offset=*/0, "foo"),
                                      ManifestBox(/*merkle_offset=*/0, "bar")),
-            .result = absl::NotFoundError("Multiple manifest stores found"),
+            .result = absl::NotFoundError("multiple manifest boxes found"),
         },
         TestCase{
             .name = "NonC2paUuidBoxIgnored",
@@ -294,5 +571,99 @@
       CreateStartOfManifestStorePayload()));
 }
 
+TEST(BmffExtractorTest, EndOffsetStopsScanningBeforeTrailingData) {
+  const std::string media = absl::StrCat(
+      Box("ftyp", "isomisom"), C2paUuidBox("foo"), Box("mdat", "media data"));
+  // Data past `end_offset` would otherwise fail extraction.
+  const std::string contents =
+      absl::StrCat(media, BadBox("free", "padding", 16), C2paUuidBox("bar"));
+  riegeli::StringReader<> input(contents);
+
+  EXPECT_THAT(
+      BmffExtractor().ExtractManifestStoreLocation(
+          input, {.end_offset = static_cast<int64_t>(media.size())}),
+      IsOkAndHolds(Optional(Eq(ByteRange{.offset = 16, .length = 48}))));
+}
+
+TEST(BmffExtractorTest, EndOffsetWithoutLimitScansWholeFile) {
+  const std::string contents =
+      absl::StrCat(Box("ftyp", "isomisom"), C2paUuidBox("foo"),
+                   Box("mdat", "media data"), BadBox("free", "padding", 16));
+  riegeli::StringReader<> input(contents);
+
+  EXPECT_THAT(
+      BmffExtractor().ExtractManifestStoreLocation(input, {}),
+      StatusIs(absl::StatusCode::kNotFound, HasSubstr("truncated BMFF box")));
+}
+
+TEST(BmffExtractorTest, EndOffsetInsideBoxFails) {
+  const std::string contents = absl::StrCat(
+      Box("ftyp", "isomisom"), C2paUuidBox("foo"), Box("mdat", "media data"));
+  riegeli::StringReader<> input(contents);
+
+  EXPECT_THAT(
+      BmffExtractor().ExtractManifestStoreLocation(
+          input, {.end_offset = static_cast<int64_t>(contents.size() - 1)}),
+      StatusIs(absl::StatusCode::kNotFound, HasSubstr("truncated BMFF box")));
+}
+
+TEST(BmffExtractorTest, UpdateBoxEndingAtEndOffsetIsLast) {
+  const std::string media =
+      absl::StrCat(C2paUuidBox("orig", "original"), Box("mdat", "media data"),
+                   Box("uuid", C2paBoxPayload("update", "upd")));
+  const std::string contents = absl::StrCat(media, Box("free", "padding"));
+  riegeli::StringReader<> input(contents);
+
+  EXPECT_THAT(BmffExtractor().ExtractManifestStoreLocation(
+                  input, {.end_offset = static_cast<int64_t>(media.size())}),
+              StatusIs(absl::StatusCode::kNotFound,
+                       HasSubstr("No manifest store found")));
+}
+
+TEST(BmffExtractorTest, LocationReturnsNulloptWhenC2paNotRequired) {
+  const std::string contents = absl::StrCat(
+      Box("ftyp", "isomisom"), Box("mdat", "media data"), C2paUuidBox("foo"));
+  riegeli::StringReader<> input(contents);
+
+  EXPECT_THAT(BmffExtractor().ExtractManifestStoreLocation(
+                  input, {.requires_c2pa = false}),
+              IsOkAndHolds(Eq(std::nullopt)));
+}
+
+class NoSizeStringReader : public riegeli::StringReader<> {
+ public:
+  using riegeli::StringReader<>::StringReader;
+  bool SupportsSize() override { return false; }
+};
+
+class SizeNotValuedStringReader : public riegeli::StringReader<> {
+ public:
+  using riegeli::StringReader<>::StringReader;
+  bool SupportsSize() override { return true; }
+
+ protected:
+  std::optional<riegeli::Position> SizeImpl() override { return std::nullopt; }
+};
+
+TEST(BmffExtractorTest, ReaderWithoutSizeFails) {
+  const std::string contents = absl::StrCat(
+      Box("ftyp", "isomisom"), C2paUuidBox("foo"), Box("mdat", "media data"));
+  NoSizeStringReader input(contents);
+
+  EXPECT_THAT(BmffExtractor().ExtractManifestStore(input),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("reader size cannot be determined")));
+}
+
+TEST(BmffExtractorTest, ReaderWithUnknownSizeFails) {
+  const std::string contents = absl::StrCat(
+      Box("ftyp", "isomisom"), C2paUuidBox("foo"), Box("mdat", "media data"));
+  SizeNotValuedStringReader input(contents);
+
+  EXPECT_THAT(BmffExtractor().ExtractManifestStore(input),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("reader size cannot be determined")));
+}
+
 }  // namespace
 }  // namespace credentio
diff --git a/formats/bmff/offset_adjustment.cc b/formats/bmff/offset_adjustment.cc
index 749c86e..0267541 100644
--- a/formats/bmff/offset_adjustment.cc
+++ b/formats/bmff/offset_adjustment.cc
@@ -15,7 +15,9 @@
 
 #include "formats/bmff/offset_adjustment.h"
 
+#include <algorithm>
 #include <cstdint>
+#include <optional>
 #include <string>
 #include <vector>
 
@@ -24,6 +26,7 @@
 #include "absl/status/statusor.h"
 #include "absl/strings/str_cat.h"
 #include "formats/bmff/box_header.h"
+#include "riegeli/base/types.h"
 #include "riegeli/bytes/reader.h"
 #include "riegeli/bytes/writer.h"
 #include "riegeli/endian/endian_reading.h"
@@ -33,6 +36,11 @@
 
 namespace {
 
+// Caps upfront `.reserve()` at 2,000,000 entries (~8 MB for `stco`, ~16 MB for
+// `co64`/`saio`, and up to ~80 MB for v2 `iloc` items; `iloc` `extent_count` is
+// uint16_t, so at most ~1.6 MB per item).
+constexpr uint64_t kMaxEntryCountToReserve = 2000000;
+
 absl::StatusOr<uint64_t> ReadVariableSize(riegeli::Reader& reader,
                                           uint8_t size) {
   if (size == 0) return 0;
@@ -75,6 +83,33 @@
   return absl::InvalidArgumentError(absl::StrCat("invalid byte_size: ", size));
 }
 
+// Returns how many of `count` entries can be reserved up front (capped at
+// `kMaxEntryCountToReserve`), or an error if `reader` does not have enough data
+// left for `count` entries of at least `min_entry_size` bytes each. `count` is
+// read from the input, so reserving it unchecked lets a few bytes of input
+// allocate gigabytes of memory.
+//
+// Returns 0 if the size of `reader` is unknown, in which case entries should be
+// added as they are read.
+absl::StatusOr<uint64_t> EntriesToReserve(riegeli::Reader& reader,
+                                          uint64_t count,
+                                          uint64_t min_entry_size) {
+  if (!reader.SupportsSize()) {
+    return 0;
+  }
+  const std::optional<riegeli::Position> size = reader.Size();
+  if (!size.has_value()) {
+    return 0;
+  }
+  const riegeli::Position remaining =
+      *size > reader.pos() ? *size - reader.pos() : 0;
+  if (count > remaining / min_entry_size) {
+    return absl::DataLossError(absl::StrCat(
+        "entry count ", count, " exceeds the remaining ", remaining, " bytes"));
+  }
+  return std::min(count, kMaxEntryCountToReserve);
+}
+
 }  // namespace
 
 absl::StatusOr<IlocBox> ParseIloc(riegeli::Reader& reader,
@@ -107,7 +142,18 @@
     }
   }
 
-  box.items.reserve(item_count);
+  // item_ID, [reserved and construction_method], data_reference_index,
+  // base_offset, and extent_count.
+  const uint64_t min_item_size =
+      (header.version == 2 ? 4 : 2) +
+      (header.version == 1 || header.version == 2 ? 2 : 0) + 2 +
+      box.base_offset_size + 2;
+  ABSL_ASSIGN_OR_RETURN(uint64_t items_to_reserve,
+                        EntriesToReserve(reader, item_count, min_item_size));
+  box.items.reserve(items_to_reserve);
+  const uint64_t extent_size =
+      (header.version == 1 || header.version == 2 ? box.index_size : 0) +
+      box.offset_size + box.length_size;
   for (uint32_t i = 0; i < item_count; ++i) {
     IlocItem item;
     if (header.version < 2) {
@@ -141,7 +187,20 @@
       return absl::DataLossError("Failed to read extent count");
     }
 
-    item.extents.reserve(extent_count);
+    // With all extent fields absent, an extent occupies no bytes and refers to
+    // the entire referenced container, so an item cannot be fragmented into
+    // multiple extents.
+    uint64_t extents_to_reserve = extent_count;
+    if (extent_size > 0) {
+      ABSL_ASSIGN_OR_RETURN(
+          extents_to_reserve,
+          EntriesToReserve(reader, extent_count, extent_size));
+    } else if (extent_count > 1) {
+      return absl::DataLossError(absl::StrCat("iloc item ", item.item_id,
+                                              " has ", extent_count,
+                                              " extents of size 0"));
+    }
+    item.extents.reserve(extents_to_reserve);
     for (int j = 0; j < extent_count; ++j) {
       IlocExtent extent;
       if ((header.version == 1 || header.version == 2) && box.index_size > 0) {
@@ -265,7 +324,10 @@
   if (!riegeli::ReadBigEndian<uint32_t>(reader, entry_count)) {
     return absl::DataLossError("Failed to read entry count");
   }
-  box.offsets.reserve(entry_count);
+  ABSL_ASSIGN_OR_RETURN(
+      uint64_t entries_to_reserve,
+      EntriesToReserve(reader, entry_count, sizeof(uint32_t)));
+  box.offsets.reserve(entries_to_reserve);
   for (uint32_t i = 0; i < entry_count; ++i) {
     uint32_t offset;
     if (!riegeli::ReadBigEndian<uint32_t>(reader, offset)) {
@@ -304,7 +366,10 @@
   if (!riegeli::ReadBigEndian<uint32_t>(reader, entry_count)) {
     return absl::DataLossError("Failed to read entry count");
   }
-  box.offsets.reserve(entry_count);
+  ABSL_ASSIGN_OR_RETURN(
+      uint64_t entries_to_reserve,
+      EntriesToReserve(reader, entry_count, sizeof(uint64_t)));
+  box.offsets.reserve(entries_to_reserve);
   for (uint32_t i = 0; i < entry_count; ++i) {
     uint64_t offset;
     if (!riegeli::ReadBigEndian<uint64_t>(reader, offset)) {
@@ -355,7 +420,12 @@
     return absl::DataLossError("Failed to read entry count");
   }
 
-  box.offsets.reserve(entry_count);
+  ABSL_ASSIGN_OR_RETURN(
+      uint64_t entries_to_reserve,
+      EntriesToReserve(
+          reader, entry_count,
+          header.version == 0 ? sizeof(uint32_t) : sizeof(uint64_t)));
+  box.offsets.reserve(entries_to_reserve);
   for (uint32_t i = 0; i < entry_count; ++i) {
     if (header.version == 0) {
       uint32_t offset;
diff --git a/formats/bmff/offset_adjustment_test.cc b/formats/bmff/offset_adjustment_test.cc
index eb512bc..09d8ae4 100644
--- a/formats/bmff/offset_adjustment_test.cc
+++ b/formats/bmff/offset_adjustment_test.cc
@@ -15,15 +15,19 @@
 
 #include "formats/bmff/offset_adjustment.h"
 
+#include <cstdint>
+#include <optional>
 #include <string>
 #include <vector>
 
 #include "absl/status/status.h"
 #include "absl/status/status_matchers.h"
 #include "absl/strings/str_cat.h"
+#include "absl/strings/string_view.h"
 #include "formats/bmff/box_header.h"
 #include "gmock/gmock.h"
 #include "gtest/gtest.h"
+#include "riegeli/base/types.h"
 #include "riegeli/bytes/string_reader.h"
 #include "riegeli/bytes/string_writer.h"
 #include "testing/test_string_utils.h"
@@ -39,6 +43,37 @@
 using ::testing::ElementsAre;
 using ::testing::HasSubstr;
 
+class NoSizeStringReader : public riegeli::StringReader<> {
+ public:
+  using riegeli::StringReader<>::StringReader;
+  bool SupportsSize() override { return false; }
+};
+
+class SizeNotValuedStringReader : public riegeli::StringReader<> {
+ public:
+  using riegeli::StringReader<>::StringReader;
+  bool SupportsSize() override { return true; }
+
+ protected:
+  std::optional<riegeli::Position> SizeImpl() override { return std::nullopt; }
+};
+
+class CustomSizeStringReader : public riegeli::StringReader<> {
+ public:
+  explicit CustomSizeStringReader(absl::string_view src,
+                                  riegeli::Position reported_size)
+      : riegeli::StringReader<>(src), reported_size_(reported_size) {}
+  bool SupportsSize() override { return true; }
+
+ protected:
+  std::optional<riegeli::Position> SizeImpl() override {
+    return reported_size_;
+  }
+
+ private:
+  riegeli::Position reported_size_;
+};
+
 TEST(OffsetAdjustmentTest, Stco) {
   std::string data = absl::StrCat(Uint32Str(2),    // entry_count
                                   Uint32Str(100),  // offset 1
@@ -506,5 +541,269 @@
   writer.Close();
 }
 
+// The following tests use counts that exceed the remaining data. They must fail
+// before reserving memory for the declared entries, which would otherwise take
+// gigabytes.
+
+TEST(OffsetAdjustmentTest, StcoEntryCountExceedsData) {
+  std::string data = absl::StrCat(Uint32Str(0xffffffff), Uint32Str(100));
+  riegeli::StringReader reader(data);
+  EXPECT_THAT(ParseStco(reader),
+              StatusIs(absl::StatusCode::kDataLoss,
+                       HasSubstr("entry count 4294967295 exceeds the "
+                                 "remaining 4 bytes")));
+}
+
+TEST(OffsetAdjustmentTest, Co64EntryCountExceedsData) {
+  // Two entries need 16 bytes, but only 15 remain.
+  std::string data =
+      absl::StrCat(Uint32Str(2), Uint64Str(100), std::string(7, '\0'));
+  riegeli::StringReader reader(data);
+  EXPECT_THAT(ParseCo64(reader),
+              StatusIs(absl::StatusCode::kDataLoss,
+                       HasSubstr("entry count 2 exceeds the remaining 15 "
+                                 "bytes")));
+}
+
+TEST(OffsetAdjustmentTest, SaioEntryCountExceedsData) {
+  BmffBoxHeader header;
+  header.version = 1;
+  header.flags = std::string("\x00\x00\x00", 3);
+  std::string data = absl::StrCat(Uint32Str(0xffffffff), Uint64Str(100));
+  riegeli::StringReader reader(data);
+  EXPECT_THAT(ParseSaio(reader, header),
+              StatusIs(absl::StatusCode::kDataLoss,
+                       HasSubstr("entry count 4294967295 exceeds the "
+                                 "remaining 8 bytes")));
+}
+
+TEST(OffsetAdjustmentTest, SaioVersion0EntryCountExceedsData) {
+  // Two version 0 entries need 8 bytes, but only 7 remain.
+  BmffBoxHeader header;
+  header.version = 0;
+  header.flags = std::string("\x00\x00\x00", 3);
+  std::string data =
+      absl::StrCat(Uint32Str(2), Uint32Str(100), std::string(3, '\0'));
+  riegeli::StringReader reader(data);
+  EXPECT_THAT(ParseSaio(reader, header),
+              StatusIs(absl::StatusCode::kDataLoss,
+                       HasSubstr("entry count 2 exceeds the remaining 7 "
+                                 "bytes")));
+}
+
+TEST(OffsetAdjustmentTest, IlocItemCountExceedsData) {
+  BmffBoxHeader header;
+  header.version = 2;
+  std::string data = absl::StrCat(Uint8Str(0x44),  // offset/length size
+                                  Uint8Str(0x00),  // base offset/index size
+                                  Uint32Str(0xffffffff),  // item_count
+                                  Uint32Str(1));          // item_ID
+  riegeli::StringReader reader(data);
+  EXPECT_THAT(ParseIloc(reader, header),
+              StatusIs(absl::StatusCode::kDataLoss,
+                       HasSubstr("entry count 4294967295 exceeds the "
+                                 "remaining 4 bytes")));
+}
+
+TEST(OffsetAdjustmentTest, IlocExtentCountExceedsData) {
+  BmffBoxHeader header;
+  header.version = 0;
+  std::string data = absl::StrCat(Uint8Str(0x44),     // offset/length size
+                                  Uint8Str(0x00),     // base offset/index size
+                                  Uint16Str(1),       // item_count
+                                  Uint16Str(1),       // item_ID
+                                  Uint16Str(0),       // data_reference_index
+                                  Uint16Str(0xffff),  // extent_count
+                                  Uint32Str(100),     // extent_offset
+                                  Uint32Str(20));     // extent_length
+  riegeli::StringReader reader(data);
+  EXPECT_THAT(ParseIloc(reader, header),
+              StatusIs(absl::StatusCode::kDataLoss,
+                       HasSubstr("entry count 65535 exceeds the remaining 8 "
+                                 "bytes")));
+}
+
+TEST(OffsetAdjustmentTest, IlocZeroSizeExtent) {
+  // With all extent fields absent, a single extent occupies no bytes and refers
+  // to the entire referenced container.
+  BmffBoxHeader header;
+  header.version = 0;
+  std::string data = absl::StrCat(Uint8Str(0x00),  // offset/length size
+                                  Uint8Str(0x00),  // base offset/index size
+                                  Uint16Str(1),    // item_count
+                                  Uint16Str(1),    // item_ID
+                                  Uint16Str(0),    // data_reference_index
+                                  Uint16Str(1));   // extent_count
+  riegeli::StringReader reader(data);
+  auto box_or = ParseIloc(reader, header);
+  ABSL_ASSERT_OK(box_or);
+  const IlocBox& box = *box_or;
+  ASSERT_EQ(box.items.size(), 1);
+  ASSERT_EQ(box.items[0].extents.size(), 1);
+  EXPECT_EQ(box.items[0].extents[0].offset, 0);
+  EXPECT_EQ(box.items[0].extents[0].length, 0);
+}
+
+TEST(OffsetAdjustmentTest, IlocZeroSizeMultipleExtents) {
+  BmffBoxHeader header;
+  header.version = 0;
+  std::string data = absl::StrCat(Uint8Str(0x00),  // offset/length size
+                                  Uint8Str(0x00),  // base offset/index size
+                                  Uint16Str(1),    // item_count
+                                  Uint16Str(1),    // item_ID
+                                  Uint16Str(0),    // data_reference_index
+                                  Uint16Str(3));   // extent_count
+  riegeli::StringReader reader(data);
+  EXPECT_THAT(ParseIloc(reader, header),
+              StatusIs(absl::StatusCode::kDataLoss,
+                       HasSubstr("iloc item 1 has 3 extents of size 0")));
+}
+
+// When the reader cannot report its size, counts cannot be checked up front, so
+// parsing proceeds entry by entry and fails at the end of the data.
+TEST(OffsetAdjustmentTest, Co64NoSizeReader) {
+  std::string data = absl::StrCat(Uint32Str(2), Uint64Str(100), Uint64Str(200));
+  NoSizeStringReader reader(data);
+  auto box_or = ParseCo64(reader);
+  ABSL_ASSERT_OK(box_or);
+  EXPECT_THAT(box_or->offsets, ElementsAre(100, 200));
+}
+
+TEST(OffsetAdjustmentTest, Co64NoSizeReaderEntryCountExceedsData) {
+  std::string data = absl::StrCat(Uint32Str(0xffffffff), Uint64Str(100));
+  NoSizeStringReader reader(data);
+  EXPECT_THAT(ParseCo64(reader), StatusIs(absl::StatusCode::kDataLoss,
+                                          HasSubstr("Failed to read offset")));
+}
+
+TEST(OffsetAdjustmentTest, Co64UnknownSizeEntryCountExceedsData) {
+  std::string data = absl::StrCat(Uint32Str(0xffffffff), Uint64Str(100));
+  SizeNotValuedStringReader reader(data);
+  EXPECT_THAT(ParseCo64(reader), StatusIs(absl::StatusCode::kDataLoss,
+                                          HasSubstr("Failed to read offset")));
+}
+
+TEST(OffsetAdjustmentTest, Co64ReportedSizeSmallerThanPos) {
+  std::string data = absl::StrCat(Uint32Str(1), Uint64Str(100));
+  CustomSizeStringReader reader(data, /*reported_size=*/0);
+  EXPECT_THAT(ParseCo64(reader),
+              StatusIs(absl::StatusCode::kDataLoss,
+                       HasSubstr("entry count 1 exceeds the remaining 0 "
+                                 "bytes")));
+}
+
+// When the reader reports a size large enough to satisfy the entry count check
+// (e.g. a sparse file or spoofed size), the initial reservation is still capped
+// at kMaxEntryCountToReserve rather than allocating for all declared entries.
+TEST(OffsetAdjustmentTest, StcoLargeReportedSizeCapsReserve) {
+  std::string data = absl::StrCat(Uint32Str(0xffffffff), Uint32Str(100));
+  CustomSizeStringReader reader(data, /*reported_size=*/uint64_t{1} << 40);
+  EXPECT_THAT(ParseStco(reader), StatusIs(absl::StatusCode::kDataLoss,
+                                          HasSubstr("Failed to read offset")));
+}
+
+TEST(OffsetAdjustmentTest, Co64LargeReportedSizeCapsReserve) {
+  std::string data = absl::StrCat(Uint32Str(0xffffffff), Uint64Str(100));
+  CustomSizeStringReader reader(data, /*reported_size=*/uint64_t{1} << 40);
+  EXPECT_THAT(ParseCo64(reader), StatusIs(absl::StatusCode::kDataLoss,
+                                          HasSubstr("Failed to read offset")));
+}
+
+TEST(OffsetAdjustmentTest, SaioLargeReportedSizeCapsReserve) {
+  BmffBoxHeader header;
+  header.version = 1;
+  header.flags = std::string("\x00\x00\x00", 3);
+  std::string data = absl::StrCat(Uint32Str(0xffffffff), Uint64Str(100));
+  CustomSizeStringReader reader(data, /*reported_size=*/uint64_t{1} << 40);
+  EXPECT_THAT(ParseSaio(reader, header),
+              StatusIs(absl::StatusCode::kDataLoss,
+                       HasSubstr("Failed to read offset")));
+}
+
+TEST(OffsetAdjustmentTest, IlocLargeReportedSizeCapsReserve) {
+  BmffBoxHeader header;
+  header.version = 2;
+  std::string data = absl::StrCat(Uint8Str(0x44),         // offset/length size
+                                  Uint8Str(0x00),         // base offset/index
+                                  Uint32Str(0xffffffff),  // item_count
+                                  Uint32Str(1));          // item_ID
+  CustomSizeStringReader reader(data, /*reported_size=*/uint64_t{1} << 40);
+  EXPECT_THAT(ParseIloc(reader, header), StatusIs(absl::StatusCode::kDataLoss,
+                                                  HasSubstr("Failed to read")));
+}
+
+// The following tests pin the minimum item and extent sizes for each iloc
+// version: the data holds enough bytes for the count only if the size is
+// underestimated.
+
+TEST(OffsetAdjustmentTest, IlocVersion2ItemCountUsesFourByteItemId) {
+  // A version 2 item has at least 10 bytes: item_ID (4),
+  // construction_method (2), data_reference_index (2), and extent_count (2).
+  BmffBoxHeader header;
+  header.version = 2;
+  std::string data = absl::StrCat(Uint8Str(0x44),  // offset/length size
+                                  Uint8Str(0x00),  // base offset/index size
+                                  Uint32Str(2),    // item_count
+                                  std::string(16, '\0'));
+  riegeli::StringReader reader(data);
+  EXPECT_THAT(ParseIloc(reader, header),
+              StatusIs(absl::StatusCode::kDataLoss,
+                       HasSubstr("entry count 2 exceeds the remaining 16 "
+                                 "bytes")));
+}
+
+TEST(OffsetAdjustmentTest, IlocVersion1ItemCountIncludesConstructionMethod) {
+  // A version 1 item has at least 8 bytes: item_ID (2),
+  // construction_method (2), data_reference_index (2), and extent_count (2).
+  BmffBoxHeader header;
+  header.version = 1;
+  std::string data = absl::StrCat(Uint8Str(0x44),  // offset/length size
+                                  Uint8Str(0x00),  // base offset/index size
+                                  Uint16Str(2),    // item_count
+                                  std::string(12, '\0'));
+  riegeli::StringReader reader(data);
+  EXPECT_THAT(ParseIloc(reader, header),
+              StatusIs(absl::StatusCode::kDataLoss,
+                       HasSubstr("entry count 2 exceeds the remaining 12 "
+                                 "bytes")));
+}
+
+TEST(OffsetAdjustmentTest, IlocItemCountIncludesBaseOffset) {
+  // A version 0 item with a 4 byte base_offset has at least 10 bytes:
+  // item_ID (2), data_reference_index (2), base_offset (4), and
+  // extent_count (2).
+  BmffBoxHeader header;
+  header.version = 0;
+  std::string data = absl::StrCat(Uint8Str(0x44),  // offset/length size
+                                  Uint8Str(0x40),  // base offset/index size
+                                  Uint16Str(2),    // item_count
+                                  std::string(16, '\0'));
+  riegeli::StringReader reader(data);
+  EXPECT_THAT(ParseIloc(reader, header),
+              StatusIs(absl::StatusCode::kDataLoss,
+                       HasSubstr("entry count 2 exceeds the remaining 16 "
+                                 "bytes")));
+}
+
+TEST(OffsetAdjustmentTest, IlocVersion1ExtentCountIncludesIndex) {
+  // Each extent has an extent_index (4), extent_offset (4), and
+  // extent_length (4).
+  BmffBoxHeader header;
+  header.version = 1;
+  std::string data = absl::StrCat(Uint8Str(0x44),  // offset/length size
+                                  Uint8Str(0x04),  // base offset/index size
+                                  Uint16Str(1),    // item_count
+                                  Uint16Str(1),    // item_ID
+                                  Uint16Str(0),    // construction_method
+                                  Uint16Str(0),    // data_reference_index
+                                  Uint16Str(2),    // extent_count
+                                  std::string(16, '\0'));
+  riegeli::StringReader reader(data);
+  EXPECT_THAT(ParseIloc(reader, header),
+              StatusIs(absl::StatusCode::kDataLoss,
+                       HasSubstr("entry count 2 exceeds the remaining 16 "
+                                 "bytes")));
+}
+
 }  // namespace
 }  // namespace credentio
diff --git a/formats/pdf/constants.h b/formats/pdf/constants.h
index 537a55a..b792a52 100644
--- a/formats/pdf/constants.h
+++ b/formats/pdf/constants.h
@@ -43,6 +43,12 @@
 constexpr absl::string_view kPdfParamsKey = "Params";
 constexpr absl::string_view kPdfModDateKey = "ModDate";
 
+// Page and annotation dictionary constants, see ISO 32000-2 Sections 7.7.3.3
+// and 12.5.6.15.
+constexpr absl::string_view kPdfAnnotsKey = "Annots";
+constexpr absl::string_view kPdfFileAttachmentSubtype = "FileAttachment";
+constexpr absl::string_view kPdfFsKey = "FS";
+
 // C2PA Manifest file spec constants. See
 // https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_adding_the_manifest_to_a_pdf.
 constexpr absl::string_view kPdfC2paManifestMimeType = "application/c2pa";
diff --git a/formats/pdf/objects.h b/formats/pdf/objects.h
index 4b19df5..929dfd2 100644
--- a/formats/pdf/objects.h
+++ b/formats/pdf/objects.h
@@ -23,6 +23,7 @@
 #include <ostream>
 #include <sstream>
 #include <string>
+#include <utility>
 #include <variant>
 #include <vector>
 
@@ -165,6 +166,10 @@
                                     const IndirectReference& r) {
       return os << r.object_number << " " << r.generation_number << " R";
     }
+    template <typename H>
+    friend H AbslHashValue(H h, const IndirectReference& r) {
+      return H::combine(std::move(h), r.object_number, r.generation_number);
+    }
   };
 
   std::variant<Boolean, Integer, RealNumber, LiteralString, HexadecimalString,
diff --git a/formats/pdf/reader.cc b/formats/pdf/reader.cc
index 6c635a0..9a5345b 100644
--- a/formats/pdf/reader.cc
+++ b/formats/pdf/reader.cc
@@ -40,8 +40,10 @@
 namespace {
 
 // Matches the cross-reference table offset in the trailer. See ISO 32000-2
-// Section 7.5.5
-static constexpr LazyRE2 kXrefOffsetPattern = {R"(startxref\s(\d+)\s%%EOF)"};
+// Section 7.5.5. Any run of white-space is accepted around the offset: this
+// covers CR LF end-of-line markers (Section 7.2.3), and readers may treat
+// end-of-line markers like other white-space.
+static constexpr LazyRE2 kXrefOffsetPattern = {R"(startxref\s+(\d+)\s+%%EOF)"};
 
 // Matches a single cross-reference entry in the cross-reference table. See ISO
 // 32000-2 Section 7.5.4
@@ -167,6 +169,22 @@
 
   return absl::OkStatus();
 }
+
+// Returns an error if `obj`, read at `offset`, is not the object `ref` that the
+// cross-reference table lists at that offset.
+absl::Status CheckObjectHeader(const IndirectObject& obj,
+                               const Object::IndirectReference& ref,
+                               uint64_t offset) {
+  if (obj.object_number != ref.object_number ||
+      obj.generation_number != ref.generation_number) {
+    return absl::InvalidArgumentError(absl::Substitute(
+        "Object mismatch at offset $0: the cross-reference table expects "
+        "object $1 $2 but found object $3 $4",
+        offset, ref.object_number, ref.generation_number, obj.object_number,
+        obj.generation_number));
+  }
+  return absl::OkStatus();
+}
 }  // namespace
 
 absl::StatusOr<std::unique_ptr<PdfReader>> PdfReader::Create(
@@ -238,6 +256,8 @@
       !status.ok()) {
     return status;
   }
+  ABSL_RETURN_IF_ERROR(
+      CheckObjectHeader(obj, current_it_->first, current_it_->second));
   ++current_it_;
   return obj;
 }
@@ -284,6 +304,7 @@
       !status.ok()) {
     return status;
   }
+  ABSL_RETURN_IF_ERROR(CheckObjectHeader(obj, ref, offset));
   return obj;
 }
 
diff --git a/formats/pdf/reader_test.cc b/formats/pdf/reader_test.cc
index cd9cb71..df11a35 100644
--- a/formats/pdf/reader_test.cc
+++ b/formats/pdf/reader_test.cc
@@ -25,6 +25,7 @@
 #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_replace.h"
 #include "absl/strings/string_view.h"
 #include "formats/pdf/objects.h"
@@ -36,6 +37,7 @@
 namespace credentio {
 namespace {
 
+using ::absl_testing::IsOk;
 using ::absl_testing::IsOkAndHolds;
 using ::absl_testing::StatusIs;
 using ::testing::ElementsAre;
@@ -233,6 +235,64 @@
       StatusIs(absl::StatusCode::kNotFound, HasSubstr("No offset found")));
 }
 
+// Returns the test PDF with the cross-reference entry for object 2 pointing at
+// the header of object 1.
+std::string GetTestPdfContentWithMismatchedObjectNumber() {
+  return absl::StrReplaceAll(GetTestPdfContent(),
+                             {{"0000000041 00000 n ", "0000000015 00000 n "}});
+}
+
+// Returns the test PDF with object 2 declared as `2 1 obj` while the
+// cross-reference table lists it as `2 0`.
+std::string GetTestPdfContentWithMismatchedGenerationNumber() {
+  return absl::StrReplaceAll(GetTestPdfContent(), {{"2 0 obj", "2 1 obj"}});
+}
+
+TEST(PdfReaderTest, GetObjectWithMismatchedObjectNumberFailure) {
+  std::string content = GetTestPdfContentWithMismatchedObjectNumber();
+  riegeli::StringReader<> input(content);
+  absl::StatusOr<std::unique_ptr<PdfReader>> reader = PdfReader::Create(&input);
+  ABSL_ASSERT_OK(reader);
+
+  EXPECT_THAT((*reader)->GetObject(2, 0),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("expects object 2 0 but found object 1 0")));
+  // Entries that point at the right header still resolve.
+  EXPECT_THAT((*reader)->GetObject(1, 0),
+              IsOkAndHolds(IndirectObject{.object_number = 1,
+                                          .generation_number = 0,
+                                          .object = CreateName("test_name")}));
+  EXPECT_THAT(
+      (*reader)->GetObject(3, 0),
+      IsOkAndHolds(IndirectObject{
+          .object_number = 3,
+          .generation_number = 0,
+          .object = CreateDictionary({{"Type", CreateName("Catalog")}})}));
+}
+
+TEST(PdfReaderTest, GetObjectWithMismatchedGenerationNumberFailure) {
+  std::string content = GetTestPdfContentWithMismatchedGenerationNumber();
+  riegeli::StringReader<> input(content);
+  absl::StatusOr<std::unique_ptr<PdfReader>> reader = PdfReader::Create(&input);
+  ABSL_ASSERT_OK(reader);
+
+  EXPECT_THAT((*reader)->GetObject(2, 0),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("expects object 2 0 but found object 2 1")));
+}
+
+TEST(PdfReaderTest, NextWithMismatchedObjectNumberFailure) {
+  std::string content = GetTestPdfContentWithMismatchedObjectNumber();
+  riegeli::StringReader<> input(content);
+  absl::StatusOr<std::unique_ptr<PdfReader>> reader = PdfReader::Create(&input);
+  ABSL_ASSERT_OK(reader);
+
+  ABSL_ASSERT_OK((*reader)->Next());  // Object 1.
+  EXPECT_THAT((*reader)->Next(),
+              StatusIs(absl::StatusCode::kInvalidArgument,
+                       HasSubstr("expects object 2 0 but found object 1 0")));
+}
+
 TEST(PdfReaderTest, ShortFileCausesInitializationFailure) {
   std::string content = "file is too short";
   riegeli::StringReader<> input(content);
@@ -308,6 +368,58 @@
                HasSubstr("Object is not a credentio::Object::Integer")));
 }
 
+// Returns the test PDF with its "startxref" trailer replaced by `trailer`.
+std::string GetTestPdfContentWithTrailer(absl::string_view trailer) {
+  return absl::StrReplaceAll(GetTestPdfContent(),
+                             {{"startxref\n98\n%%EOF", trailer}});
+}
+
+TEST(PdfReaderTest, ReadsTrailerWithAnyEndOfLineMarkers) {
+  for (absl::string_view trailer : {
+           "startxref\n98\n%%EOF",
+           "startxref\r\n98\r\n%%EOF",
+           "startxref\r98\r%%EOF",
+           "startxref\n98\n%%EOF\n",
+           "startxref\r\n98\r\n%%EOF\r\n",
+           "startxref \n\n98  \r\n\r\n%%EOF",
+       }) {
+    std::string content = GetTestPdfContentWithTrailer(trailer);
+    riegeli::StringReader<> input(content);
+    absl::StatusOr<std::unique_ptr<PdfReader>> reader =
+        PdfReader::Create(&input);
+    EXPECT_THAT(reader, IsOk()) << "trailer: " << absl::CEscape(trailer);
+    if (!reader.ok()) continue;
+    EXPECT_THAT(
+        (*reader)->GetObject(2, 0),
+        IsOkAndHolds(IndirectObject{.object_number = 2,
+                                    .generation_number = 0,
+                                    .object = CreateRealNumber("123.456")}))
+        << "trailer: " << absl::CEscape(trailer);
+  }
+}
+
+TEST(PdfReaderTest, CrlfTrailerWithTenDigitOffsetFitsTailScan) {
+  // "startxref\r\n" (11) + 10 digits + "\r\n%%EOF\r\n" (9) = 30 bytes, which
+  // is exactly the tail scan. The offset is found, then seeking to it fails.
+  std::string content =
+      GetTestPdfContentWithTrailer("startxref\r\n9999999999\r\n%%EOF\r\n");
+  riegeli::StringReader<> input(content);
+  EXPECT_THAT(PdfReader::Create(&input),
+              StatusIs(absl::StatusCode::kDataLoss,
+                       HasSubstr("Failed to seek to offset")));
+}
+
+TEST(PdfReaderTest, CrlfTrailerWithElevenDigitOffsetExceedsTailScanFailure) {
+  // 31 bytes: "startxref" no longer fits in the 30-byte tail scan.
+  std::string content =
+      GetTestPdfContentWithTrailer("startxref\r\n99999999999\r\n%%EOF\r\n");
+  riegeli::StringReader<> input(content);
+  EXPECT_THAT(
+      PdfReader::Create(&input),
+      StatusIs(absl::StatusCode::kInvalidArgument,
+               HasSubstr("Failed to find cross-reference table offset")));
+}
+
 TEST(PdfReaderTest, ContainingPreviousXrefTable) {
   std::string content = R"(%PDF-1.7
 1 0 obj
diff --git a/formats/pdf/writer.cc b/formats/pdf/writer.cc
index 8797136..9ef92c7 100644
--- a/formats/pdf/writer.cc
+++ b/formats/pdf/writer.cc
@@ -76,7 +76,7 @@
                         DataHashTemplate(alg));
   ABSL_ASSIGN_OR_RETURN(ManifestStoreTemplate manifest_store_template,
                         generator_.GenerateManifestStoreTemplate(request));
-  constexpr int64_t kMaxManifestStoreSizeBytes = 100 * 1024 * 1024;  // 100 MiB
+  constexpr int64_t kMaxManifestStoreSizeBytes = 10 * 1024 * 1024;  // 10 MiB
   if (manifest_store_template.manifest_store_size() <= 0 ||
       manifest_store_template.manifest_store_size() >
           kMaxManifestStoreSizeBytes) {
diff --git a/formats/pdf/writer_test.cc b/formats/pdf/writer_test.cc
index 9656960..0e1c171 100644
--- a/formats/pdf/writer_test.cc
+++ b/formats/pdf/writer_test.cc
@@ -191,7 +191,32 @@
 }
 
 INSTANTIATE_TEST_SUITE_P(PdfWriterInvalidSizeTests, PdfWriterInvalidSizeTest,
-                         ::testing::Values(-5, 0, 100LL * 1024 * 1024 + 1));
+                         ::testing::Values(-5, 0, 10LL * 1024 * 1024 + 1));
+
+TEST(PdfWriterMaxSizeTest, LargestManifestStoreCanBeReadBack) {
+  constexpr int64_t kMaxManifestStoreSize = 10 * 1024 * 1024;
+  FakeManifestStoreGenerator generator(std::string(kMaxManifestStoreSize, 'x'));
+  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());
+  ABSL_ASSERT_OK(writer.Write(request, &source, &destination));
+  ASSERT_TRUE(destination.Close());
+
+  // The location covers the whole stream object, not just its data.
+  riegeli::StringReader result_reader(result);
+  EXPECT_THAT(PdfExtractor().ExtractManifestStoreLocation(result_reader, {}),
+              IsOkAndHolds(Optional(
+                  Field(&ByteRange::length,
+                        Gt(static_cast<uint64_t>(kMaxManifestStoreSize))))));
+}
 
 }  // namespace
 }  // namespace credentio
diff --git a/formats/tiff/BUILD b/formats/tiff/BUILD
index 17cdcee..41b6779 100644
--- a/formats/tiff/BUILD
+++ b/formats/tiff/BUILD
@@ -221,7 +221,11 @@
         "//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/container:flat_hash_set",
         "@abseil-cpp//absl/log:absl_check",
         "@abseil-cpp//absl/status",
diff --git a/formats/tiff/writer.cc b/formats/tiff/writer.cc
index b59d26d..076c3f0 100644
--- a/formats/tiff/writer.cc
+++ b/formats/tiff/writer.cc
@@ -316,7 +316,8 @@
 absl::Status WriteImageFileDirectory(
     absl::Span<const TiffImageFileDirectoryEntry> entries,
     TiffEndianness endianness, std::string& destination) {
-  riegeli::StringWriter<> writer(&destination);
+  riegeli::StringWriter<> writer(
+      &destination, riegeli::StringWriterBase::Options().set_append(true));
   ABSL_RETURN_IF_ERROR(WriteImageFileDirectory(entries, endianness, writer));
   if (!writer.Close()) {
     return writer.status();
@@ -342,6 +343,8 @@
   }
 
   uint64_t source_size = *source->Size();
+  uint64_t padding_size = source_size % 2;
+  uint64_t ifd_offset = source_size + padding_size;
 
   auto hash_algorithm = HashAlgorithm::kSha256;
 
@@ -351,17 +354,17 @@
 
   ABSL_ASSIGN_OR_RETURN(
       ArtifactInformation artifact_information,
-      ScanForArtifactInformation(*source, source_size, request,
+      ScanForArtifactInformation(*source, ifd_offset, 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));
+                            artifact_information.endianness, ifd_offset));
 
   uint32_t file_size_after_ifd =
-      source_size + ByteSizeOfImageFileDirectory(
-                        artifact_information.ifd_to_add_to.entries.size() + 1);
+      ifd_offset + ByteSizeOfImageFileDirectory(
+                       artifact_information.ifd_to_add_to.entries.size() + 1);
 
   artifact_information.ifd_to_add_to.entries.push_back(
       TiffImageFileDirectoryEntry{
@@ -389,7 +392,7 @@
       ++c2pa_entry_index;
     }
 
-    count_exclusion->set_start(source_size +       // Existing file size
+    count_exclusion->set_start(ifd_offset +        // Offset of relocated IFD
                                sizeof(uint16_t) +  // final IFD count
                                // Number of entries before C2PA entry
                                (c2pa_entry_index * kTiffIfdEntrySize) +
@@ -430,6 +433,7 @@
     ByteInstruction last_ifd{
         .operation = ByteInstruction::Operation::kInsert,
         .offset = source_size,
+        .bytes = std::string(padding_size, '\0'),
     };
     ABSL_RETURN_IF_ERROR(WriteImageFileDirectory(
         artifact_information.ifd_to_add_to.entries,
diff --git a/formats/tiff/writer_test.cc b/formats/tiff/writer_test.cc
index 8fc46e5..b1b97de 100644
--- a/formats/tiff/writer_test.cc
+++ b/formats/tiff/writer_test.cc
@@ -34,10 +34,14 @@
 #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 "riegeli/endian/endian_writing.h"
 #include "testing/nonprod_generator.h"
+#include "testing/nonprod_validator.h"
+#include "validator/asset_validator.h"
 
 namespace credentio {
 namespace {
@@ -48,6 +52,7 @@
 using ::testing::Field;
 using ::testing::Gt;
 using ::testing::HasSubstr;
+using ::testing::IsEmpty;
 using ::testing::Optional;
 
 std::string CreateMinimalTiff(
@@ -93,10 +98,15 @@
     ABSL_CHECK_OK(generator);
     generator_ = *std::move(generator);
     writer_ = std::make_unique<TiffWriter>(generator_.get());
+
+    auto validator = CreateNonProdValidator({});
+    ABSL_CHECK_OK(validator);
+    validator_ = *std::move(validator);
   }
 
   std::unique_ptr<ManifestStoreGenerator> generator_;
   std::unique_ptr<TiffWriter> writer_;
+  std::unique_ptr<AssetValidator> validator_;
 };
 
 TEST_F(TiffWriterTest, WriteMaintainsSortedTags) {
@@ -221,5 +231,55 @@
                                           Field(&ByteRange::length, Gt(0))))));
 }
 
+TEST_F(TiffWriterTest, WriteRelocatesIfdToWordBoundary) {
+  std::string buffer = CreateMinimalTiff();
+  buffer.push_back('x');
+  ASSERT_EQ(buffer.size() % 2, 1);
+
+  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());
+
+  EXPECT_EQ(result[buffer.size()], '\0');
+
+  riegeli::StringReader result_reader(result);
+  EXPECT_THAT(TiffExtractor().ExtractManifestStoreLocation(result_reader, {}),
+              IsOkAndHolds(Optional(AllOf(
+                  Field(&ByteRange::offset,
+                        buffer.size() + 1 + ByteSizeOfImageFileDirectory(3)),
+                  Field(&ByteRange::length, Gt(0))))));
+
+  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.offset, buffer.size() + 1);
+        EXPECT_EQ(ifd.offset % 2, 0);
+        EXPECT_EQ(ifd.entries.size(), 3);
+        return true;
+      }));
+  EXPECT_TRUE(validated);
+
+  ASSERT_TRUE(result_reader.Seek(0));
+  auto validation_result = validator_->Validate(result_reader, "image/tiff");
+  ABSL_ASSERT_OK(validation_result);
+  EXPECT_THAT(
+      (*validation_result)->proto().active_manifest().validation().failures(),
+      IsEmpty());
+}
+
 }  // namespace
 }  // namespace credentio
diff --git a/kokoro/android/build_and_test.sh b/kokoro/android/build_and_test.sh
new file mode 100755
index 0000000..ffd4226
--- /dev/null
+++ b/kokoro/android/build_and_test.sh
@@ -0,0 +1,122 @@
+#!/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 the C2PA Android SDK, including emulator instrumentation
+# tests, on a Kokoro VM that supports nested virtualization.
+set -e
+
+# 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"
+
+EMULATOR_PID=""
+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 </dev/null > "${KOKORO_ARTIFACTS_DIR}/emulator-logs/emulator_${emulator_name}.log" 2>&1 &
+  EMULATOR_PID=$!
+}
+
+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 [ -n "${EMULATOR_PID:-}" ]; then
+    echo "Stopping emulator..."
+    adb emu kill >/dev/null 2>&1 || true
+    kill -9 "${EMULATOR_PID}" >/dev/null 2>&1 || true
+    wait "${EMULATOR_PID}" 2>/dev/null || true
+  fi
+  if [ -x "android/gradlew" ]; then
+    (cd android && ./gradlew --stop >/dev/null 2>&1) || true
+  elif [ -x "./gradlew" ]; then
+    ./gradlew --stop >/dev/null 2>&1 || true
+  fi
+  if command -v adb >/dev/null 2>&1; then
+    adb kill-server >/dev/null 2>&1 || true
+  fi
+}
+trap cleanup EXIT
+
+# --- Run Gradle build and tests for C2PA Android SDK ---
+if [ -d "android" ] && [ -x "android/gradlew" ]; then
+
+  cd android
+
+  ./gradlew assembleDebug :crypto:tsa_app:assembleDebug testDebugUnitTest --no-daemon --stacktrace
+
+  if [ "${RUN_INSTRUMENTATION_TESTS:-0}" = "1" ] || [ "${RUN_INSTRUMENTATION_TESTS:-false}" = "true" ]; then
+    # Fail fast with a clear message instead of timing out on emulator boot.
+    if [ ! -w /dev/kvm ]; then
+      echo "/dev/kvm is not available; the Android emulator needs nested virtualization."
+      exit 1
+    fi
+
+    echo "Starting Android emulator for instrumentation tests..."
+    create_and_start_emulator
+    wait_for_emulator_to_boot
+
+    echo "Installing TSA test app APK (debug) for instrumentation test runs..."
+    for apk in crypto/tsa_app/build/outputs/apk/debug/*.apk; do
+      if [ -f "$apk" ]; then
+        adb install -r -t "$apk"
+        break
+      fi
+    done
+
+    ./gradlew connectedAndroidTest \
+      -Pandroid.testInstrumentationRunnerArguments.clearPackageData=true \
+      -Pandroid.testoptions.execution=ANDROIDX_TEST_ORCHESTRATOR \
+      --no-daemon --stacktrace
+  fi
+else
+  echo "No android/gradlew found; skipping Android Gradle build."
+fi
diff --git a/kokoro/android/continuous.cfg b/kokoro/android/continuous.cfg
new file mode 100644
index 0000000..75b808c
--- /dev/null
+++ b/kokoro/android/continuous.cfg
@@ -0,0 +1,10 @@
+# proto-file: google3/devtools/kokoro/config/proto/build.proto
+# proto-message: BuildConfig
+
+build_file: "staging/kokoro/android/build_and_test.sh"
+
+container_properties {
+  docker_image: "us-central1-docker.pkg.dev/kokoro-container-bakery/c2pa/builder:latest"
+  # The Android emulator needs /dev/kvm inside the container.
+  docker_privileged: true
+}
diff --git a/kokoro/android/presubmit.cfg b/kokoro/android/presubmit.cfg
new file mode 100644
index 0000000..75b808c
--- /dev/null
+++ b/kokoro/android/presubmit.cfg
@@ -0,0 +1,10 @@
+# proto-file: google3/devtools/kokoro/config/proto/build.proto
+# proto-message: BuildConfig
+
+build_file: "staging/kokoro/android/build_and_test.sh"
+
+container_properties {
+  docker_image: "us-central1-docker.pkg.dev/kokoro-container-bakery/c2pa/builder:latest"
+  # The Android emulator needs /dev/kvm inside the container.
+  docker_privileged: true
+}
diff --git a/kokoro/gcp_ubuntu/build_and_test.sh b/kokoro/gcp_ubuntu/build_and_test.sh
index 7646c03..2b601f3 100755
--- a/kokoro/gcp_ubuntu/build_and_test.sh
+++ b/kokoro/gcp_ubuntu/build_and_test.sh
@@ -19,8 +19,6 @@
 # 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"
 
 # Use the Bazel caches pre-warmed in the container image, if any.
@@ -36,77 +34,10 @@
 bazel build "${bazel_flags[@]}" ...
 bazel test "${bazel_flags[@]}" ...
 
-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
+  if command -v bazel >/dev/null 2>&1; then
+    bazel shutdown >/dev/null 2>&1 || 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..."
-    sudo apt-get update || true
-    sudo apt-get install -y openjdk-17-jdk || true
-    export JAVA_HOME="/usr/lib/jvm/java-17-openjdk-amd64"
-    export PATH="${JAVA_HOME}/bin:${PATH}"
-  fi
-
-  cd android
-
-  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/testing/cms.cc b/testing/cms.cc
index b73dd6d..705feb7 100644
--- a/testing/cms.cc
+++ b/testing/cms.cc
@@ -18,6 +18,7 @@
 #include <cstdint>
 #include <ctime>
 #include <memory>
+#include <optional>
 #include <string>
 #include <vector>
 
@@ -126,11 +127,9 @@
   }
   std::string hash = hasher->Digest();
 
-  std::vector<uint8_t> signing_time_der, issuer_name, serial_number;
+  std::vector<uint8_t> issuer_name, serial_number;
   if (!SerializeCertificateIssuerName(signer_certificate, &issuer_name) ||
-      !SerializeCertificateSerialNumber(signer_certificate, &serial_number) ||
-      !SerializeSigningTime(absl::ToUnixSeconds(options.signing_time),
-                            &signing_time_der)) {
+      !SerializeCertificateSerialNumber(signer_certificate, &serial_number)) {
     return absl::InternalError("Failed to serialize certificate components.");
   }
 
@@ -170,13 +169,18 @@
   }
 
   // Signing Time
-  if (CBB_add_asn1(attributes.get(), &att_seq, CBS_ASN1_SEQUENCE) != 1 ||
-      CBB_add_asn1(&att_seq, &oid, CBS_ASN1_OBJECT) != 1 ||
-      !AddOid(&oid, kOidSigningTime) ||
-      CBB_add_asn1(&att_seq, &value_set, CBS_ASN1_SET) != 1 ||
-      CBB_add_bytes(&value_set, signing_time_der.data(),
-                    signing_time_der.size()) != 1) {
-    return absl::InternalError("Failed to add signing time attribute.");
+  if (options.signing_time.has_value()) {
+    std::vector<uint8_t> signing_time_der;
+    if (!SerializeSigningTime(absl::ToUnixSeconds(*options.signing_time),
+                              &signing_time_der) ||
+        CBB_add_asn1(attributes.get(), &att_seq, CBS_ASN1_SEQUENCE) != 1 ||
+        CBB_add_asn1(&att_seq, &oid, CBS_ASN1_OBJECT) != 1 ||
+        !AddOid(&oid, kOidSigningTime) ||
+        CBB_add_asn1(&att_seq, &value_set, CBS_ASN1_SET) != 1 ||
+        CBB_add_bytes(&value_set, signing_time_der.data(),
+                      signing_time_der.size()) != 1) {
+      return absl::InternalError("Failed to add signing time attribute.");
+    }
   }
 
   // SMIME-CAPS
@@ -236,9 +240,11 @@
     }
   }
 
+  // Sort signed attributes into canonical DER SET OF order (RFC 5652 §5.4).
   uint8_t* raw_attr_data;
   size_t raw_attr_len;
-  if (CBB_finish(attributes.get(), &raw_attr_data, &raw_attr_len) != 1) {
+  if (CBB_flush_asn1_set_of(attributes.get()) != 1 ||
+      CBB_finish(attributes.get(), &raw_attr_data, &raw_attr_len) != 1) {
     return absl::InternalError("Failed to finish attributes CBB.");
   }
   bssl::UniquePtr<uint8_t> raw_attr_data_uniq(raw_attr_data);
diff --git a/testing/cms.h b/testing/cms.h
index 98c944a..8d76703 100644
--- a/testing/cms.h
+++ b/testing/cms.h
@@ -17,6 +17,7 @@
 #define THIRD_PARTY_CREDENTIO_TESTING_CMS_H_
 
 #include <cstdint>
+#include <optional>
 #include <string>
 #include <vector>
 
@@ -30,7 +31,7 @@
 namespace credentio_testing {
 
 struct SignedCmsOptions {
-  absl::Time signing_time = absl::UnixEpoch();
+  std::optional<absl::Time> signing_time = std::nullopt;
   bool include_user_certificate = false;
   absl::Span<const X509* const> ca_certificates = {};
   absl::string_view extra_signed_attributes = "";
diff --git a/testing/local_key_crypto_write_handler.cc b/testing/local_key_crypto_write_handler.cc
index 944bb4f..71df461 100644
--- a/testing/local_key_crypto_write_handler.cc
+++ b/testing/local_key_crypto_write_handler.cc
@@ -125,14 +125,17 @@
 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::string_view tsa_cert_pem,
+    std::optional<absl::Time> tsa_signing_time) {
   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);
+                                   /*embed_cert=*/true,
+                                   /*signing_certificate_attribute=*/true,
+                                   tsa_signing_time);
 }
 
 }  // namespace
@@ -181,13 +184,14 @@
     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)
+    std::string tsa_cert_pem, std::optional<absl::Time> tsa_signing_time)
     : 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)) {}
+      tsa_cert_pem_(std::move(tsa_cert_pem)),
+      tsa_signing_time_(tsa_signing_time) {}
 
 void LocalKeyCryptoWriteHandler::CreateTimestamp(
     HashAlgorithm hash_algorithm, std::string hashed_message,
@@ -199,7 +203,8 @@
     return;
   }
   resolver.Return(CreateTimeStampToken(hash_algorithm, hashed_message, *clock_,
-                                       *tsa_signer_, tsa_cert_pem_));
+                                       *tsa_signer_, tsa_cert_pem_,
+                                       tsa_signing_time_));
 }
 
 uint64_t LocalKeyCryptoWriteHandler::max_timestamp_byte_size(
@@ -215,7 +220,7 @@
     // unusable, so crash.
     absl::StatusOr<std::string> token = CreateTimeStampToken(
         hash_algorithm, std::string(HashSizeBytes(hash_algorithm), '\0'),
-        *clock_, *tsa_signer_, tsa_cert_pem_);
+        *clock_, *tsa_signer_, tsa_cert_pem_, tsa_signing_time_);
     ABSL_CHECK_OK(token);
     it->second = token->size() + kSizeMargin;
   }
diff --git a/testing/local_key_crypto_write_handler.h b/testing/local_key_crypto_write_handler.h
index b851e37..4b447a1 100644
--- a/testing/local_key_crypto_write_handler.h
+++ b/testing/local_key_crypto_write_handler.h
@@ -98,7 +98,8 @@
       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);
+      std::string tsa_cert_pem,
+      std::optional<absl::Time> tsa_signing_time = std::nullopt);
 
   LocalKeyCryptoWriteHandler(const LocalKeyCryptoWriteHandler&) = delete;
   LocalKeyCryptoWriteHandler& operator=(const LocalKeyCryptoWriteHandler&) =
@@ -123,6 +124,7 @@
   const std::unique_ptr<crypto::tink::PublicKeySign> absl_nullable tsa_signer_;
   const bool is_timestamping_disabled_;
   const std::string tsa_cert_pem_;
+  const std::optional<absl::Time> tsa_signing_time_;
 
   mutable absl::Mutex mutex_;
   mutable absl::flat_hash_map<HashAlgorithm, uint64_t>
diff --git a/tracing/BUILD b/tracing/BUILD
new file mode 100644
index 0000000..4eb6582
--- /dev/null
+++ b/tracing/BUILD
@@ -0,0 +1,149 @@
+# Copyright 2026 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#    https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+load("@rules_cc//cc:cc_library.bzl", "cc_library")
+load("@rules_cc//cc:cc_test.bzl", "cc_test")
+
+package(default_visibility = ["//visibility:public"])
+
+cc_library(
+    name = "span",
+    srcs = [
+        "scoped_span.cc",
+        "thread_context.cc",
+    ],
+    hdrs = [
+        "scoped_span.h",
+        "span.h",
+        "thread_context.h",
+    ],
+    deps = [
+        "@abseil-cpp//absl/base:nullability",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/strings:string_view",
+    ],
+)
+
+cc_test(
+    name = "span_test",
+    srcs = ["span_test.cc"],
+    deps = [
+        ":span",
+        ":tracer",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/strings:string_view",
+        "@googletest//:gtest_main",
+    ],
+)
+
+cc_test(
+    name = "thread_context_test",
+    srcs = ["thread_context_test.cc"],
+    deps = [
+        ":span",
+        ":tracer",
+        "@googletest//:gtest_main",
+    ],
+)
+
+cc_library(
+    name = "tracer",
+    srcs = [
+        "noop_tracing.cc",
+        "tracer_factory.cc",
+    ],
+    hdrs = [
+        "noop_tracing.h",
+        "tracer.h",
+        "tracer_factory.h",
+    ],
+    deps = [
+        ":span",
+        "//async:async_context",
+        "@abseil-cpp//absl/base:core_headers",
+        "@abseil-cpp//absl/base:nullability",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/strings:string_view",
+        "@abseil-cpp//absl/synchronization",
+    ],
+)
+
+cc_test(
+    name = "tracer_test",
+    srcs = ["tracer_test.cc"],
+    deps = [
+        ":scoped_tracer_factory",
+        ":span",
+        ":tracer",
+        "//async:async_context",
+        "@abseil-cpp//absl/base:nullability",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/strings:string_view",
+        "@googletest//:gtest_main",
+    ],
+)
+
+cc_test(
+    name = "scoped_span_test",
+    srcs = ["scoped_span_test.cc"],
+    deps = [
+        ":span",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/strings:string_view",
+        "@googletest//:gtest_main",
+    ],
+)
+
+cc_library(
+    name = "test_tracing",
+    testonly = 1,
+    srcs = ["test_tracing.cc"],
+    hdrs = ["test_tracing.h"],
+    deps = [
+        ":span",
+        ":tracer",
+        "@abseil-cpp//absl/base:core_headers",
+        "@abseil-cpp//absl/container:flat_hash_map",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/strings:str_format",
+        "@abseil-cpp//absl/strings:string_view",
+        "@abseil-cpp//absl/synchronization",
+        "@abseil-cpp//absl/time",
+    ],
+)
+
+cc_library(
+    name = "scoped_tracer_factory",
+    srcs = ["scoped_tracer_factory.cc"],
+    hdrs = ["scoped_tracer_factory.h"],
+    deps = [
+        ":tracer",
+        "@abseil-cpp//absl/base:nullability",
+    ],
+)
+
+cc_test(
+    name = "test_tracing_test",
+    srcs = ["test_tracing_test.cc"],
+    deps = [
+        ":span",
+        ":test_tracing",
+        ":tracer",
+        "@abseil-cpp//absl/status",
+        "@abseil-cpp//absl/strings:string_view",
+        "@abseil-cpp//absl/time",
+        "@googletest//:gtest_main",
+    ],
+)
diff --git a/tracing/noop_tracing.cc b/tracing/noop_tracing.cc
new file mode 100644
index 0000000..b7f37b4
--- /dev/null
+++ b/tracing/noop_tracing.cc
@@ -0,0 +1,39 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+#include "tracing/noop_tracing.h"
+
+#include <memory>
+
+#include "absl/base/nullability.h"
+#include "absl/strings/string_view.h"
+#include "tracing/span.h"
+#include "tracing/tracer.h"
+
+namespace credentio_tracing {
+
+std::unique_ptr<Span> NoopTracer::StartSpanImpl(absl::string_view,
+                                                const StartSpanOptions&) const {
+  return std::make_unique<NoopSpan>();
+}
+
+absl_nonnull std::shared_ptr<Tracer> NoopTracerFactory::GetTracer(
+    absl::string_view) const {
+  static auto* tracer =
+      new std::shared_ptr<Tracer>(std::make_shared<NoopTracer>());
+  return *tracer;
+}
+
+}  // namespace credentio_tracing
diff --git a/tracing/noop_tracing.h b/tracing/noop_tracing.h
new file mode 100644
index 0000000..2a552d4
--- /dev/null
+++ b/tracing/noop_tracing.h
@@ -0,0 +1,70 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+#ifndef THIRD_PARTY_CREDENTIO_TRACING_NOOP_TRACING_H_
+#define THIRD_PARTY_CREDENTIO_TRACING_NOOP_TRACING_H_
+
+#include <memory>
+
+#include "absl/base/nullability.h"
+#include "absl/status/status.h"
+#include "absl/strings/string_view.h"
+#include "tracing/span.h"
+#include "tracing/tracer.h"
+#include "tracing/tracer_factory.h"
+
+namespace credentio_tracing {
+
+// No-op implementation of Span that discards all recorded operations and
+// attributes.
+class NoopSpan : public Span {
+ public:
+  // Discards the attribute key-value pair.
+  void SetAttribute(absl::string_view, AttributeValue) override {}
+
+  // Discards the point-in-time event.
+  void AddEvent(absl::string_view) override {}
+
+  // Discards the status code update.
+  void SetStatus(const absl::Status&) override {}
+
+  // Discards span completion.
+  void End() override {}
+};
+
+// No-op implementation of Tracer that creates NoopSpan instances with sub-5ns
+// overhead.
+class NoopTracer : public Tracer {
+ public:
+  using Tracer::StartSpan;
+
+  // Returns a new NoopSpan instance.
+  std::unique_ptr<Span> StartSpanImpl(
+      absl::string_view span_name,
+      const StartSpanOptions& options) const override;
+};
+
+// No-op implementation of TracerFactory that returns shared NoopTracer
+// instances.
+class NoopTracerFactory : public TracerFactory {
+ public:
+  // Returns a thread-safe singleton NoopTracer instance.
+  absl_nonnull std::shared_ptr<Tracer> GetTracer(
+      absl::string_view) const override;
+};
+
+}  // namespace credentio_tracing
+
+#endif  // THIRD_PARTY_CREDENTIO_TRACING_NOOP_TRACING_H_
diff --git a/tracing/scoped_span.cc b/tracing/scoped_span.cc
new file mode 100644
index 0000000..8eaec46
--- /dev/null
+++ b/tracing/scoped_span.cc
@@ -0,0 +1,79 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+#include "tracing/scoped_span.h"
+
+#include <memory>
+#include <utility>
+
+#include "tracing/span.h"
+#include "tracing/thread_context.h"
+
+namespace credentio_tracing {
+
+ScopedSpan::ScopedSpan(Span* span)
+    : span_(span), previous_span_(ThreadContext::GetCurrentSpan()) {
+  if (span_) {
+    ThreadContext::SetCurrentSpan(span_);
+  }
+}
+
+ScopedSpan::ScopedSpan(std::unique_ptr<Span> span)
+    : owned_span_(std::move(span)),
+      span_(owned_span_.get()),
+      previous_span_(ThreadContext::GetCurrentSpan()) {
+  if (span_) {
+    ThreadContext::SetCurrentSpan(span_);
+  }
+}
+
+void ScopedSpan::Teardown() {
+  if (active_) {
+    if (owned_span_) {
+      owned_span_->End();
+    }
+    ThreadContext::SetCurrentSpan(previous_span_);
+    active_ = false;
+  }
+}
+
+ScopedSpan::~ScopedSpan() { Teardown(); }
+
+ScopedSpan::ScopedSpan(ScopedSpan&& other) noexcept
+    : owned_span_(std::move(other.owned_span_)),
+      span_(std::exchange(other.span_, nullptr)),
+      previous_span_(std::exchange(other.previous_span_, nullptr)),
+      active_(std::exchange(other.active_, false)) {
+  // NOMUTANTS -- Defensive cleanup; previous_span_ is private and only accessed
+  // when active_ is true.
+}
+
+ScopedSpan& ScopedSpan::operator=(ScopedSpan&& other) noexcept {
+  if (this != &other) {
+    Teardown();
+    owned_span_ = std::move(other.owned_span_);
+    span_ = std::exchange(other.span_, nullptr);
+    // NOMUTANTS -- Defensive cleanup; previous_span_ is private and only
+    // accessed when active_ is true.
+    previous_span_ = std::exchange(other.previous_span_, nullptr);
+    active_ = std::exchange(other.active_, false);
+    if (active_ && span_) {
+      ThreadContext::SetCurrentSpan(span_);
+    }
+  }
+  return *this;
+}
+
+}  // namespace credentio_tracing
diff --git a/tracing/scoped_span.h b/tracing/scoped_span.h
new file mode 100644
index 0000000..e87a59b
--- /dev/null
+++ b/tracing/scoped_span.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_TRACING_SCOPED_SPAN_H_
+#define THIRD_PARTY_CREDENTIO_TRACING_SCOPED_SPAN_H_
+
+#include <memory>
+
+#include "absl/status/status.h"
+#include "tracing/span.h"
+
+namespace credentio_tracing {
+
+// RAII container for managing Span object lifetimes and thread-local stack
+// binding.
+//
+// Automatically calls End() on the wrapped Span when ScopedSpan is destroyed,
+// unless ownership has been moved, and maintains the implicit parent-child span
+// hierarchy on the current thread.
+class ScopedSpan {
+ public:
+  // Binds an existing Span pointer as active in ThreadContext for this scope.
+  explicit ScopedSpan(Span* span);
+
+  // Wraps an existing Span unique pointer and sets it as active in
+  // ThreadContext.
+  explicit ScopedSpan(std::unique_ptr<Span> span);
+
+  // Automatically invokes End() on the contained span (if owned) and restores
+  // the previous active span upon destruction.
+  ~ScopedSpan();
+
+  ScopedSpan(ScopedSpan&& other) noexcept;
+  ScopedSpan& operator=(ScopedSpan&& other) noexcept;
+  ScopedSpan(const ScopedSpan&) = delete;
+  ScopedSpan& operator=(const ScopedSpan&) = delete;
+
+  // Returns a raw pointer to the underlying Span without releasing ownership.
+  Span* get() const { return span_; }
+
+  // Member access operator forwarding calls to the underlying Span.
+  Span* operator->() const { return span_; }
+
+  // Convenience method to set status on the underlying span if valid.
+  void SetStatus(const absl::Status& status) {
+    if (span_) {
+      span_->SetStatus(status);
+    }
+  }
+
+ private:
+  void Teardown();
+
+  std::unique_ptr<Span> owned_span_;
+  Span* span_ = nullptr;
+  Span* previous_span_ = nullptr;
+  bool active_ = true;
+};
+
+}  // namespace credentio_tracing
+
+#endif  // THIRD_PARTY_CREDENTIO_TRACING_SCOPED_SPAN_H_
diff --git a/tracing/scoped_span_test.cc b/tracing/scoped_span_test.cc
new file mode 100644
index 0000000..0d4898b
--- /dev/null
+++ b/tracing/scoped_span_test.cc
@@ -0,0 +1,355 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+#include "tracing/scoped_span.h"
+
+#include <memory>
+#include <optional>
+#include <string>
+#include <utility>
+
+#include "absl/status/status.h"
+#include "absl/strings/string_view.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "tracing/span.h"
+#include "tracing/thread_context.h"
+
+namespace credentio_tracing {
+namespace {
+
+using ::testing::InSequence;
+using ::testing::StrictMock;
+
+class MockSpan : public Span {
+ public:
+  MOCK_METHOD(void, SetAttribute, (absl::string_view key, AttributeValue value),
+              (override));
+  MOCK_METHOD(void, AddEvent, (absl::string_view name), (override));
+  MOCK_METHOD(void, SetStatus, (const absl::Status& status), (override));
+  MOCK_METHOD(void, End, (), (override));
+};
+
+TEST(ScopedSpanTest, CallsEndOnDestruction) {
+  auto mock_span = std::make_unique<StrictMock<MockSpan>>();
+  MockSpan* raw_span = mock_span.get();
+
+  EXPECT_CALL(*raw_span, End());
+  {
+    ScopedSpan scoped_span(std::move(mock_span));
+    EXPECT_EQ(scoped_span.get(), raw_span);
+    EXPECT_EQ(scoped_span.operator->(), raw_span);
+  }
+}
+
+TEST(ScopedSpanTest, ForwardsSetStatus) {
+  auto mock_span = std::make_unique<StrictMock<MockSpan>>();
+  MockSpan* raw_span = mock_span.get();
+
+  absl::Status test_status = absl::InternalError("test error");
+  EXPECT_CALL(*raw_span, SetStatus(test_status));
+  EXPECT_CALL(*raw_span, End());
+
+  ScopedSpan scoped_span(std::move(mock_span));
+  scoped_span.SetStatus(test_status);
+}
+
+TEST(ScopedSpanTest, MoveConstructorTransfersOwnership) {
+  auto mock_span = std::make_unique<StrictMock<MockSpan>>();
+  MockSpan* raw_span = mock_span.get();
+
+  EXPECT_CALL(*raw_span, End());
+  {
+    ScopedSpan scoped1(std::move(mock_span));
+    EXPECT_EQ(ThreadContext::GetCurrentSpan(), raw_span);
+
+    ScopedSpan scoped2(std::move(scoped1));
+
+    EXPECT_EQ(scoped1.get(), nullptr);  // NOLINT(bugprone-use-after-move)
+    EXPECT_EQ(scoped2.get(), raw_span);
+    EXPECT_EQ(ThreadContext::GetCurrentSpan(), raw_span);
+  }
+  EXPECT_EQ(ThreadContext::GetCurrentSpan(), nullptr);
+}
+
+TEST(ScopedSpanTest, MoveAssignmentTransfersOwnership) {
+  auto mock_span1 = std::make_unique<StrictMock<MockSpan>>();
+  MockSpan* raw_span1 = mock_span1.get();
+
+  auto mock_span2 = std::make_unique<StrictMock<MockSpan>>();
+  MockSpan* raw_span2 = mock_span2.get();
+
+  EXPECT_CALL(*raw_span1, End());
+  EXPECT_CALL(*raw_span2, End());
+
+  {
+    ScopedSpan scoped1(std::move(mock_span1));
+    ScopedSpan scoped2(std::move(mock_span2));
+    EXPECT_EQ(ThreadContext::GetCurrentSpan(), raw_span2);
+
+    scoped2 = std::move(scoped1);
+    EXPECT_EQ(scoped1.get(), nullptr);  // NOLINT(bugprone-use-after-move)
+    EXPECT_EQ(scoped2.get(), raw_span1);
+    EXPECT_EQ(ThreadContext::GetCurrentSpan(), raw_span1);
+  }
+  EXPECT_EQ(ThreadContext::GetCurrentSpan(), nullptr);
+}
+
+TEST(ScopedSpanTest, MoveAssignmentOrderAndSequence) {
+  auto mock_span1 = std::make_unique<StrictMock<MockSpan>>();
+  MockSpan* raw_span1 = mock_span1.get();
+
+  auto mock_span2 = std::make_unique<StrictMock<MockSpan>>();
+  MockSpan* raw_span2 = mock_span2.get();
+
+  InSequence seq;
+  EXPECT_CALL(*raw_span2, End());
+  EXPECT_CALL(*raw_span1, End());
+
+  {
+    ScopedSpan scoped1(std::move(mock_span1));
+    ScopedSpan scoped2(std::move(mock_span2));
+
+    scoped2 = std::move(scoped1);
+  }
+}
+
+TEST(ScopedSpanTest, MoveAssignmentToNullSpan) {
+  auto mock_span = std::make_unique<StrictMock<MockSpan>>();
+  MockSpan* raw_span = mock_span.get();
+
+  EXPECT_CALL(*raw_span, End());
+
+  {
+    ScopedSpan scoped1(std::move(mock_span));
+    ScopedSpan scoped2(nullptr);
+
+    scoped2 = std::move(scoped1);
+    EXPECT_EQ(scoped1.get(), nullptr);  // NOLINT(bugprone-use-after-move)
+    EXPECT_EQ(scoped2.get(), raw_span);
+  }
+}
+
+TEST(ScopedSpanTest, SelfMoveAssignment) {
+  auto mock_span = std::make_unique<StrictMock<MockSpan>>();
+  MockSpan* raw_span = mock_span.get();
+
+  EXPECT_CALL(*raw_span, End()).Times(1);
+
+  {
+    ScopedSpan scoped(std::move(mock_span));
+    ScopedSpan& scoped_ref = scoped;
+    scoped = std::move(scoped_ref);
+    EXPECT_EQ(scoped.get(), raw_span);
+  }
+}
+
+TEST(ScopedSpanTest, NullPointerSafe) {
+  ScopedSpan null_scoped(nullptr);
+  EXPECT_EQ(null_scoped.get(), nullptr);
+
+  // SetStatus on null span should not crash
+  null_scoped.SetStatus(absl::OkStatus());
+  // Destruction of null span should not crash
+}
+
+TEST(ScopedSpanTest, NestedSpanHierarchyRestoresParent) {
+  auto mock_outer = std::make_unique<StrictMock<MockSpan>>();
+  MockSpan* raw_outer = mock_outer.get();
+
+  auto mock_inner = std::make_unique<StrictMock<MockSpan>>();
+  MockSpan* raw_inner = mock_inner.get();
+
+  EXPECT_CALL(*raw_inner, End());
+  EXPECT_CALL(*raw_outer, End());
+
+  EXPECT_EQ(ThreadContext::GetCurrentSpan(), nullptr);
+  {
+    ScopedSpan outer(std::move(mock_outer));
+    EXPECT_EQ(ThreadContext::GetCurrentSpan(), raw_outer);
+
+    {
+      ScopedSpan inner(std::move(mock_inner));
+      EXPECT_EQ(ThreadContext::GetCurrentSpan(), raw_inner);
+    }
+
+    EXPECT_EQ(ThreadContext::GetCurrentSpan(), raw_outer);
+  }
+  EXPECT_EQ(ThreadContext::GetCurrentSpan(), nullptr);
+}
+
+TEST(ScopedSpanTest, MovedFromScopedSpanDestructionDoesNotRestoreSpan) {
+  MockSpan outer_span;
+  ThreadContext::SetCurrentSpan(&outer_span);
+
+  auto mock_span = std::make_unique<StrictMock<MockSpan>>();
+  MockSpan* raw_span = mock_span.get();
+  EXPECT_CALL(*raw_span, End());
+
+  {
+    std::optional<ScopedSpan> scoped2;
+    {
+      ScopedSpan scoped1(std::move(mock_span));
+      EXPECT_EQ(ThreadContext::GetCurrentSpan(), raw_span);
+
+      scoped2.emplace(std::move(scoped1));
+      EXPECT_EQ(ThreadContext::GetCurrentSpan(), raw_span);
+      // scoped1 is destroyed here at end of inner scope.
+    }
+    // Destruction of moved-from scoped1 must not unbind raw_span or restore
+    // outer_span.
+    EXPECT_EQ(ThreadContext::GetCurrentSpan(), raw_span);
+  }
+
+  EXPECT_EQ(ThreadContext::GetCurrentSpan(), &outer_span);
+  ThreadContext::SetCurrentSpan(nullptr);
+}
+
+TEST(ScopedSpanTest, MoveAssignmentIntoInactiveScopedSpanActivatesTarget) {
+  auto mock_span = std::make_unique<StrictMock<MockSpan>>();
+  MockSpan* raw_span = mock_span.get();
+  EXPECT_CALL(*raw_span, End());
+
+  ScopedSpan target(nullptr);
+  {
+    ScopedSpan sink(std::move(target));
+  }
+  // target is now inactive.
+  EXPECT_EQ(target.get(), nullptr);  // NOLINT(bugprone-use-after-move)
+  EXPECT_EQ(ThreadContext::GetCurrentSpan(), nullptr);
+
+  {
+    ScopedSpan source(std::move(mock_span));
+    EXPECT_EQ(ThreadContext::GetCurrentSpan(), raw_span);
+
+    // Assigning active source into inactive target must activate target.
+    target = std::move(source);
+    EXPECT_EQ(target.get(), raw_span);
+    EXPECT_EQ(ThreadContext::GetCurrentSpan(), raw_span);
+  }
+  // source went out of scope (inactive).
+  // target remains active in outer scope with raw_span.
+  EXPECT_EQ(ThreadContext::GetCurrentSpan(), raw_span);
+}
+
+TEST(ScopedSpanTest, MoveAssignmentFromInactiveScopedSpanDeactivatesTarget) {
+  MockSpan outer_span;
+  ThreadContext::SetCurrentSpan(&outer_span);
+
+  auto mock_span = std::make_unique<StrictMock<MockSpan>>();
+  MockSpan* raw_span = mock_span.get();
+  EXPECT_CALL(*raw_span, End());
+
+  {
+    ScopedSpan inactive_scoped(nullptr);
+    {
+      ScopedSpan sink(std::move(inactive_scoped));
+    }
+    // inactive_scoped is now inactive.
+    EXPECT_EQ(ThreadContext::GetCurrentSpan(), &outer_span);
+
+    ScopedSpan active_scoped(std::move(mock_span));
+    EXPECT_EQ(ThreadContext::GetCurrentSpan(), raw_span);
+
+    // Assigning inactive into active must end active_scoped's span,
+    // restore outer_span, and deactivate active_scoped.
+    active_scoped =
+        std::move(inactive_scoped);  // NOLINT(bugprone-use-after-move)
+    EXPECT_EQ(active_scoped.get(), nullptr);
+    EXPECT_EQ(ThreadContext::GetCurrentSpan(), &outer_span);
+  }
+
+  // Destruction of deactivated active_scoped must not change ThreadContext.
+  EXPECT_EQ(ThreadContext::GetCurrentSpan(), &outer_span);
+  ThreadContext::SetCurrentSpan(nullptr);
+}
+
+TEST(ScopedSpanTest, RawSpanConstructorBindsAndUnbindsWithoutCallingEnd) {
+  StrictMock<MockSpan> raw_span;
+  // No End() call expected for non-owning ScopedSpan(Span*).
+
+  EXPECT_EQ(ThreadContext::GetCurrentSpan(), nullptr);
+  {
+    ScopedSpan scoped(&raw_span);
+    EXPECT_EQ(scoped.get(), &raw_span);
+    EXPECT_EQ(ThreadContext::GetCurrentSpan(), &raw_span);
+  }
+  EXPECT_EQ(ThreadContext::GetCurrentSpan(), nullptr);
+}
+
+TEST(ScopedSpanTest, RawSpanConstructorWithNullAndActiveContext) {
+  StrictMock<MockSpan> outer_span;
+  ThreadContext::SetCurrentSpan(&outer_span);
+
+  {
+    ScopedSpan null_scoped(static_cast<Span*>(nullptr));
+    EXPECT_EQ(null_scoped.get(), nullptr);
+    EXPECT_EQ(ThreadContext::GetCurrentSpan(), &outer_span);
+  }
+
+  EXPECT_EQ(ThreadContext::GetCurrentSpan(), &outer_span);
+  ThreadContext::SetCurrentSpan(nullptr);
+}
+
+TEST(ScopedSpanTest, UniquePtrConstructorWithNullAndActiveContext) {
+  StrictMock<MockSpan> outer_span;
+  ThreadContext::SetCurrentSpan(&outer_span);
+
+  {
+    ScopedSpan null_scoped(std::unique_ptr<Span>(nullptr));
+    EXPECT_EQ(null_scoped.get(), nullptr);
+    EXPECT_EQ(ThreadContext::GetCurrentSpan(), &outer_span);
+  }
+
+  EXPECT_EQ(ThreadContext::GetCurrentSpan(), &outer_span);
+  ThreadContext::SetCurrentSpan(nullptr);
+}
+
+TEST(ScopedSpanTest, MoveConstructorWithRawSpan) {
+  StrictMock<MockSpan> raw_span;
+
+  {
+    ScopedSpan scoped1(&raw_span);
+    EXPECT_EQ(ThreadContext::GetCurrentSpan(), &raw_span);
+
+    ScopedSpan scoped2(std::move(scoped1));
+    EXPECT_EQ(scoped1.get(), nullptr);  // NOLINT(bugprone-use-after-move)
+    EXPECT_EQ(scoped2.get(), &raw_span);
+    EXPECT_EQ(ThreadContext::GetCurrentSpan(), &raw_span);
+  }
+
+  EXPECT_EQ(ThreadContext::GetCurrentSpan(), nullptr);
+}
+
+TEST(ScopedSpanTest, MoveAssignmentWithRawSpan) {
+  StrictMock<MockSpan> raw_span1;
+  StrictMock<MockSpan> raw_span2;
+
+  {
+    ScopedSpan scoped1(&raw_span1);
+    ScopedSpan scoped2(&raw_span2);
+    EXPECT_EQ(ThreadContext::GetCurrentSpan(), &raw_span2);
+
+    scoped2 = std::move(scoped1);
+    EXPECT_EQ(scoped1.get(), nullptr);  // NOLINT(bugprone-use-after-move)
+    EXPECT_EQ(scoped2.get(), &raw_span1);
+    EXPECT_EQ(ThreadContext::GetCurrentSpan(), &raw_span1);
+  }
+
+  EXPECT_EQ(ThreadContext::GetCurrentSpan(), nullptr);
+}
+
+}  // namespace
+}  // namespace credentio_tracing
diff --git a/tracing/scoped_tracer_factory.cc b/tracing/scoped_tracer_factory.cc
new file mode 100644
index 0000000..b1e2a5b
--- /dev/null
+++ b/tracing/scoped_tracer_factory.cc
@@ -0,0 +1,36 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+#include "tracing/scoped_tracer_factory.h"
+
+#include <memory>
+#include <utility>
+
+#include "absl/base/nullability.h"
+#include "tracing/tracer_factory.h"
+
+namespace credentio_tracing {
+
+ScopedTracerFactory::ScopedTracerFactory(
+    absl_nullable std::shared_ptr<TracerFactory> factory)
+    : previous_factory_(TracerFactory::GetThreadLocalFactory()) {
+  TracerFactory::SetThreadLocalFactory(std::move(factory));
+}
+
+ScopedTracerFactory::~ScopedTracerFactory() {
+  TracerFactory::SetThreadLocalFactory(std::move(previous_factory_));
+}
+
+}  // namespace credentio_tracing
diff --git a/tracing/scoped_tracer_factory.h b/tracing/scoped_tracer_factory.h
new file mode 100644
index 0000000..2c3f98e
--- /dev/null
+++ b/tracing/scoped_tracer_factory.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_TRACING_SCOPED_TRACER_FACTORY_H_
+#define THIRD_PARTY_CREDENTIO_TRACING_SCOPED_TRACER_FACTORY_H_
+
+#include <memory>
+
+#include "absl/base/nullability.h"
+#include "tracing/tracer_factory.h"
+
+namespace credentio_tracing {
+
+// RAII guard that installs a TracerFactory for the current thread during its
+// lifetime and restores the previous one upon destruction.
+//
+// Installing and clearing a thread-local factory by hand is error prone: the
+// two calls must run on the same thread, which is easy to get wrong when the
+// work in between hops to a callback or a dispatch queue. This guard makes
+// that mistake unrepresentable for synchronous scopes.
+//
+// It only covers work performed on the calling thread. To scope tracing to an
+// asynchronous operation, carry the factory on the operation's
+// `credentio::AsyncContext` instead.
+class ScopedTracerFactory {
+ public:
+  // Installs `factory` as the calling thread's factory.
+  explicit ScopedTracerFactory(
+      absl_nullable std::shared_ptr<TracerFactory> factory);
+
+  // Restores the previously installed thread-local factory.
+  ~ScopedTracerFactory();
+
+  ScopedTracerFactory(const ScopedTracerFactory&) = delete;
+  ScopedTracerFactory& operator=(const ScopedTracerFactory&) = delete;
+  ScopedTracerFactory(ScopedTracerFactory&&) = delete;
+  ScopedTracerFactory& operator=(ScopedTracerFactory&&) = delete;
+
+ private:
+  absl_nullable std::shared_ptr<TracerFactory> previous_factory_;
+};
+
+}  // namespace credentio_tracing
+
+#endif  // THIRD_PARTY_CREDENTIO_TRACING_SCOPED_TRACER_FACTORY_H_
diff --git a/tracing/span.h b/tracing/span.h
new file mode 100644
index 0000000..f69902e
--- /dev/null
+++ b/tracing/span.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_TRACING_SPAN_H_
+#define THIRD_PARTY_CREDENTIO_TRACING_SPAN_H_
+
+#include <cstdint>
+#include <string>
+#include <variant>
+
+#include "absl/status/status.h"
+#include "absl/strings/string_view.h"
+
+namespace credentio_tracing {
+
+// Represents supported primitive attribute values that can be attached to a
+// tracing span.
+using AttributeValue =
+    std::variant<std::string, uint64_t, int64_t, double, bool>;
+
+// Abstract interface for a tracing span representing an operation duration.
+//
+// Implementations of Span record information about execution status, timed
+// events, and key-value attributes.
+//
+// Thread safety: a span may be started on one thread and ended on another --
+// the asynchronous encoding pipeline routinely does this, for example a span
+// that ends inside an RPC callback while its siblings end on the calling
+// thread. Implementations must therefore synchronize any state they share
+// between spans, such as a destination shared by all spans of an operation.
+// A single span instance must not be used concurrently from multiple threads;
+// callers hand a span off to at most one thread at a time, which establishes
+// the happens-before edge that makes its own state safe to access unguarded.
+class Span {
+ public:
+  virtual ~Span() = default;
+
+  // Sets a key-value attribute on the span.
+  virtual void SetAttribute(absl::string_view key, AttributeValue value) = 0;
+
+  // Records a point-in-time event with the specified name within the span.
+  virtual void AddEvent(absl::string_view name) = 0;
+
+  // Sets the execution status for the operation tracked by the span.
+  virtual void SetStatus(const absl::Status& status) = 0;
+
+  // Completes execution of the span and records its end time.
+  virtual void End() = 0;
+
+  // Sets the execution status and completes execution of the span.
+  void EndWithStatus(const absl::Status& status) {
+    SetStatus(status);
+    End();
+  }
+};
+
+}  // namespace credentio_tracing
+
+#endif  // THIRD_PARTY_CREDENTIO_TRACING_SPAN_H_
diff --git a/tracing/span_test.cc b/tracing/span_test.cc
new file mode 100644
index 0000000..a6c453c
--- /dev/null
+++ b/tracing/span_test.cc
@@ -0,0 +1,152 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+#include "tracing/span.h"
+
+#include <cstdint>
+#include <memory>
+#include <string>
+
+#include "absl/status/status.h"
+#include "absl/strings/string_view.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "tracing/noop_tracing.h"
+#include "tracing/tracer.h"
+#include "tracing/tracer_factory.h"
+
+namespace credentio_tracing {
+namespace {
+
+class MockSpan : public Span {
+ public:
+  MOCK_METHOD(void, SetAttribute, (absl::string_view key, AttributeValue value),
+              (override));
+  MOCK_METHOD(void, AddEvent, (absl::string_view name), (override));
+  MOCK_METHOD(void, SetStatus, (const absl::Status& status), (override));
+  MOCK_METHOD(void, End, (), (override));
+};
+
+TEST(AttributeValueTest, SupportsExpectedTypes) {
+  AttributeValue str_val = std::string("test_string");
+  EXPECT_EQ(std::get<std::string>(str_val), "test_string");
+
+  AttributeValue uint_val = uint64_t{42};
+  EXPECT_EQ(std::get<uint64_t>(uint_val), 42);
+
+  AttributeValue int_val = int64_t{42};
+  EXPECT_EQ(std::get<int64_t>(int_val), 42);
+
+  AttributeValue double_val = 3.14159;
+  EXPECT_DOUBLE_EQ(std::get<double>(double_val), 3.14159);
+
+  AttributeValue bool_val = true;
+  EXPECT_TRUE(std::get<bool>(bool_val));
+}
+
+TEST(SpanTest, MockSpanInvocations) {
+  MockSpan span;
+  EXPECT_CALL(span, SetAttribute("key", AttributeValue(int64_t{123})));
+  EXPECT_CALL(span, AddEvent("event_name"));
+  EXPECT_CALL(span, SetStatus(absl::OkStatus()));
+  EXPECT_CALL(span, End());
+
+  span.SetAttribute("key", int64_t{123});
+  span.AddEvent("event_name");
+  span.SetStatus(absl::OkStatus());
+  span.End();
+}
+
+TEST(SpanTest, EndWithStatusInvocations) {
+  MockSpan span;
+  absl::Status test_status = absl::InvalidArgumentError("invalid argument");
+  EXPECT_CALL(span, SetStatus(test_status));
+  EXPECT_CALL(span, End());
+
+  span.EndWithStatus(test_status);
+}
+
+TEST(NoopSpanTest, AllMethodsExecuteWithoutError) {
+  NoopSpan span;
+
+  span.SetAttribute("key_str", std::string("value"));
+  span.SetAttribute("key_int", int64_t{42});
+  span.SetAttribute("key_double", 3.14);
+  span.SetAttribute("key_bool", true);
+
+  span.AddEvent("test_event");
+  span.SetStatus(absl::OkStatus());
+  span.SetStatus(absl::InvalidArgumentError("invalid argument"));
+  span.End();
+}
+
+TEST(NoopTracerTest, StartSpanReturnsNonNullNoopSpan) {
+  NoopTracer tracer;
+  std::unique_ptr<Span> span = tracer.StartSpan("test_span");
+  ASSERT_NE(span, nullptr);
+
+  span->SetAttribute("key", int64_t{100});
+  span->AddEvent("event");
+  span->SetStatus(absl::OkStatus());
+  span->End();
+}
+
+TEST(TracerFactoryTest, DefaultGlobalFactoryIsNoopTracerFactory) {
+  auto factory = TracerFactory::GetGlobalFactory();
+  ASSERT_NE(factory, nullptr);
+  auto tracer = factory->GetTracer("test_tracer");
+  ASSERT_NE(tracer, nullptr);
+  auto span = tracer->StartSpan("test_span");
+  ASSERT_NE(span, nullptr);
+}
+
+class MockCustomTracerFactory : public TracerFactory {
+ public:
+  MOCK_METHOD(std::shared_ptr<Tracer>, GetTracer,
+              (absl::string_view tracer_name), (const, override));
+};
+
+TEST(TracerFactoryTest, GlobalFactoryRegistration) {
+  auto initial_factory = TracerFactory::GetGlobalFactory();
+  EXPECT_NE(initial_factory, nullptr);
+
+  auto custom_factory = std::make_shared<MockCustomTracerFactory>();
+  TracerFactory::RegisterGlobalFactory(custom_factory);
+
+  EXPECT_EQ(TracerFactory::GetGlobalFactory(), custom_factory);
+
+  // Restore original factory state after test
+  TracerFactory::RegisterGlobalFactory(initial_factory);
+}
+
+TEST(TracerFactoryTest, RegisterNullptrResetsToNoopTracerFactory) {
+  auto initial_factory = TracerFactory::GetGlobalFactory();
+  EXPECT_NE(initial_factory, nullptr);
+
+  auto custom_factory = std::make_shared<MockCustomTracerFactory>();
+  TracerFactory::RegisterGlobalFactory(custom_factory);
+  EXPECT_EQ(TracerFactory::GetGlobalFactory(), custom_factory);
+
+  TracerFactory::RegisterGlobalFactory(nullptr);
+  auto current_factory = TracerFactory::GetGlobalFactory();
+  ASSERT_NE(current_factory, nullptr);
+  EXPECT_NE(current_factory, custom_factory);
+
+  // Restore original factory state after test
+  TracerFactory::RegisterGlobalFactory(initial_factory);
+}
+
+}  // namespace
+}  // namespace credentio_tracing
diff --git a/tracing/test_tracing.cc b/tracing/test_tracing.cc
new file mode 100644
index 0000000..3bd6f84
--- /dev/null
+++ b/tracing/test_tracing.cc
@@ -0,0 +1,276 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+#include "tracing/test_tracing.h"
+
+#include <atomic>
+#include <cstdint>
+#include <memory>
+#include <optional>
+#include <string>
+#include <utility>
+#include <vector>
+
+#include "absl/status/status.h"
+#include "absl/strings/str_format.h"
+#include "absl/strings/string_view.h"
+#include "absl/synchronization/mutex.h"
+#include "absl/time/clock.h"
+#include "absl/time/time.h"
+#include "tracing/span.h"
+#include "tracing/tracer.h"
+#include "tracing/tracer_factory.h"
+
+namespace credentio_tracing {
+
+namespace {
+
+std::string GenerateTraceId() {
+  static std::atomic<uint64_t> counter{1};
+  uint64_t id = counter.fetch_add(1, std::memory_order_relaxed);
+  return absl::StrFormat("%032x", id);
+}
+
+std::string GenerateSpanId() {
+  static std::atomic<uint64_t> counter{1};
+  uint64_t id = counter.fetch_add(1, std::memory_order_relaxed);
+  return absl::StrFormat("%016x", id);
+}
+
+}  // namespace
+
+void TestRecordedSpan::SetAttribute(absl::string_view key,
+                                    AttributeValue value) {
+  absl::MutexLock lock(mutex_);
+  for (auto& [k, v] : attributes_) {
+    if (k == key) {
+      v = std::move(value);
+      return;
+    }
+  }
+  attributes_.push_back({std::string(key), std::move(value)});
+}
+
+void TestRecordedSpan::AddEvent(absl::string_view name) {
+  absl::MutexLock lock(mutex_);
+  events_.push_back({std::string(name), absl::Now()});
+}
+
+void TestRecordedSpan::SetStatus(const absl::Status& status) {
+  absl::MutexLock lock(mutex_);
+  status_ = status;
+}
+
+void TestRecordedSpan::End() {
+  absl::MutexLock lock(mutex_);
+  ended_ = true;
+  end_time_ = absl::Now();
+}
+
+bool TestRecordedSpan::ended() const {
+  absl::MutexLock lock(mutex_);
+  return ended_;
+}
+
+absl::Status TestRecordedSpan::status() const {
+  absl::MutexLock lock(mutex_);
+  return status_;
+}
+
+absl::Time TestRecordedSpan::end_time() const {
+  absl::MutexLock lock(mutex_);
+  return end_time_;
+}
+
+std::vector<std::pair<std::string, AttributeValue>>
+TestRecordedSpan::attributes() const {
+  absl::MutexLock lock(mutex_);
+  return attributes_;
+}
+
+std::vector<TestRecordedEvent> TestRecordedSpan::events() const {
+  absl::MutexLock lock(mutex_);
+  return events_;
+}
+
+std::optional<AttributeValue> TestRecordedSpan::GetAttribute(
+    absl::string_view key) const {
+  absl::MutexLock lock(mutex_);
+  for (const auto& [k, v] : attributes_) {
+    if (k == key) {
+      return v;
+    }
+  }
+  return std::nullopt;
+}
+
+TestSpan::TestSpan(std::shared_ptr<TestRecordedSpan> recorded_span)
+    : recorded_span_(std::move(recorded_span)) {}
+
+void TestSpan::SetAttribute(absl::string_view key, AttributeValue value) {
+  if (recorded_span_) {
+    recorded_span_->SetAttribute(key, std::move(value));
+  }
+}
+
+void TestSpan::AddEvent(absl::string_view name) {
+  if (recorded_span_) {
+    recorded_span_->AddEvent(name);
+  }
+}
+
+void TestSpan::SetStatus(const absl::Status& status) {
+  if (recorded_span_) {
+    recorded_span_->SetStatus(status);
+  }
+}
+
+void TestSpan::End() {
+  if (recorded_span_) {
+    recorded_span_->End();
+  }
+}
+
+std::shared_ptr<TestRecordedSpan> TestSpan::recorded_span() const {
+  return recorded_span_;
+}
+
+std::unique_ptr<Span> TestTracer::StartSpanImpl(
+    absl::string_view span_name, const StartSpanOptions& options) const {
+  auto recorded = std::make_shared<TestRecordedSpan>();
+  recorded->name_ = std::string(span_name);
+  recorded->start_time_ = absl::Now();
+  recorded->span_id_ = GenerateSpanId();
+
+  for (const auto& [k, v] : options.attributes) {
+    recorded->SetAttribute(k, v);
+  }
+
+  // `Tracer::StartSpan` already resolves the implicit parent from the current
+  // `ThreadContext`, so `options.parent` is authoritative here.
+  recorded->parent_ = options.parent;
+
+  // `dynamic_cast` yields nullptr for both a null parent and a parent that is
+  // not a `TestSpan`, in which case the span starts a new trace.
+  const auto* test_parent = dynamic_cast<const TestSpan*>(options.parent);
+  if (test_parent != nullptr && test_parent->recorded_span() != nullptr) {
+    recorded->parent_span_id_ = test_parent->recorded_span()->span_id_;
+    recorded->trace_id_ = test_parent->recorded_span()->trace_id_;
+  }
+  if (recorded->trace_id_.empty()) {
+    recorded->trace_id_ = GenerateTraceId();
+  }
+
+  absl::MutexLock lock(mutex_);
+  spans_.push_back(recorded);
+  return std::make_unique<TestSpan>(recorded);
+}
+
+std::vector<std::shared_ptr<TestRecordedSpan>> TestTracer::GetSpans() const {
+  absl::MutexLock lock(mutex_);
+  return spans_;
+}
+
+std::shared_ptr<TestRecordedSpan> TestTracer::GetSpan(
+    absl::string_view name) const {
+  absl::MutexLock lock(mutex_);
+  for (const auto& span : spans_) {
+    if (span->name_ == name) {
+      return span;
+    }
+  }
+  return nullptr;
+}
+
+std::vector<std::shared_ptr<TestRecordedSpan>> TestTracer::GetSpansByName(
+    absl::string_view name) const {
+  absl::MutexLock lock(mutex_);
+  std::vector<std::shared_ptr<TestRecordedSpan>> result;
+  for (const auto& span : spans_) {
+    if (span->name_ == name) {
+      result.push_back(span);
+    }
+  }
+  return result;
+}
+
+void TestTracer::Clear() {
+  absl::MutexLock lock(mutex_);
+  spans_.clear();
+}
+
+std::shared_ptr<TestTracerFactory> TestTracerFactory::Register() {
+  auto factory = std::make_shared<TestTracerFactory>();
+  TracerFactory::RegisterGlobalFactory(factory);
+  return factory;
+}
+
+void TestTracerFactory::Register(std::shared_ptr<TestTracerFactory> factory) {
+  TracerFactory::RegisterGlobalFactory(std::move(factory));
+}
+
+void TestTracerFactory::RegisterThis() {
+  TracerFactory::RegisterGlobalFactory(shared_from_this());
+}
+
+void TestTracerFactory::Unregister() {
+  TracerFactory::RegisterGlobalFactory(nullptr);
+}
+
+std::shared_ptr<Tracer> TestTracerFactory::GetTracer(
+    absl::string_view tracer_name) const {
+  absl::MutexLock lock(mutex_);
+  auto& tracer = tracers_[tracer_name];
+  if (tracer == nullptr) {
+    tracer = std::make_shared<TestTracer>();
+  }
+  return tracer;
+}
+
+std::shared_ptr<TestTracer> TestTracerFactory::GetTestTracer(
+    absl::string_view tracer_name) const {
+  absl::MutexLock lock(mutex_);
+  auto it = tracers_.find(tracer_name);
+  if (it != tracers_.end()) {
+    return it->second;
+  }
+  return nullptr;
+}
+
+std::vector<std::shared_ptr<TestRecordedSpan>> TestTracerFactory::GetAllSpans()
+    const {
+  std::vector<std::shared_ptr<TestTracer>> tracers_copy;
+  {
+    absl::MutexLock lock(mutex_);
+    tracers_copy.reserve(tracers_.size());
+    for (const auto& [name, tracer] : tracers_) {
+      tracers_copy.push_back(tracer);
+    }
+  }
+
+  std::vector<std::shared_ptr<TestRecordedSpan>> all_spans;
+  for (const auto& tracer : tracers_copy) {
+    auto spans = tracer->GetSpans();
+    all_spans.insert(all_spans.end(), spans.begin(), spans.end());
+  }
+  return all_spans;
+}
+
+void TestTracerFactory::Clear() {
+  absl::MutexLock lock(mutex_);
+  tracers_.clear();
+}
+
+}  // namespace credentio_tracing
diff --git a/tracing/test_tracing.h b/tracing/test_tracing.h
new file mode 100644
index 0000000..3791142
--- /dev/null
+++ b/tracing/test_tracing.h
@@ -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.
+//
+
+#ifndef THIRD_PARTY_CREDENTIO_TRACING_TEST_TRACING_H_
+#define THIRD_PARTY_CREDENTIO_TRACING_TEST_TRACING_H_
+
+#include <memory>
+#include <optional>
+#include <string>
+#include <utility>
+#include <variant>
+#include <vector>
+
+#include "absl/base/thread_annotations.h"
+#include "absl/container/flat_hash_map.h"
+#include "absl/status/status.h"
+#include "absl/strings/string_view.h"
+#include "absl/synchronization/mutex.h"
+#include "absl/time/clock.h"
+#include "absl/time/time.h"
+#include "tracing/span.h"
+#include "tracing/tracer.h"
+#include "tracing/tracer_factory.h"
+
+namespace credentio_tracing {
+
+// Simple struct representing a recorded point-in-time event within a span.
+struct TestRecordedEvent {
+  std::string name;
+  absl::Time time = absl::Now();
+
+  bool operator==(const TestRecordedEvent& other) const {
+    return name == other.name;
+  }
+};
+
+// Simple struct holding recorded span data for verification in tests.
+class TestRecordedSpan {
+ public:
+  TestRecordedSpan() = default;
+
+  std::string name_;
+  std::string trace_id_;
+  std::string span_id_;
+  std::string parent_span_id_;
+  const Span* parent_ = nullptr;
+  absl::Time start_time_ = absl::Now();
+
+  // Set or update an attribute key-value pair.
+  void SetAttribute(absl::string_view key, AttributeValue value);
+
+  // Record an event.
+  void AddEvent(absl::string_view name);
+
+  // Set status.
+  void SetStatus(const absl::Status& status);
+
+  // Mark span as ended.
+  void End();
+
+  // Returns true if the span has ended.
+  bool ended() const;
+
+  // Returns the recorded status.
+  absl::Status status() const;
+
+  // Returns the recorded end time.
+  absl::Time end_time() const;
+
+  // Returns a copy of recorded attributes.
+  std::vector<std::pair<std::string, AttributeValue>> attributes() const;
+
+  // Returns a copy of recorded events.
+  std::vector<TestRecordedEvent> events() const;
+
+  // Returns optional copy of attribute value for specified key.
+  std::optional<AttributeValue> GetAttribute(absl::string_view key) const;
+
+  // Returns the attribute value converted to type T if found and matching, or
+  // std::nullopt.
+  template <typename T>
+  std::optional<T> GetAttributeAs(absl::string_view key) const {
+    std::optional<AttributeValue> val = GetAttribute(key);
+    if (val && std::holds_alternative<T>(*val)) {
+      return std::get<T>(*val);
+    }
+    return std::nullopt;
+  }
+
+ private:
+  mutable absl::Mutex mutex_;
+  std::vector<std::pair<std::string, AttributeValue>> attributes_
+      ABSL_GUARDED_BY(mutex_);
+  std::vector<TestRecordedEvent> events_ ABSL_GUARDED_BY(mutex_);
+  absl::Status status_ ABSL_GUARDED_BY(mutex_) = absl::OkStatus();
+  bool ended_ ABSL_GUARDED_BY(mutex_) = false;
+  absl::Time end_time_ ABSL_GUARDED_BY(mutex_) = absl::InfinitePast();
+};
+
+// Test implementation of Span that records operations to a TestRecordedSpan
+// struct.
+class TestSpan : public Span {
+ public:
+  explicit TestSpan(std::shared_ptr<TestRecordedSpan> recorded_span);
+
+  void SetAttribute(absl::string_view key, AttributeValue value) override;
+  void AddEvent(absl::string_view name) override;
+  void SetStatus(const absl::Status& status) override;
+  void End() override;
+
+  // Returns the underlying TestRecordedSpan struct pointer.
+  std::shared_ptr<TestRecordedSpan> recorded_span() const;
+
+ private:
+  const std::shared_ptr<TestRecordedSpan> recorded_span_;
+};
+
+// Test implementation of Tracer that records created spans.
+class TestTracer : public Tracer {
+ public:
+  using Tracer::StartSpan;
+
+  TestTracer() = default;
+
+  std::unique_ptr<Span> StartSpanImpl(
+      absl::string_view span_name,
+      const StartSpanOptions& options) const override;
+
+  // Returns all recorded spans.
+  std::vector<std::shared_ptr<TestRecordedSpan>> GetSpans() const;
+
+  // Returns the first span matching the given name, or nullptr if not found.
+  std::shared_ptr<TestRecordedSpan> GetSpan(absl::string_view name) const;
+
+  // Returns all spans matching the given name.
+  std::vector<std::shared_ptr<TestRecordedSpan>> GetSpansByName(
+      absl::string_view name) const;
+
+  // Clears all recorded spans.
+  void Clear();
+
+ private:
+  mutable absl::Mutex mutex_;
+  mutable std::vector<std::shared_ptr<TestRecordedSpan>> spans_
+      ABSL_GUARDED_BY(mutex_);
+};
+
+// Test implementation of TracerFactory that manages TestTracer instances.
+class TestTracerFactory
+    : public TracerFactory,
+      public std::enable_shared_from_this<TestTracerFactory> {
+ public:
+  TestTracerFactory() = default;
+
+  // Creates a new TestTracerFactory instance, registers it as the global
+  // factory, and returns it.
+  static std::shared_ptr<TestTracerFactory> Register();
+
+  // Registers the specified factory instance as the global factory.
+  static void Register(std::shared_ptr<TestTracerFactory> factory);
+
+  // Registers this TestTracerFactory instance as the global factory.
+  void RegisterThis();
+
+  // Unregisters the global TracerFactory instance, resetting to default.
+  static void Unregister();
+
+  std::shared_ptr<Tracer> GetTracer(
+      absl::string_view tracer_name) const override;
+
+  // Returns the TestTracer associated with tracer_name, or nullptr if not
+  // found.
+  std::shared_ptr<TestTracer> GetTestTracer(
+      absl::string_view tracer_name) const;
+
+  // Returns all recorded spans across all managed tracers.
+  std::vector<std::shared_ptr<TestRecordedSpan>> GetAllSpans() const;
+
+  // Clears all registered tracers and their recorded spans.
+  void Clear();
+
+ private:
+  mutable absl::Mutex mutex_;
+  mutable absl::flat_hash_map<std::string, std::shared_ptr<TestTracer>> tracers_
+      ABSL_GUARDED_BY(mutex_);
+};
+
+}  // namespace credentio_tracing
+
+#endif  // THIRD_PARTY_CREDENTIO_TRACING_TEST_TRACING_H_
diff --git a/tracing/test_tracing_test.cc b/tracing/test_tracing_test.cc
new file mode 100644
index 0000000..4234896
--- /dev/null
+++ b/tracing/test_tracing_test.cc
@@ -0,0 +1,322 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+#include "tracing/test_tracing.h"
+
+#include <cstdint>
+#include <memory>
+#include <optional>
+#include <string>
+#include <thread>  // NOLINT(build/c++11)
+#include <vector>
+
+#include "absl/status/status.h"
+#include "absl/strings/string_view.h"
+#include "absl/time/clock.h"
+#include "absl/time/time.h"
+#include "gtest/gtest.h"
+#include "tracing/scoped_span.h"
+#include "tracing/span.h"
+#include "tracing/tracer.h"
+#include "tracing/tracer_factory.h"
+
+namespace credentio_tracing {
+namespace {
+
+TEST(TestRecordedSpanTest, GetAttributeAndGetAttributeAs) {
+  TestRecordedSpan span;
+  span.SetAttribute("string_key", std::string("val"));
+  span.SetAttribute("int_key", int64_t{42});
+  span.SetAttribute("double_key", 3.14);
+  span.SetAttribute("bool_key", true);
+
+  EXPECT_NE(span.GetAttribute("string_key"), std::nullopt);
+  EXPECT_EQ(span.GetAttribute("nonexistent"), std::nullopt);
+
+  EXPECT_EQ(span.GetAttributeAs<std::string>("string_key"), "val");
+  EXPECT_EQ(span.GetAttributeAs<int64_t>("int_key"), 42);
+  EXPECT_EQ(span.GetAttributeAs<double>("double_key"), 3.14);
+  EXPECT_EQ(span.GetAttributeAs<bool>("bool_key"), true);
+
+  // Type mismatch returns std::nullopt
+  EXPECT_EQ(span.GetAttributeAs<int64_t>("string_key"), std::nullopt);
+  EXPECT_EQ(span.GetAttributeAs<std::string>("nonexistent"), std::nullopt);
+}
+
+TEST(TestSpanTest, RecordsAttributesEventsStatusAndEnded) {
+  auto recorded = std::make_shared<TestRecordedSpan>();
+  TestSpan span(recorded);
+
+  span.SetAttribute("attr1", std::string("val1"));
+  span.SetAttribute("attr2", int64_t{100});
+  // Overwrite existing attribute
+  span.SetAttribute("attr1", std::string("new_val1"));
+
+  span.AddEvent("event1");
+  span.AddEvent("event2");
+
+  span.SetStatus(absl::InvalidArgumentError("bad input"));
+  span.End();
+
+  EXPECT_TRUE(recorded->ended());
+  EXPECT_EQ(recorded->status(), absl::InvalidArgumentError("bad input"));
+
+  ASSERT_EQ(recorded->events().size(), 2);
+  EXPECT_EQ(recorded->events()[0].name, "event1");
+  EXPECT_EQ(recorded->events()[1].name, "event2");
+
+  EXPECT_EQ(recorded->GetAttributeAs<std::string>("attr1"), "new_val1");
+  EXPECT_EQ(recorded->GetAttributeAs<int64_t>("attr2"), 100);
+}
+
+TEST(TestTracerTest, StartSpanRecordsOptionsAndParent) {
+  TestTracer tracer;
+  TestSpan parent_span(std::make_shared<TestRecordedSpan>());
+
+  StartSpanOptions options;
+  options.parent = &parent_span;
+  options.attributes.push_back({"init_key", std::string("init_val")});
+
+  std::unique_ptr<Span> span = tracer.StartSpan("child_span", options);
+  ASSERT_NE(span, nullptr);
+
+  auto spans = tracer.GetSpans();
+  ASSERT_EQ(spans.size(), 1);
+  EXPECT_EQ(spans[0]->name_, "child_span");
+  EXPECT_EQ(spans[0]->parent_, &parent_span);
+  EXPECT_EQ(spans[0]->GetAttributeAs<std::string>("init_key"), "init_val");
+
+  span->End();
+  EXPECT_TRUE(spans[0]->ended());
+}
+
+TEST(TestTracerTest, QueryingSpansByName) {
+  TestTracer tracer;
+  tracer.StartSpan("op_a", {});
+  tracer.StartSpan("op_b", {});
+  tracer.StartSpan("op_a", {});
+
+  EXPECT_EQ(tracer.GetSpans().size(), 3);
+
+  auto span_a = tracer.GetSpan("op_a");
+  ASSERT_NE(span_a, nullptr);
+  EXPECT_EQ(span_a->name_, "op_a");
+
+  EXPECT_EQ(tracer.GetSpan("nonexistent"), nullptr);
+
+  auto spans_a = tracer.GetSpansByName("op_a");
+  EXPECT_EQ(spans_a.size(), 2);
+
+  tracer.Clear();
+  EXPECT_TRUE(tracer.GetSpans().empty());
+}
+
+TEST(TestTracerTest, IntegrationWithScopedSpan) {
+  TestTracer tracer;
+  {
+    ScopedSpan scoped_span(tracer.StartSpan("scoped_op", {}));
+    ASSERT_NE(scoped_span.get(), nullptr);
+    scoped_span->SetAttribute("key", std::string("value"));
+    scoped_span.SetStatus(absl::OkStatus());
+  }
+
+  auto spans = tracer.GetSpans();
+  ASSERT_EQ(spans.size(), 1);
+  EXPECT_EQ(spans[0]->name_, "scoped_op");
+  EXPECT_TRUE(spans[0]->ended());
+  EXPECT_EQ(spans[0]->GetAttributeAs<std::string>("key"), "value");
+}
+
+TEST(TestTracerFactoryTest, GetTracerAndRegisterGlobalFactory) {
+  auto factory = std::make_shared<TestTracerFactory>();
+  TestTracerFactory::Register(factory);
+
+  auto current_factory = TracerFactory::GetGlobalFactory();
+  EXPECT_EQ(current_factory, factory);
+
+  auto tracer1 = factory->GetTracer("component_1");
+  auto tracer2 = factory->GetTracer("component_1");
+  EXPECT_EQ(tracer1, tracer2);
+
+  tracer1->StartSpan("span_1", {});
+
+  auto test_tracer = factory->GetTestTracer("component_1");
+  ASSERT_NE(test_tracer, nullptr);
+  EXPECT_EQ(test_tracer->GetSpans().size(), 1);
+
+  EXPECT_EQ(factory->GetAllSpans().size(), 1);
+
+  factory->Clear();
+  EXPECT_TRUE(factory->GetAllSpans().empty());
+
+  TestTracerFactory::Unregister();
+  EXPECT_NE(TracerFactory::GetGlobalFactory(), factory);
+}
+
+TEST(TestTracerFactoryTest, RegisterStaticHelper) {
+  auto factory = TestTracerFactory::Register();
+  EXPECT_EQ(TracerFactory::GetGlobalFactory(), factory);
+
+  auto tracer = TracerFactory::GetGlobalFactory()->GetTracer("test_comp");
+  tracer->StartSpan("span", {});
+  EXPECT_EQ(factory->GetAllSpans().size(), 1);
+
+  TestTracerFactory::Unregister();
+  EXPECT_NE(TracerFactory::GetGlobalFactory(), factory);
+}
+
+TEST(TestTracerFactoryTest, RegisterThisMemberMethod) {
+  auto factory = std::make_shared<TestTracerFactory>();
+  factory->RegisterThis();
+  EXPECT_EQ(TracerFactory::GetGlobalFactory(), factory);
+
+  factory->Unregister();
+  EXPECT_NE(TracerFactory::GetGlobalFactory(), factory);
+}
+
+TEST(TestTracerTest, MultithreadedSpanCreation) {
+  TestTracer tracer;
+  constexpr int kNumThreads = 4;
+  constexpr int kSpansPerThread = 50;
+
+  std::vector<std::thread> threads;
+  threads.reserve(kNumThreads);
+
+  for (int t = 0; t < kNumThreads; ++t) {
+    threads.emplace_back([&tracer, t]() {
+      for (int i = 0; i < kSpansPerThread; ++i) {
+        auto span = tracer.StartSpan("mt_span", {});
+        span->SetAttribute("thread", int64_t{t});
+        span->SetAttribute("iter", int64_t{i});
+        span->AddEvent("tick");
+        span->SetStatus(absl::OkStatus());
+        span->End();
+      }
+    });
+  }
+
+  for (auto& thread : threads) {
+    thread.join();
+  }
+
+  EXPECT_EQ(tracer.GetSpans().size(), kNumThreads * kSpansPerThread);
+}
+
+TEST(TestTracerTest, ImplicitParentLinkingWithScopedSpan) {
+  TestTracer tracer;
+  {
+    ScopedSpan outer_span(tracer.StartSpan("outer", {}));
+    {
+      ScopedSpan inner_span(tracer.StartSpan("inner", {}));
+    }
+  }
+
+  auto spans = tracer.GetSpans();
+  ASSERT_EQ(spans.size(), 2);
+
+  EXPECT_EQ(spans[0]->name_, "outer");
+  EXPECT_EQ(spans[1]->name_, "inner");
+
+  EXPECT_FALSE(spans[0]->trace_id_.empty());
+  EXPECT_EQ(spans[0]->trace_id_, spans[1]->trace_id_);
+  EXPECT_EQ(spans[1]->parent_span_id_, spans[0]->span_id_);
+}
+
+TEST(TestRecordedEventTest, EqualityOperator) {
+  TestRecordedEvent event1{"event_name"};
+  TestRecordedEvent event2{"event_name"};
+  TestRecordedEvent event3{"different_name"};
+
+  EXPECT_EQ(event1, event2);
+  EXPECT_NE(event1.name, event3.name);
+}
+
+TEST(TestRecordedSpanTest, EndTimeAndAttributesAccessors) {
+  TestRecordedSpan span;
+  EXPECT_FALSE(span.ended());
+
+  absl::Time start = absl::Now();
+  span.SetAttribute("key1", std::string("val1"));
+  span.SetAttribute("key2", int64_t{50});
+  span.End();
+
+  EXPECT_TRUE(span.ended());
+  EXPECT_GE(span.end_time(), start);
+
+  auto attrs = span.attributes();
+  ASSERT_EQ(attrs.size(), 2);
+  EXPECT_EQ(attrs[0].first, "key1");
+  EXPECT_EQ(std::get<std::string>(attrs[0].second), "val1");
+  EXPECT_EQ(attrs[1].first, "key2");
+  EXPECT_EQ(std::get<int64_t>(attrs[1].second), 50);
+}
+
+TEST(TestSpanTest, NullTestRecordedSpanHandledSafely) {
+  TestSpan span(nullptr);
+  EXPECT_EQ(span.recorded_span(), nullptr);
+
+  // Calling methods on TestSpan with null recorded_span should not crash
+  span.SetAttribute("key", std::string("val"));
+  span.AddEvent("event");
+  span.SetStatus(absl::OkStatus());
+  span.End();
+}
+
+TEST(TestTracerTest, NonTestSpanParentHandling) {
+  TestTracer tracer;
+  class NonTestSpan : public Span {
+   public:
+    void SetAttribute(absl::string_view, AttributeValue) override {}
+    void AddEvent(absl::string_view) override {}
+    void SetStatus(const absl::Status&) override {}
+    void End() override {}
+  };
+
+  NonTestSpan custom_parent;
+  StartSpanOptions options;
+  options.parent = &custom_parent;
+
+  auto span = tracer.StartSpan("span_with_custom_parent", options);
+  ASSERT_NE(span, nullptr);
+
+  auto spans = tracer.GetSpans();
+  ASSERT_EQ(spans.size(), 1);
+  EXPECT_EQ(spans[0]->parent_, &custom_parent);
+  EXPECT_TRUE(spans[0]->parent_span_id_.empty());
+  EXPECT_FALSE(spans[0]->trace_id_.empty());
+}
+
+TEST(TestTracerTest, ParentTestSpanWithoutTestRecordedSpanStartsNewTrace) {
+  TestTracer tracer;
+  TestSpan parent_without_recording(nullptr);
+
+  StartSpanOptions options;
+  options.parent = &parent_without_recording;
+  tracer.StartSpan("orphan_op", options);
+
+  auto spans = tracer.GetSpans();
+  ASSERT_EQ(spans.size(), 1);
+  EXPECT_EQ(spans[0]->parent_, &parent_without_recording);
+  EXPECT_TRUE(spans[0]->parent_span_id_.empty());
+  EXPECT_FALSE(spans[0]->trace_id_.empty());
+}
+
+TEST(TestTracerFactoryTest, GetTestTracerReturnsNullForNonexistent) {
+  TestTracerFactory factory;
+  EXPECT_EQ(factory.GetTestTracer("nonexistent_tracer"), nullptr);
+}
+
+}  // namespace
+}  // namespace credentio_tracing
diff --git a/tracing/thread_context.cc b/tracing/thread_context.cc
new file mode 100644
index 0000000..554a0a0
--- /dev/null
+++ b/tracing/thread_context.cc
@@ -0,0 +1,35 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+#include "tracing/thread_context.h"
+
+#include "absl/base/nullability.h"
+#include "tracing/span.h"
+
+namespace credentio_tracing {
+
+namespace {
+
+thread_local Span* g_current_span = nullptr;
+
+}  // namespace
+
+Span* absl_nullable ThreadContext::GetCurrentSpan() { return g_current_span; }
+
+void ThreadContext::SetCurrentSpan(Span* absl_nullable span) {
+  g_current_span = span;
+}
+
+}  // namespace credentio_tracing
diff --git a/tracing/thread_context.h b/tracing/thread_context.h
new file mode 100644
index 0000000..4f9de4a
--- /dev/null
+++ b/tracing/thread_context.h
@@ -0,0 +1,41 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+#ifndef THIRD_PARTY_CREDENTIO_TRACING_THREAD_CONTEXT_H_
+#define THIRD_PARTY_CREDENTIO_TRACING_THREAD_CONTEXT_H_
+
+#include "absl/base/nullability.h"
+#include "tracing/span.h"
+
+namespace credentio_tracing {
+
+// Manages thread-local active span state across execution boundaries.
+class ThreadContext {
+ public:
+  // Returns the currently active Span on the calling thread, or nullptr if
+  // none.
+  static Span* absl_nullable GetCurrentSpan();
+
+  // Sets the active Span for the calling thread.
+  //
+  // Does not take ownership of `span`. The caller is responsible for ensuring
+  // `span` remains valid while set as active on the calling thread, and is
+  // unset or restored before `span` is destroyed.
+  static void SetCurrentSpan(Span* absl_nullable span);
+};
+
+}  // namespace credentio_tracing
+
+#endif  // THIRD_PARTY_CREDENTIO_TRACING_THREAD_CONTEXT_H_
diff --git a/tracing/thread_context_test.cc b/tracing/thread_context_test.cc
new file mode 100644
index 0000000..508e283
--- /dev/null
+++ b/tracing/thread_context_test.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 "tracing/thread_context.h"
+
+#include <thread>  // NOLINT(build/c++11)
+
+#include "gtest/gtest.h"
+#include "tracing/noop_tracing.h"
+
+namespace credentio_tracing {
+namespace {
+
+TEST(ThreadContextTest, InitialStateIsNull) {
+  EXPECT_EQ(ThreadContext::GetCurrentSpan(), nullptr);
+}
+
+TEST(ThreadContextTest, SetAndGetCurrentSpan) {
+  NoopSpan span1;
+  NoopSpan span2;
+
+  EXPECT_EQ(ThreadContext::GetCurrentSpan(), nullptr);
+
+  ThreadContext::SetCurrentSpan(&span1);
+  EXPECT_EQ(ThreadContext::GetCurrentSpan(), &span1);
+
+  ThreadContext::SetCurrentSpan(&span2);
+  EXPECT_EQ(ThreadContext::GetCurrentSpan(), &span2);
+
+  ThreadContext::SetCurrentSpan(nullptr);
+  EXPECT_EQ(ThreadContext::GetCurrentSpan(), nullptr);
+}
+
+TEST(ThreadContextTest, ThreadLocalIsolation) {
+  NoopSpan main_span;
+
+  ThreadContext::SetCurrentSpan(&main_span);
+
+  std::thread worker([&]() {
+    EXPECT_EQ(ThreadContext::GetCurrentSpan(), nullptr);
+
+    NoopSpan worker_span;
+
+    ThreadContext::SetCurrentSpan(&worker_span);
+
+    EXPECT_EQ(ThreadContext::GetCurrentSpan(), &worker_span);
+  });
+
+  worker.join();
+
+  EXPECT_EQ(ThreadContext::GetCurrentSpan(), &main_span);
+
+  ThreadContext::SetCurrentSpan(nullptr);
+}
+
+TEST(ThreadContextTest, SetNullWhenAlreadyNull) {
+  EXPECT_EQ(ThreadContext::GetCurrentSpan(), nullptr);
+
+  ThreadContext::SetCurrentSpan(nullptr);
+
+  EXPECT_EQ(ThreadContext::GetCurrentSpan(), nullptr);
+}
+
+}  // namespace
+}  // namespace credentio_tracing
diff --git a/tracing/tracer.h b/tracing/tracer.h
new file mode 100644
index 0000000..1be4ff8
--- /dev/null
+++ b/tracing/tracer.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_TRACING_TRACER_H_
+#define THIRD_PARTY_CREDENTIO_TRACING_TRACER_H_
+
+#include <memory>
+#include <string>
+#include <utility>
+#include <vector>
+
+#include "absl/strings/string_view.h"
+#include "tracing/span.h"
+#include "tracing/thread_context.h"
+
+namespace credentio_tracing {
+
+// Configuration options passed when starting a new span.
+struct StartSpanOptions {
+  // Optional parent span to establish parent-child relationship.
+  Span* parent = nullptr;
+
+  // Initial key-value attribute pairs attached to the span upon creation.
+  std::vector<std::pair<std::string, AttributeValue>> attributes;
+};
+
+// Abstract interface for creating tracing spans.
+class Tracer {
+ public:
+  virtual ~Tracer() = default;
+
+  // Convenience overload to start a span with default options.
+  std::unique_ptr<Span> StartSpan(absl::string_view span_name) const {
+    return StartSpan(span_name, {});
+  }
+
+  // Creates and starts a new Span with the given name and options.
+  std::unique_ptr<Span> StartSpan(absl::string_view span_name,
+                                  StartSpanOptions options) const {
+    // If no parent span is specified, use the current thread context span
+    if (options.parent == nullptr) {
+      options.parent = ThreadContext::GetCurrentSpan();
+    }
+    return StartSpanImpl(span_name, options);
+  }
+
+ protected:
+  virtual std::unique_ptr<Span> StartSpanImpl(
+      absl::string_view span_name, const StartSpanOptions& options) const = 0;
+};
+
+}  // namespace credentio_tracing
+
+#endif  // THIRD_PARTY_CREDENTIO_TRACING_TRACER_H_
diff --git a/tracing/tracer_factory.cc b/tracing/tracer_factory.cc
new file mode 100644
index 0000000..eaacba9
--- /dev/null
+++ b/tracing/tracer_factory.cc
@@ -0,0 +1,108 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+#include "tracing/tracer_factory.h"
+
+#include <memory>
+#include <utility>
+
+#include "absl/base/attributes.h"
+#include "absl/base/const_init.h"
+#include "absl/base/nullability.h"
+#include "absl/strings/string_view.h"
+#include "absl/synchronization/mutex.h"
+#include "async/async_context.h"
+#include "tracing/noop_tracing.h"
+#include "tracing/tracer.h"
+
+namespace credentio_tracing {
+
+namespace {
+
+ABSL_CONST_INIT absl::Mutex g_factory_mutex(absl::kConstInit);
+
+// Returns a reference to the global heap-allocated shared_ptr holding the
+// registered TracerFactory instance. Defaults to NoopTracerFactory.
+std::shared_ptr<TracerFactory>& GlobalFactoryStorage() {
+  static auto* factory =
+      new std::shared_ptr<TracerFactory>(std::make_shared<NoopTracerFactory>());
+  return *factory;
+}
+
+// Returns a reference to the calling thread's factory, which is null unless
+// one has been installed.
+//
+// Unlike the global storage above, this is held by value so that it is released
+// when the thread exits; a heap allocation here would be reported as a leak by
+// every thread that installs a factory.
+std::shared_ptr<TracerFactory>& ThreadLocalFactoryStorage() {
+  static thread_local std::shared_ptr<TracerFactory> factory;
+  return factory;
+}
+
+}  // namespace
+
+void TracerFactory::RegisterGlobalFactory(
+    absl_nullable std::shared_ptr<TracerFactory> factory) {
+  absl::MutexLock lock(g_factory_mutex);
+  if (factory == nullptr) {
+    factory = std::make_shared<NoopTracerFactory>();
+  }
+  GlobalFactoryStorage() = std::move(factory);
+}
+
+absl_nonnull std::shared_ptr<TracerFactory> TracerFactory::GetGlobalFactory() {
+  absl::MutexLock lock(g_factory_mutex);
+  return GlobalFactoryStorage();
+}
+
+void TracerFactory::SetThreadLocalFactory(
+    absl_nullable std::shared_ptr<TracerFactory> factory) {
+  ThreadLocalFactoryStorage() = std::move(factory);
+}
+
+absl_nullable std::shared_ptr<TracerFactory>
+TracerFactory::GetThreadLocalFactory() {
+  return ThreadLocalFactoryStorage();
+}
+
+absl_nonnull std::shared_ptr<TracerFactory> TracerFactory::Get() {
+  if (std::shared_ptr<TracerFactory> factory = ThreadLocalFactoryStorage();
+      factory != nullptr) {
+    return factory;
+  }
+  return GetGlobalFactory();
+}
+
+absl_nonnull std::shared_ptr<TracerFactory> ResolveTracerFactory(
+    const credentio::AsyncContext& context) {
+  if (std::shared_ptr<TracerFactory> factory =
+          context.GetExtension<TracerFactory>();
+      factory != nullptr) {
+    return factory;
+  }
+  return TracerFactory::Get();
+}
+
+absl_nonnull std::shared_ptr<Tracer> GetTracer(
+    absl::string_view tracer_name, const credentio::AsyncContext& context) {
+  return ResolveTracerFactory(context)->GetTracer(tracer_name);
+}
+
+absl_nonnull std::shared_ptr<Tracer> GetTracer(absl::string_view tracer_name) {
+  return TracerFactory::Get()->GetTracer(tracer_name);
+}
+
+}  // namespace credentio_tracing
diff --git a/tracing/tracer_factory.h b/tracing/tracer_factory.h
new file mode 100644
index 0000000..b5d2378
--- /dev/null
+++ b/tracing/tracer_factory.h
@@ -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.
+//
+
+#ifndef THIRD_PARTY_CREDENTIO_TRACING_TRACER_FACTORY_H_
+#define THIRD_PARTY_CREDENTIO_TRACING_TRACER_FACTORY_H_
+
+#include <memory>
+
+#include "absl/base/nullability.h"
+#include "absl/strings/string_view.h"
+#include "async/async_context.h"
+#include "tracing/tracer.h"
+
+namespace credentio_tracing {
+
+// Abstract factory interface and registry for obtaining Tracer instances.
+//
+// A factory can be installed at three scopes, consulted in this order:
+//
+//   1. Per operation, by attaching it to the `credentio::AsyncContext` passed
+//   to the
+//      operation (`context.SetExtension<TracerFactory>(factory)`). This is the
+//      only scope that isolates concurrent operations from one another, and the
+//      only one that follows the work across executor hops. Prefer it for any
+//      tracer that accumulates per-operation state.
+//   2. Per thread, via `SetThreadLocalFactory` (or the `ScopedTracerFactory`
+//      guard). Suitable for synchronous callers that cannot thread a context
+//      through; it does *not* follow work handed to another thread.
+//   3. Process-wide, via `RegisterGlobalFactory`. Suitable for a tracer that
+//      is safe to share across all operations, such as one exporting to a
+//      metrics backend.
+//
+// If none is installed the no-op factory is used.
+class TracerFactory {
+ public:
+  virtual ~TracerFactory() = default;
+
+  // Obtains a Tracer associated with the specified component or module name.
+  virtual absl_nonnull std::shared_ptr<Tracer> GetTracer(
+      absl::string_view tracer_name) const = 0;
+
+  // Registers the global TracerFactory instance. If passed nullptr, resets to
+  // the default NoopTracerFactory instance. Thread-safe.
+  static void RegisterGlobalFactory(
+      absl_nullable std::shared_ptr<TracerFactory> factory);
+
+  // Returns the current global TracerFactory instance. Thread-safe.
+  static absl_nonnull std::shared_ptr<TracerFactory> GetGlobalFactory();
+
+  // Installs `factory` for the calling thread only, shadowing the global
+  // factory. Passing nullptr clears it. Prefer `ScopedTracerFactory`, which
+  // guarantees the factory is cleared on the same thread that installed it.
+  static void SetThreadLocalFactory(
+      absl_nullable std::shared_ptr<TracerFactory> factory);
+
+  // Returns the factory installed for the calling thread, or nullptr.
+  static absl_nullable std::shared_ptr<TracerFactory> GetThreadLocalFactory();
+
+  // Returns the thread-local factory if one is installed, otherwise the global
+  // one. Never returns nullptr.
+  static absl_nonnull std::shared_ptr<TracerFactory> Get();
+};
+
+// Returns the factory that should be used for work performed under `context`:
+// the factory carried by the context if it has one, otherwise the thread-local
+// factory, otherwise the global one. Never returns nullptr.
+absl_nonnull std::shared_ptr<TracerFactory> ResolveTracerFactory(
+    const credentio::AsyncContext& context);
+
+// Returns a tracer named `tracer_name` from the factory resolved for
+// `context`. This is the form instrumentation should use: passing the context
+// keeps spans with the operation they belong to.
+absl_nonnull std::shared_ptr<Tracer> GetTracer(
+    absl::string_view tracer_name, const credentio::AsyncContext& context);
+
+// As above, for synchronous code with no context to hand. Resolves against the
+// thread-local factory, then the global one.
+absl_nonnull std::shared_ptr<Tracer> GetTracer(absl::string_view tracer_name);
+
+}  // namespace credentio_tracing
+
+#endif  // THIRD_PARTY_CREDENTIO_TRACING_TRACER_FACTORY_H_
diff --git a/tracing/tracer_test.cc b/tracing/tracer_test.cc
new file mode 100644
index 0000000..af88656
--- /dev/null
+++ b/tracing/tracer_test.cc
@@ -0,0 +1,346 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+#include "tracing/tracer.h"
+
+#include <cstdint>
+#include <memory>
+#include <string>
+#include <thread>  // NOLINT(build/c++11)
+#include <vector>
+
+#include "absl/base/nullability.h"
+#include "absl/status/status.h"
+#include "absl/strings/string_view.h"
+#include "async/async_context.h"
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "tracing/noop_tracing.h"
+#include "tracing/scoped_span.h"
+#include "tracing/scoped_tracer_factory.h"
+#include "tracing/span.h"
+#include "tracing/thread_context.h"
+#include "tracing/tracer_factory.h"
+
+namespace credentio_tracing {
+namespace {
+
+using ::testing::_;
+using ::testing::Eq;
+using ::testing::Return;
+using ::testing::StrictMock;
+
+class MockTracer : public Tracer {
+ public:
+  MOCK_METHOD(std::unique_ptr<Span>, StartSpanImpl,
+              (absl::string_view span_name, const StartSpanOptions& options),
+              (const, override));
+};
+
+class MockTracerFactory : public TracerFactory {
+ public:
+  MOCK_METHOD(absl_nonnull std::shared_ptr<Tracer>, GetTracer,
+              (absl::string_view tracer_name), (const, override));
+};
+
+TEST(StartSpanOptionsTest, DefaultOptions) {
+  StartSpanOptions options;
+  EXPECT_EQ(options.parent, nullptr);
+  EXPECT_TRUE(options.attributes.empty());
+}
+
+TEST(StartSpanOptionsTest, CustomOptions) {
+  NoopSpan parent_span;
+  StartSpanOptions options;
+  options.parent = &parent_span;
+  options.attributes.push_back({"key1", std::string("val1")});
+  options.attributes.push_back({"key2", int64_t{100}});
+
+  EXPECT_EQ(options.parent, &parent_span);
+  ASSERT_EQ(options.attributes.size(), 2);
+  EXPECT_EQ(options.attributes[0].first, "key1");
+  EXPECT_EQ(std::get<std::string>(options.attributes[0].second), "val1");
+  EXPECT_EQ(options.attributes[1].first, "key2");
+  EXPECT_EQ(std::get<int64_t>(options.attributes[1].second), 100);
+}
+
+TEST(TracerTest, StartSpanInvocation) {
+  StrictMock<MockTracer> mock_tracer;
+  EXPECT_CALL(mock_tracer, StartSpanImpl(Eq("test_span"), _))
+      .WillOnce([](absl::string_view, const StartSpanOptions& options) {
+        EXPECT_EQ(options.parent, nullptr);
+        return std::make_unique<NoopSpan>();
+      });
+
+  std::unique_ptr<Span> span = mock_tracer.StartSpan("test_span", {});
+  EXPECT_NE(span, nullptr);
+}
+
+TEST(TracerTest, StartSpanSingleArgumentOverload) {
+  StrictMock<MockTracer> mock_tracer;
+  EXPECT_CALL(mock_tracer, StartSpanImpl(Eq("simple_span"), _))
+      .WillOnce([](absl::string_view, const StartSpanOptions& options) {
+        EXPECT_EQ(options.parent, nullptr);
+        EXPECT_TRUE(options.attributes.empty());
+        return std::make_unique<NoopSpan>();
+      });
+
+  std::unique_ptr<Span> span = mock_tracer.StartSpan("simple_span");
+  EXPECT_NE(span, nullptr);
+}
+
+TEST(TracerTest, StartSpanImplicitParentFromThreadContext) {
+  NoopSpan active_thread_span;
+  ThreadContext::SetCurrentSpan(&active_thread_span);
+
+  StrictMock<MockTracer> mock_tracer;
+  EXPECT_CALL(mock_tracer, StartSpanImpl(Eq("child_span"), _))
+      .WillOnce([&active_thread_span](absl::string_view,
+                                      const StartSpanOptions& options) {
+        EXPECT_EQ(options.parent, &active_thread_span);
+        return std::make_unique<NoopSpan>();
+      });
+
+  std::unique_ptr<Span> span = mock_tracer.StartSpan("child_span");
+  EXPECT_NE(span, nullptr);
+
+  ThreadContext::SetCurrentSpan(nullptr);
+}
+
+TEST(TracerTest, StartSpanExplicitParentOverridesThreadContext) {
+  NoopSpan active_thread_span;
+  NoopSpan explicit_parent_span;
+  ThreadContext::SetCurrentSpan(&active_thread_span);
+
+  StrictMock<MockTracer> mock_tracer;
+  StartSpanOptions options;
+  options.parent = &explicit_parent_span;
+
+  EXPECT_CALL(mock_tracer, StartSpanImpl(Eq("child_span"), _))
+      .WillOnce([&explicit_parent_span](absl::string_view,
+                                        const StartSpanOptions& options) {
+        EXPECT_EQ(options.parent, &explicit_parent_span);
+        return std::make_unique<NoopSpan>();
+      });
+
+  std::unique_ptr<Span> span = mock_tracer.StartSpan("child_span", options);
+  EXPECT_NE(span, nullptr);
+
+  ThreadContext::SetCurrentSpan(nullptr);
+}
+
+TEST(TracerFactoryTest, GetTracerInvocation) {
+  StrictMock<MockTracerFactory> mock_factory;
+  auto mock_tracer = std::make_shared<StrictMock<MockTracer>>();
+
+  EXPECT_CALL(mock_factory, GetTracer(Eq("mock_tracer")))
+      .WillOnce(Return(mock_tracer));
+
+  std::shared_ptr<Tracer> tracer = mock_factory.GetTracer("mock_tracer");
+  EXPECT_EQ(tracer, mock_tracer);
+}
+
+TEST(TracerFactoryTest, DefaultGlobalFactoryIsNoopTracerFactory) {
+  auto factory = TracerFactory::GetGlobalFactory();
+  ASSERT_NE(factory, nullptr);
+  auto tracer = factory->GetTracer("mock_tracer");
+  ASSERT_NE(tracer, nullptr);
+  auto span = tracer->StartSpan("test_span", {});
+  ASSERT_NE(span, nullptr);
+}
+
+TEST(TracerFactoryTest, GlobalFactoryRegistration) {
+  auto initial_factory = TracerFactory::GetGlobalFactory();
+  EXPECT_NE(initial_factory, nullptr);
+
+  auto custom_factory = std::make_shared<StrictMock<MockTracerFactory>>();
+  TracerFactory::RegisterGlobalFactory(custom_factory);
+
+  EXPECT_EQ(TracerFactory::GetGlobalFactory(), custom_factory);
+
+  // Restore original factory state after test
+  TracerFactory::RegisterGlobalFactory(initial_factory);
+}
+
+TEST(TracerFactoryTest, RegisterNullptrResetsToNoopTracerFactory) {
+  auto initial_factory = TracerFactory::GetGlobalFactory();
+  EXPECT_NE(initial_factory, nullptr);
+
+  auto custom_factory = std::make_shared<StrictMock<MockTracerFactory>>();
+  TracerFactory::RegisterGlobalFactory(custom_factory);
+  EXPECT_EQ(TracerFactory::GetGlobalFactory(), custom_factory);
+
+  TracerFactory::RegisterGlobalFactory(nullptr);
+  auto current_factory = TracerFactory::GetGlobalFactory();
+  ASSERT_NE(current_factory, nullptr);
+  EXPECT_NE(current_factory, custom_factory);
+
+  // Restore original factory state after test
+  TracerFactory::RegisterGlobalFactory(initial_factory);
+}
+
+TEST(TracerFactoryTest, ConcurrentGlobalFactoryAccess) {
+  constexpr int kNumThreads = 8;
+  constexpr int kIterations = 100;
+
+  std::vector<std::thread> threads;
+  threads.reserve(kNumThreads);
+
+  for (int i = 0; i < kNumThreads; ++i) {
+    threads.emplace_back([&]() {
+      for (int j = 0; j < kIterations; ++j) {
+        auto factory = TracerFactory::GetGlobalFactory();
+        EXPECT_NE(factory, nullptr);
+        auto tracer = factory->GetTracer("concurrent_test");
+        EXPECT_NE(tracer, nullptr);
+        auto span = tracer->StartSpan("concurrent_span", {});
+        EXPECT_NE(span, nullptr);
+      }
+    });
+  }
+
+  for (auto& t : threads) {
+    t.join();
+  }
+}
+
+TEST(TracerFactoryResolutionTest, FallsBackToGlobalFactory) {
+  auto initial_factory = TracerFactory::GetGlobalFactory();
+  auto global_factory = std::make_shared<StrictMock<MockTracerFactory>>();
+  TracerFactory::RegisterGlobalFactory(global_factory);
+
+  EXPECT_EQ(TracerFactory::Get(), global_factory);
+  EXPECT_EQ(ResolveTracerFactory(credentio::AsyncContext()), global_factory);
+
+  TracerFactory::RegisterGlobalFactory(initial_factory);
+}
+
+TEST(TracerFactoryResolutionTest, ThreadLocalFactoryShadowsGlobal) {
+  auto initial_factory = TracerFactory::GetGlobalFactory();
+  auto global_factory = std::make_shared<StrictMock<MockTracerFactory>>();
+  auto thread_local_factory = std::make_shared<StrictMock<MockTracerFactory>>();
+  TracerFactory::RegisterGlobalFactory(global_factory);
+
+  {
+    ScopedTracerFactory guard(thread_local_factory);
+    EXPECT_EQ(TracerFactory::Get(), thread_local_factory);
+    EXPECT_EQ(ResolveTracerFactory(credentio::AsyncContext()),
+              thread_local_factory);
+  }
+
+  EXPECT_EQ(TracerFactory::GetThreadLocalFactory(), nullptr);
+  EXPECT_EQ(TracerFactory::Get(), global_factory);
+
+  TracerFactory::RegisterGlobalFactory(initial_factory);
+}
+
+TEST(TracerFactoryResolutionTest, ContextFactoryShadowsThreadLocalAndGlobal) {
+  auto initial_factory = TracerFactory::GetGlobalFactory();
+  auto global_factory = std::make_shared<StrictMock<MockTracerFactory>>();
+  auto thread_local_factory = std::make_shared<StrictMock<MockTracerFactory>>();
+  auto context_factory = std::make_shared<StrictMock<MockTracerFactory>>();
+  TracerFactory::RegisterGlobalFactory(global_factory);
+  ScopedTracerFactory guard(thread_local_factory);
+
+  credentio::AsyncContext context;
+  context.SetExtension<TracerFactory>(context_factory);
+  EXPECT_EQ(ResolveTracerFactory(context), context_factory);
+
+  TracerFactory::RegisterGlobalFactory(initial_factory);
+}
+
+TEST(TracerFactoryResolutionTest, ScopedTracerFactoryRestoresPrevious) {
+  auto outer = std::make_shared<StrictMock<MockTracerFactory>>();
+  auto inner = std::make_shared<StrictMock<MockTracerFactory>>();
+
+  {
+    ScopedTracerFactory outer_guard(outer);
+    EXPECT_EQ(TracerFactory::GetThreadLocalFactory(), outer);
+    {
+      ScopedTracerFactory inner_guard(inner);
+      EXPECT_EQ(TracerFactory::GetThreadLocalFactory(), inner);
+    }
+    EXPECT_EQ(TracerFactory::GetThreadLocalFactory(), outer);
+  }
+  EXPECT_EQ(TracerFactory::GetThreadLocalFactory(), nullptr);
+}
+
+TEST(TracerFactoryResolutionTest, ThreadLocalFactoryDoesNotLeakAcrossThreads) {
+  auto initial_factory = TracerFactory::GetGlobalFactory();
+  auto global_factory = std::make_shared<StrictMock<MockTracerFactory>>();
+  auto thread_local_factory = std::make_shared<StrictMock<MockTracerFactory>>();
+  TracerFactory::RegisterGlobalFactory(global_factory);
+
+  ScopedTracerFactory guard(thread_local_factory);
+
+  // A thread-local factory covers only the installing thread, which is why
+  // asynchronous work needs the factory on the context instead.
+  std::shared_ptr<TracerFactory> seen_without_context;
+  std::shared_ptr<TracerFactory> seen_with_context;
+  auto context_factory = std::make_shared<StrictMock<MockTracerFactory>>();
+  credentio::AsyncContext context;
+  context.SetExtension<TracerFactory>(context_factory);
+
+  std::thread worker([&]() {
+    seen_without_context = TracerFactory::Get();
+    seen_with_context = ResolveTracerFactory(context);
+  });
+  worker.join();
+
+  EXPECT_EQ(seen_without_context, global_factory);
+  EXPECT_EQ(seen_with_context, context_factory);
+
+  TracerFactory::RegisterGlobalFactory(initial_factory);
+}
+
+TEST(NoopSpanTest, AllMethodsExecuteWithoutError) {
+  NoopSpan span;
+
+  span.SetAttribute("key_str", std::string("value"));
+  span.SetAttribute("key_int", int64_t{42});
+  span.SetAttribute("key_double", 3.14);
+  span.SetAttribute("key_bool", true);
+
+  span.AddEvent("test_event");
+  span.SetStatus(absl::OkStatus());
+  span.SetStatus(absl::InvalidArgumentError("invalid argument"));
+  span.End();
+}
+
+TEST(NoopTracerTest, StartSpanReturnsNonNullNoopSpan) {
+  NoopTracer tracer;
+  StartSpanOptions options;
+
+  std::unique_ptr<Span> span = tracer.StartSpan("test_span", options);
+  ASSERT_NE(span, nullptr);
+
+  span->SetAttribute("key", int64_t{100});
+  span->AddEvent("event");
+  span->SetStatus(absl::OkStatus());
+  span->End();
+}
+
+TEST(NoopTracerTest, IntegrationWithScopedSpan) {
+  NoopTracer tracer;
+  {
+    ScopedSpan scoped_span(tracer.StartSpan("scoped_op", {}));
+    ASSERT_NE(scoped_span.get(), nullptr);
+    scoped_span->SetAttribute("attr", std::string("val"));
+    scoped_span.SetStatus(absl::OkStatus());
+  }
+}
+
+}  // namespace
+}  // namespace credentio_tracing
diff --git a/tsp/test_helpers.cc b/tsp/test_helpers.cc
index 0e734e1..d34ca96 100644
--- a/tsp/test_helpers.cc
+++ b/tsp/test_helpers.cc
@@ -249,8 +249,7 @@
   auto cms_or = credentio_testing::CreateSignedCms(
       der_data, *cert,
       [&key](absl::string_view data) { return SignWithBoringSsl(*key, data); },
-      {.signing_time = absl::FromUnixSeconds(1427738483),
-       .include_user_certificate = embed_cert,
+      {.include_user_certificate = embed_cert,
        .extra_signed_attributes = extra_attrs,
        .is_time_stamp_token = true});
   ABSL_CHECK_OK(cms_or.status());
@@ -261,7 +260,8 @@
     absl::Span<const absl::string_view> der_data,
     const crypto::tink::PublicKeySign& signer, absl::string_view cert_pem,
     bool embed_cert,
-    std::variant<absl::string_view, bool> signing_certificate_attribute) {
+    std::variant<absl::string_view, bool> signing_certificate_attribute,
+    std::optional<absl::Time> signing_time) {
   bssl::UniquePtr<X509> cert(PemToCertificate(cert_pem));
   ABSL_CHECK(cert != nullptr);
   std::string extra_attrs;
@@ -283,7 +283,7 @@
         ABSL_ASSIGN_OR_RETURN(auto signature, signer.Sign(data));
         return std::vector<uint8_t>(signature.begin(), signature.end());
       },
-      {.signing_time = absl::FromUnixSeconds(1427738483),
+      {.signing_time = signing_time,
        .include_user_certificate = embed_cert,
        .extra_signed_attributes = extra_attrs,
        .is_time_stamp_token = true});
@@ -342,8 +342,7 @@
         ABSL_ASSIGN_OR_RETURN(auto signature, signer_ptr->Sign(data));
         return std::vector<uint8_t>(signature.begin(), signature.end());
       },
-      {.signing_time = absl::FromUnixSeconds(1427738483),
-       .include_user_certificate = embed_cert,
+      {.include_user_certificate = embed_cert,
        .ca_certificates = ca_certs,
        .extra_signed_attributes = extra_attrs,
        .is_time_stamp_token = !use_wrong_e_content_type});
diff --git a/tsp/test_helpers.h b/tsp/test_helpers.h
index 5d4590d..88db3c0 100644
--- a/tsp/test_helpers.h
+++ b/tsp/test_helpers.h
@@ -24,6 +24,7 @@
 
 #include "absl/base/nullability.h"
 #include "absl/strings/string_view.h"
+#include "absl/time/time.h"
 #include "absl/types/span.h"
 #include "crypto/algorithms.h"
 #include "openssl/base.h"
@@ -59,7 +60,8 @@
     absl::Span<const absl::string_view> der_data,
     const crypto::tink::PublicKeySign& signer, absl::string_view cert_pem,
     bool embed_cert,
-    std::variant<absl::string_view, bool> signing_certificate_attribute = true);
+    std::variant<absl::string_view, bool> signing_certificate_attribute = true,
+    std::optional<absl::Time> signing_time = std::nullopt);
 
 // If `signing_certificate_attribute` holds a string, it will be taken as the
 // raw attribute value. If it holds `true` the value will be generated from
diff --git a/utils/crjson.cc b/utils/crjson.cc
index 22d87fa..771a675 100644
--- a/utils/crjson.cc
+++ b/utils/crjson.cc
@@ -382,26 +382,6 @@
 // Signature Helpers
 // =============================================================================
 
-std::string SigningAlgorithmToString(SigningAlgorithm alg) {
-  switch (alg) {
-    case SigningAlgorithm::kEs256:
-      return "ES256";
-    case SigningAlgorithm::kEs384:
-      return "ES384";
-    case SigningAlgorithm::kEs512:
-      return "ES512";
-    case SigningAlgorithm::kPs256:
-      return "PS256";
-    case SigningAlgorithm::kPs384:
-      return "PS384";
-    case SigningAlgorithm::kPs512:
-      return "PS512";
-    case SigningAlgorithm::kEdDsa:
-      return "Ed25519";
-  }
-  return "Unknown";
-}
-
 Json ConvertDNParsed(X509_NAME* name) {
   Json dn_map = Json::object();
   if (!name) return dn_map;
@@ -494,7 +474,7 @@
 
   auto protected_header = DecodeProtectedHeader(cose_sign1->protected_header);
   if (protected_header.ok()) {
-    crjson["algorithm"] = SigningAlgorithmToString(protected_header->alg);
+    crjson["algorithm"] = absl::StrCat(protected_header->alg);
   }
 
   std::vector<std::string> cert_chain;
diff --git a/utils/crjson_test.cc b/utils/crjson_test.cc
index 8265058..7a857bf 100644
--- a/utils/crjson_test.cc
+++ b/utils/crjson_test.cc
@@ -351,5 +351,47 @@
   EXPECT_TRUE(metadata_json["regionOfInterest"].is_object());
 }
 
+TEST(ConvertManifestStoreToCrJsonTest, ClaimSignatureAlgorithmsAreConverted) {
+  struct TestCase {
+    int64_t cose_alg;
+    absl::string_view expected_alg_str;
+  };
+  const TestCase kTestCases[] = {
+      {-7, "ES256"},  {-35, "ES384"}, {-36, "ES512"}, {-37, "PS256"},
+      {-38, "PS384"}, {-39, "PS512"}, {-8, "EdDSA"},
+  };
+
+  for (const auto& tc : kTestCases) {
+    cppbor::Map protected_header_map;
+    protected_header_map.add(cppbor::Uint(1), cppbor::Nint(tc.cose_alg));
+    std::string protected_header = protected_header_map.toString();
+
+    cppbor::Array cose_array;
+    cose_array.add(cppbor::Bstr(protected_header));
+    cose_array.add(cppbor::Map());
+    cose_array.add(cppbor::Null());
+    cose_array.add(cppbor::Bstr("dummy_sig"));
+    cppbor::SemanticTag cose_sign1(18, std::move(cose_array));
+    std::string encoded_cose = cose_sign1.toString();
+
+    std::string cbor_box = jumbf::EncodeCborBox(encoded_cose);
+    std::string sig_box = jumbf::EncodeSuperBox(
+        jumbf::kCborBoxTypeUuid, kClaimSignatureLabel, {cbor_box});
+    std::string manifest_box = jumbf::EncodeSuperBox(
+        kStandardManifestUuid, "test_manifest", {sig_box});
+    std::string manifest_store_box = jumbf::EncodeSuperBox(
+        kManifestStoreUuid, kManifestStoreLabel, {manifest_box});
+
+    absl::StatusOr<nlohmann::json> j =
+        ConvertToCrJsonWithoutValidationResults(manifest_store_box);
+    ABSL_ASSERT_OK(j);
+    ASSERT_TRUE(j->contains("manifests"));
+    ASSERT_EQ((*j)["manifests"].size(), 1);
+    const auto& manifest = (*j)["manifests"][0];
+    ASSERT_TRUE(manifest.contains("signature"));
+    EXPECT_EQ(manifest["signature"]["algorithm"], tc.expected_alg_str);
+  }
+}
+
 }  // namespace
 }  // namespace credentio