[Chunked OHTTP] Implementing chunked OHTTP Gateway

PiperOrigin-RevId: 798239391
diff --git a/build/source_list.bzl b/build/source_list.bzl
index 4db4368..d019d69 100644
--- a/build/source_list.bzl
+++ b/build/source_list.bzl
@@ -1634,6 +1634,8 @@
 oblivious_http_hdrs = [
     "oblivious_http/buffers/oblivious_http_request.h",
     "oblivious_http/buffers/oblivious_http_response.h",
+    "oblivious_http/common/oblivious_http_chunk_handler.h",
+    "oblivious_http/common/oblivious_http_definitions.h",
     "oblivious_http/common/oblivious_http_header_key_config.h",
     "oblivious_http/oblivious_http_client.h",
     "oblivious_http/oblivious_http_gateway.h",
diff --git a/build/source_list.gni b/build/source_list.gni
index 2e1cf09..8563629 100644
--- a/build/source_list.gni
+++ b/build/source_list.gni
@@ -1638,6 +1638,8 @@
 oblivious_http_hdrs = [
     "src/quiche/oblivious_http/buffers/oblivious_http_request.h",
     "src/quiche/oblivious_http/buffers/oblivious_http_response.h",
+    "src/quiche/oblivious_http/common/oblivious_http_chunk_handler.h",
+    "src/quiche/oblivious_http/common/oblivious_http_definitions.h",
     "src/quiche/oblivious_http/common/oblivious_http_header_key_config.h",
     "src/quiche/oblivious_http/oblivious_http_client.h",
     "src/quiche/oblivious_http/oblivious_http_gateway.h",
diff --git a/build/source_list.json b/build/source_list.json
index 7e24315..3819711 100644
--- a/build/source_list.json
+++ b/build/source_list.json
@@ -1637,6 +1637,8 @@
   "oblivious_http_hdrs": [
     "quiche/oblivious_http/buffers/oblivious_http_request.h",
     "quiche/oblivious_http/buffers/oblivious_http_response.h",
+    "quiche/oblivious_http/common/oblivious_http_chunk_handler.h",
+    "quiche/oblivious_http/common/oblivious_http_definitions.h",
     "quiche/oblivious_http/common/oblivious_http_header_key_config.h",
     "quiche/oblivious_http/oblivious_http_client.h",
     "quiche/oblivious_http/oblivious_http_gateway.h"
diff --git a/quiche/binary_http/binary_http_message.h b/quiche/binary_http/binary_http_message.h
index 41a6bc1..ed04f16 100644
--- a/quiche/binary_http/binary_http_message.h
+++ b/quiche/binary_http/binary_http_message.h
@@ -187,7 +187,7 @@
   class QUICHE_EXPORT IndeterminateLengthDecoder {
    public:
     // The handler to invoke when a section is decoded successfully.
-    class MessageSectionHandler {
+    class QUICHE_EXPORT MessageSectionHandler {
      public:
       virtual ~MessageSectionHandler() = default;
       virtual void OnControlData(const ControlData& control_data) = 0;
diff --git a/quiche/oblivious_http/buffers/oblivious_http_request.cc b/quiche/oblivious_http/buffers/oblivious_http_request.cc
index 246141c..562833f 100644
--- a/quiche/oblivious_http/buffers/oblivious_http_request.cc
+++ b/quiche/oblivious_http/buffers/oblivious_http_request.cc
@@ -11,6 +11,7 @@
 #include "absl/memory/memory.h"
 #include "absl/status/status.h"
 #include "absl/status/statusor.h"
+#include "absl/strings/escaping.h"
 #include "absl/strings/str_cat.h"
 #include "absl/strings/string_view.h"
 #include "openssl/hpke.h"
@@ -18,6 +19,7 @@
 #include "quiche/common/platform/api/quiche_logging.h"
 #include "quiche/common/quiche_crypto_logging.h"
 #include "quiche/common/quiche_data_reader.h"
+#include "quiche/oblivious_http/common/oblivious_http_definitions.h"
 #include "quiche/oblivious_http/common/oblivious_http_header_key_config.h"
 
 namespace quiche {
@@ -229,4 +231,29 @@
   return Context(std::move(gateway_ctx), std::string(enc_key_received));
 }
 
+absl::StatusOr<std::string> ObliviousHttpRequest::DecryptChunk(
+    Context& context, absl::string_view encrypted_chunk, bool is_final_chunk) {
+  uint8_t* ad = nullptr;
+  size_t ad_len = 0;
+  std::string final_ad_bytes;
+  if (is_final_chunk) {
+    ad = const_cast<uint8_t*>(kFinalAdBytes);
+    ad_len = sizeof(kFinalAdBytes);
+  }
+
+  std::string decrypted(encrypted_chunk.size(), '\0');
+  size_t decrypted_len;
+  if (!EVP_HPKE_CTX_open(
+          context.hpke_context_.get(),
+          reinterpret_cast<uint8_t*>(decrypted.data()), &decrypted_len,
+          decrypted.size(),
+          reinterpret_cast<const uint8_t*>(encrypted_chunk.data()),
+          encrypted_chunk.size(), ad, ad_len)) {
+    return SslErrorAsStatus("Failed to decrypt.",
+                            absl::StatusCode::kInvalidArgument);
+  }
+  decrypted.resize(decrypted_len);
+  return decrypted;
+}
+
 }  // namespace quiche
diff --git a/quiche/oblivious_http/buffers/oblivious_http_request.h b/quiche/oblivious_http/buffers/oblivious_http_request.h
index 8fa6e3e..3293e4a 100644
--- a/quiche/oblivious_http/buffers/oblivious_http_request.h
+++ b/quiche/oblivious_http/buffers/oblivious_http_request.h
@@ -1,13 +1,14 @@
 #ifndef QUICHE_OBLIVIOUS_HTTP_BUFFERS_OBLIVIOUS_HTTP_REQUEST_H_
 #define QUICHE_OBLIVIOUS_HTTP_BUFFERS_OBLIVIOUS_HTTP_REQUEST_H_
 
-#include <memory>
 #include <optional>
 #include <string>
+#include <utility>
 
 #include "absl/status/statusor.h"
 #include "absl/strings/string_view.h"
 #include "openssl/hpke.h"
+#include "quiche/common/platform/api/quiche_export.h"
 #include "quiche/common/quiche_data_reader.h"
 #include "quiche/oblivious_http/common/oblivious_http_header_key_config.h"
 
@@ -111,6 +112,10 @@
       const ObliviousHttpHeaderKeyConfig& ohttp_key_config,
       absl::string_view request_label);
 
+  // Decrypts an encrypted chunk.
+  static absl::StatusOr<std::string> DecryptChunk(
+      Context& context, absl::string_view encrypted_chunk, bool is_final_chunk);
+
  private:
   explicit ObliviousHttpRequest(
       bssl::UniquePtr<EVP_HPKE_CTX> hpke_context, std::string encapsulated_key,
diff --git a/quiche/oblivious_http/buffers/oblivious_http_request_test.cc b/quiche/oblivious_http/buffers/oblivious_http_request_test.cc
index af80873..ceef6af 100644
--- a/quiche/oblivious_http/buffers/oblivious_http_request_test.cc
+++ b/quiche/oblivious_http/buffers/oblivious_http_request_test.cc
@@ -136,6 +136,119 @@
                 .status()
                 .code(),
             absl::StatusCode::kInvalidArgument);
+
+  // Valid key header but missing encapsulated secret.
+  payload = "010020000100014b";
+  ASSERT_TRUE(absl::HexStringToBytes(payload, &payload_bytes));
+  QuicheDataReader reader3(payload_bytes);
+
+  // Missing encapsulated secret.
+  EXPECT_EQ(ObliviousHttpRequest::DecodeEncapsulatedRequestHeader(
+                reader3, *hpke_key, ohttp_key_config, "test")
+                .status()
+                .code(),
+            absl::StatusCode::kFailedPrecondition);
+}
+
+TEST(ObliviousHttpRequest, DecryptChunks) {
+  // Example from
+  // https://www.ietf.org/archive/id/draft-ietf-ohai-chunked-ohttp-05.html#appendix-A
+  auto ohttp_key_config =
+      GetOhttpKeyConfig(1, EVP_HPKE_DHKEM_X25519_HKDF_SHA256,
+                        EVP_HPKE_HKDF_SHA256, EVP_HPKE_AES_128_GCM);
+
+  std::string kX25519SecretKey =
+      "1c190d72acdbe4dbc69e680503bb781a932c70a12c8f3754434c67d8640d8698";
+  std::string x25519_secret_key_bytes;
+  EXPECT_TRUE(
+      absl::HexStringToBytes(kX25519SecretKey, &x25519_secret_key_bytes));
+  auto hpke_key = ConstructHpkeKey(x25519_secret_key_bytes, ohttp_key_config);
+
+  std::string encapsulated_request_headers =
+      "01002000010001"
+      "8811eb457e100811c40a0aa71340a1b81d804bb986f736f2f566a7199761a032";
+  std::string encapsulated_request_headers_bytes;
+  EXPECT_TRUE(absl::HexStringToBytes(encapsulated_request_headers,
+                                     &encapsulated_request_headers_bytes));
+  QuicheDataReader reader(encapsulated_request_headers_bytes);
+
+  auto context = ObliviousHttpRequest::DecodeEncapsulatedRequestHeader(
+      reader, *hpke_key, ohttp_key_config,
+      ObliviousHttpHeaderKeyConfig::kChunkedOhttpRequestLabel);
+  QUICHE_EXPECT_OK(context);
+
+  // Encrypted chunks from
+  // https://www.ietf.org/archive/id/draft-ietf-ohai-chunked-ohttp-05.html#appendix-A
+  std::string decrypted_payload = "";
+  std::string encrypted_non_final_chunks[] = {
+      "2ad24942d4d692563012f2980c8fef437a336b9b2fc938ef77a5834f",
+      "2e33d8fd25577afe31bd1c79d094f76b6250ae6549b473ecd950501311"};
+
+  for (const auto& encrypted_chunk : encrypted_non_final_chunks) {
+    std::string encrypted_chunk_bytes;
+    EXPECT_TRUE(
+        absl::HexStringToBytes(encrypted_chunk, &encrypted_chunk_bytes));
+    auto decrypted_chunk = ObliviousHttpRequest::DecryptChunk(
+        *context, encrypted_chunk_bytes, /*is_final_chunk=*/false);
+    QUICHE_EXPECT_OK(decrypted_chunk);
+    absl::StrAppend(&decrypted_payload, *decrypted_chunk);
+    auto debug = absl::BytesToHexString(*decrypted_chunk);
+    EXPECT_NE(debug, encrypted_chunk);
+  }
+
+  std::string final_encrypted_chunk = "1c6c1395d0ef7c1022297966307b8a7f";
+  std::string final_encrypted_chunk_bytes;
+  EXPECT_TRUE(absl::HexStringToBytes(final_encrypted_chunk,
+                                     &final_encrypted_chunk_bytes));
+  auto decrypted_final_chunk = ObliviousHttpRequest::DecryptChunk(
+      *context, final_encrypted_chunk_bytes, /*is_final_chunk=*/true);
+  QUICHE_EXPECT_OK(decrypted_final_chunk);
+  absl::StrAppend(&decrypted_payload, *decrypted_final_chunk);
+
+  std::string decrypted_payload_hex = absl::BytesToHexString(decrypted_payload);
+
+  EXPECT_EQ(decrypted_payload_hex,
+            "00034745540568747470730b6578616d706c652e636f6d012f");
+}
+
+TEST(ObliviousHttpRequest, DecryptChunkHandleError) {
+  auto ohttp_key_config =
+      GetOhttpKeyConfig(1, EVP_HPKE_DHKEM_X25519_HKDF_SHA256,
+                        EVP_HPKE_HKDF_SHA256, EVP_HPKE_AES_128_GCM);
+
+  std::string kX25519SecretKey =
+      "1c190d72acdbe4dbc69e680503bb781a932c70a12c8f3754434c67d8640d8698";
+  std::string x25519_secret_key_bytes;
+  EXPECT_TRUE(
+      absl::HexStringToBytes(kX25519SecretKey, &x25519_secret_key_bytes));
+  auto hpke_key = ConstructHpkeKey(x25519_secret_key_bytes, ohttp_key_config);
+
+  std::string encapsulated_request_headers =
+      "01002000010001"
+      "8811eb457e100811c40a0aa71340a1b81d804bb986f736f2f566a7199761a032";
+  std::string encapsulated_request_headers_bytes;
+  EXPECT_TRUE(absl::HexStringToBytes(encapsulated_request_headers,
+                                     &encapsulated_request_headers_bytes));
+  QuicheDataReader reader(encapsulated_request_headers_bytes);
+
+  auto context = ObliviousHttpRequest::DecodeEncapsulatedRequestHeader(
+      reader, *hpke_key, ohttp_key_config,
+      ObliviousHttpHeaderKeyConfig::kChunkedOhttpRequestLabel);
+  QUICHE_EXPECT_OK(context);
+
+  // Encrypted chunks from
+  // https://www.ietf.org/archive/id/draft-ietf-ohai-chunked-ohttp-05.html#appendix-A
+  std::string decrypted_payload = "";
+  // Last byte altered to make the chunk invalid.
+  std::string encrypted_chunk =
+      "2ad24942d4d692563012f2980c8fef437a336b9b2fc938ef77a5834e";
+
+  std::string encrypted_chunk_bytes;
+  EXPECT_TRUE(absl::HexStringToBytes(encrypted_chunk, &encrypted_chunk_bytes));
+  auto decrypted_chunk = ObliviousHttpRequest::DecryptChunk(
+      *context, encrypted_chunk_bytes, /*is_final_chunk=*/false);
+  EXPECT_EQ(decrypted_chunk.status().code(),
+            absl::StatusCode::kInvalidArgument);
 }
 
 // Direct test example from RFC.
diff --git a/quiche/oblivious_http/buffers/oblivious_http_response.cc b/quiche/oblivious_http/buffers/oblivious_http_response.cc
index ea872bd..321415b 100644
--- a/quiche/oblivious_http/buffers/oblivious_http_response.cc
+++ b/quiche/oblivious_http/buffers/oblivious_http_response.cc
@@ -19,6 +19,7 @@
 #include "quiche/common/quiche_crypto_logging.h"
 #include "quiche/common/quiche_random.h"
 #include "quiche/oblivious_http/buffers/oblivious_http_request.h"
+#include "quiche/oblivious_http/common/oblivious_http_definitions.h"
 #include "quiche/oblivious_http/common/oblivious_http_header_key_config.h"
 
 namespace quiche {
@@ -336,4 +337,106 @@
   return result;
 }
 
+absl::StatusOr<ObliviousHttpResponse::AeadContextData>
+ObliviousHttpResponse::GetAeadContextData(
+    ObliviousHttpRequest::Context& oblivious_http_request_context,
+    CommonAeadParamsResult& aead_params, absl::string_view response_label,
+    absl::string_view response_nonce) {
+  // Steps (1, 3 to 5) + AEAD context SetUp before 6th step is performed in
+  // CommonOperations.
+  // https://www.rfc-editor.org/rfc/rfc9458.html#section-4.4-3
+  auto common_ops_st = CommonOperationsToEncapDecap(
+      response_nonce, oblivious_http_request_context, response_label,
+      aead_params.aead_key_len, aead_params.aead_nonce_len,
+      aead_params.secret_len);
+  if (!common_ops_st.ok()) {
+    return common_ops_st.status();
+  }
+  return AeadContextData{std::move(common_ops_st.value().aead_ctx),
+                         std::move(common_ops_st.value().aead_nonce)};
+}
+
+// Encrypts the chunk following
+// https://www.ietf.org/archive/id/draft-ietf-ohai-chunked-ohttp-05.html#section-6.1
+// The plaintext payload can only be empty if this is the final chunk. The chunk
+// nonce cannot be empty. If the operation succeeds then the returned encrypted
+// data is guaranteed to be non-empty.
+absl::StatusOr<std::string> ObliviousHttpResponse::EncryptChunk(
+    ObliviousHttpRequest::Context& oblivious_http_request_context,
+    const AeadContextData& aead_context_data,
+    absl::string_view plaintext_payload, absl::string_view chunk_nonce,
+    bool is_final_chunk) {
+  // Empty plaintext_payload is only allowed for the final chunk.
+  if (!is_final_chunk && plaintext_payload.empty()) {
+    return absl::InvalidArgumentError(
+        "Payload cannot be empty for non-final chunks.");
+  }
+  if (chunk_nonce.empty()) {
+    return absl::InvalidArgumentError("Chunk nonce cannot be empty.");
+  }
+
+  uint8_t* ad = nullptr;
+  size_t ad_len = 0;
+  if (is_final_chunk) {
+    ad = const_cast<uint8_t*>(kFinalAdBytes);
+    ad_len = sizeof(kFinalAdBytes);
+  }
+
+  const size_t max_encrypted_data_size =
+      plaintext_payload.size() +
+      EVP_AEAD_max_overhead(EVP_HPKE_AEAD_aead(EVP_HPKE_CTX_aead(
+          oblivious_http_request_context.hpke_context_.get())));
+  std::string encrypted_data(max_encrypted_data_size, '\0');
+  size_t ciphertext_len;
+
+  if (!EVP_AEAD_CTX_seal(
+          aead_context_data.aead_ctx.get(),
+          reinterpret_cast<uint8_t*>(encrypted_data.data()), &ciphertext_len,
+          encrypted_data.size(),
+          reinterpret_cast<const uint8_t*>(chunk_nonce.data()),
+          aead_context_data.aead_nonce.size(),
+          reinterpret_cast<const uint8_t*>(plaintext_payload.data()),
+          plaintext_payload.size(), ad, ad_len)) {
+    return SslErrorAsStatus(
+        "Failed to encrypt the payload with derived AEAD key.");
+  }
+  encrypted_data.resize(ciphertext_len);
+  if (ciphertext_len == 0) {
+    return absl::InternalError("Generated Encrypted payload cannot be empty.");
+  }
+  return encrypted_data;
+}
+
+absl::StatusOr<ObliviousHttpResponse::ChunkCounter>
+ObliviousHttpResponse::ChunkCounter::Create(std::string nonce) {
+  if (nonce.empty()) {
+    return absl::InvalidArgumentError("Nonce must not be empty.");
+  }
+  return ObliviousHttpResponse::ChunkCounter(std::move(nonce));
+}
+
+ObliviousHttpResponse::ChunkCounter::ChunkCounter(std::string nonce)
+    : nonce_(std::move(nonce)), encoded_counter_(nonce_.size(), '\0') {}
+
+void ObliviousHttpResponse::ChunkCounter::Increment() {
+  uint64_t pos = nonce_.size();
+  // Start with the least significant byte and increment it by 1, if it wraps
+  // then proceed to the next byte and repeat.
+  do {
+    pos--;
+    encoded_counter_[pos] += static_cast<uint8_t>(1);
+  } while (pos != 0 && encoded_counter_[pos] == '\0');
+  if (pos == 0 && encoded_counter_[pos] == '\0') {
+    // Counter has wrapped.
+    limit_exceeded_ = true;
+  }
+}
+
+std::string ObliviousHttpResponse::ChunkCounter::GetChunkNonce() const {
+  std::string chunk_nonce(nonce_.size(), '\0');
+  for (size_t i = 0; i < chunk_nonce.size(); i++) {
+    chunk_nonce[i] = nonce_[i] ^ encoded_counter_[i];
+  }
+  return chunk_nonce;
+}
 }  // namespace quiche
diff --git a/quiche/oblivious_http/buffers/oblivious_http_response.h b/quiche/oblivious_http/buffers/oblivious_http_response.h
index 2bc7caf..29b6763 100644
--- a/quiche/oblivious_http/buffers/oblivious_http_response.h
+++ b/quiche/oblivious_http/buffers/oblivious_http_response.h
@@ -4,9 +4,11 @@
 #include <stddef.h>
 
 #include <string>
+#include <utility>
 
 #include "absl/status/statusor.h"
 #include "absl/strings/string_view.h"
+#include "openssl/base.h"
 #include "quiche/common/quiche_random.h"
 #include "quiche/oblivious_http/buffers/oblivious_http_request.h"
 #include "quiche/oblivious_http/common/oblivious_http_header_key_config.h"
@@ -15,6 +17,41 @@
 
 class QUICHE_EXPORT ObliviousHttpResponse {
  public:
+  // A counter of the number of chunks sent/received in the response, used to
+  // get the appropriate chunk nonce for encryption/decryption. See
+  // (https://datatracker.ietf.org/doc/html/draft-ietf-ohai-chunked-ohttp-05#section-6.2).
+  class QUICHE_EXPORT ChunkCounter {
+   public:
+    static absl::StatusOr<ChunkCounter> Create(std::string nonce);
+    // Returns true if the counter has exceeded the maximum allowed value.
+    bool LimitExceeded() const { return limit_exceeded_; }
+    // Increments the chunk counter.
+    void Increment();
+    // XORs the nonce with the encoded counter to get the chunk nonce.
+    std::string GetChunkNonce() const;
+
+   private:
+    explicit ChunkCounter(std::string nonce);
+    // The nonce used to initialize the counter.
+    const std::string nonce_;
+    // Represents the counter value encoded to `Nn` bytes in network
+    // byte order.
+    std::string encoded_counter_;
+    bool limit_exceeded_ = false;
+  };
+
+  // Common AEAD context data used for sealing/opening response chunks.
+  struct QUICHE_EXPORT AeadContextData {
+    bssl::UniquePtr<EVP_AEAD_CTX> aead_ctx;
+    const std::string aead_nonce;
+  };
+  struct QUICHE_EXPORT CommonAeadParamsResult {
+    const EVP_AEAD* evp_hpke_aead;
+    const size_t aead_key_len;
+    const size_t aead_nonce_len;
+    const size_t secret_len;
+  };
+
   // Parse and decrypt the OHttp response using ObliviousHttpContext context obj
   // that was returned from `CreateClientObliviousRequest` method. On success,
   // returns obj that callers will use to `GetDecryptedMessage`.
@@ -57,6 +94,18 @@
 
   ~ObliviousHttpResponse() = default;
 
+  // Generates the AEAD context data from the response nonce.
+  static absl::StatusOr<AeadContextData> GetAeadContextData(
+      ObliviousHttpRequest::Context& oblivious_http_request_context,
+      CommonAeadParamsResult& aead_params, absl::string_view response_label,
+      absl::string_view response_nonce);
+
+  static absl::StatusOr<std::string> EncryptChunk(
+      ObliviousHttpRequest::Context& oblivious_http_request_context,
+      const AeadContextData& aead_context_data,
+      absl::string_view plaintext_payload, absl::string_view chunk_nonce,
+      bool is_final_chunk);
+
   // Generic Usecase : server-side calls this method in the context of Response
   // to serialize OHTTP response that will be returned to client-side.
   // Returns serialized OHTTP response bytestring.
@@ -67,14 +116,12 @@
     return std::move(response_plaintext_);
   }
 
- private:
-  struct CommonAeadParamsResult {
-    const EVP_AEAD* evp_hpke_aead;
-    const size_t aead_key_len;
-    const size_t aead_nonce_len;
-    const size_t secret_len;
-  };
+  // Determines AEAD key len(Nk), AEAD nonce len(Nn) based on HPKE context, and
+  // further estimates secret_len = std::max(Nk, Nn)
+  static absl::StatusOr<CommonAeadParamsResult> GetCommonAeadParams(
+      ObliviousHttpRequest::Context& oblivious_http_request_context);
 
+ private:
   struct CommonOperationsResult {
     bssl::UniquePtr<EVP_AEAD_CTX> aead_ctx;
     const std::string aead_nonce;
@@ -83,10 +130,6 @@
   explicit ObliviousHttpResponse(std::string encrypted_data,
                                  std::string resp_plaintext);
 
-  // Determines AEAD key len(Nk), AEAD nonce len(Nn) based on HPKE context and
-  // further estimates secret_len = std::max(Nk, Nn)
-  static absl::StatusOr<CommonAeadParamsResult> GetCommonAeadParams(
-      ObliviousHttpRequest::Context& oblivious_http_request_context);
   // Performs operations related to response handling that are common between
   // client and server.
   static absl::StatusOr<CommonOperationsResult> CommonOperationsToEncapDecap(
diff --git a/quiche/oblivious_http/buffers/oblivious_http_response_test.cc b/quiche/oblivious_http/buffers/oblivious_http_response_test.cc
index bcb91a8..c76007a 100644
--- a/quiche/oblivious_http/buffers/oblivious_http_response_test.cc
+++ b/quiche/oblivious_http/buffers/oblivious_http_response_test.cc
@@ -9,12 +9,15 @@
 #include <string>
 #include <utility>
 
+#include "absl/status/status.h"
 #include "absl/status/statusor.h"
 #include "absl/strings/escaping.h"
 #include "absl/strings/str_cat.h"
 #include "absl/strings/string_view.h"
 #include "openssl/hpke.h"
 #include "quiche/common/platform/api/quiche_test.h"
+#include "quiche/common/quiche_data_reader.h"
+#include "quiche/common/test_tools/quiche_test_utils.h"
 #include "quiche/oblivious_http/buffers/oblivious_http_request.h"
 #include "quiche/oblivious_http/common/oblivious_http_header_key_config.h"
 
@@ -225,4 +228,172 @@
       expected_encrypted_response_bytes);
 }
 
+struct EncryptChunkTestParams {
+  ObliviousHttpRequest::Context context;
+  ObliviousHttpResponse::CommonAeadParamsResult aead_params;
+  ObliviousHttpResponse::AeadContextData aead_context_data;
+};
+
+absl::StatusOr<EncryptChunkTestParams> SetUpEncryptChunkTest() {
+  // Example from
+  // https://www.ietf.org/archive/id/draft-ietf-ohai-chunked-ohttp-05.html#appendix-A
+  auto ohttp_key_config =
+      GetOhttpKeyConfig(1, EVP_HPKE_DHKEM_X25519_HKDF_SHA256,
+                        EVP_HPKE_HKDF_SHA256, EVP_HPKE_AES_128_GCM);
+
+  std::string kX25519SecretKey =
+      "1c190d72acdbe4dbc69e680503bb781a932c70a12c8f3754434c67d8640d8698";
+  std::string x25519_secret_key_bytes;
+  EXPECT_TRUE(
+      absl::HexStringToBytes(kX25519SecretKey, &x25519_secret_key_bytes));
+  auto hpke_key = ConstructHpkeKey(x25519_secret_key_bytes, ohttp_key_config);
+
+  std::string encapsulated_request_headers =
+      "01002000010001"
+      "8811eb457e100811c40a0aa71340a1b81d804bb986f736f2f566a7199761a032";
+  std::string encapsulated_request_headers_bytes;
+  EXPECT_TRUE(absl::HexStringToBytes(encapsulated_request_headers,
+                                     &encapsulated_request_headers_bytes));
+  QuicheDataReader reader(encapsulated_request_headers_bytes);
+
+  auto context = ObliviousHttpRequest::DecodeEncapsulatedRequestHeader(
+      reader, *hpke_key, ohttp_key_config,
+      ObliviousHttpHeaderKeyConfig::kChunkedOhttpRequestLabel);
+  QUICHE_EXPECT_OK(context);
+
+  absl::StatusOr<ObliviousHttpResponse::CommonAeadParamsResult> aead_params =
+      ObliviousHttpResponse::GetCommonAeadParams(*context);
+  EXPECT_TRUE(aead_params.ok());
+
+  auto response_nonce = "bcce7f4cb921309ba5d62edf1769ef09";
+  std::string response_nonce_bytes;
+  EXPECT_TRUE(absl::HexStringToBytes(response_nonce, &response_nonce_bytes));
+  auto aead_context_data = ObliviousHttpResponse::GetAeadContextData(
+      *context, *aead_params,
+      ObliviousHttpHeaderKeyConfig::kChunkedOhttpResponseLabel,
+      response_nonce_bytes);
+  QUICHE_EXPECT_OK(aead_context_data);
+
+  return EncryptChunkTestParams{
+      .context = std::move(*context),
+      .aead_params = std::move(*aead_params),
+      .aead_context_data = std::move(*aead_context_data)};
+}
+
+TEST(ObliviousHttpResponse, TestEncryptChunks) {
+  auto test_params = SetUpEncryptChunkTest();
+  QUICHE_EXPECT_OK(test_params);
+  auto& [context, aead_params, aead_context_data] = *test_params;
+
+  std::string plaintext_payload = "01";
+  std::string plaintext_payload_bytes;
+  EXPECT_TRUE(
+      absl::HexStringToBytes(plaintext_payload, &plaintext_payload_bytes));
+  std::string chunk_nonce = "fead854635d2d5527d64f546";
+  std::string chunk_nonce_bytes;
+  EXPECT_TRUE(absl::HexStringToBytes(chunk_nonce, &chunk_nonce_bytes));
+
+  auto encrypted_chunk = ObliviousHttpResponse::EncryptChunk(
+      context, aead_context_data, plaintext_payload_bytes, chunk_nonce_bytes,
+      /*is_final_chunk=*/false);
+  QUICHE_EXPECT_OK(encrypted_chunk);
+  std::string encrypted_chunk_hex = absl::BytesToHexString(*encrypted_chunk);
+  EXPECT_EQ(encrypted_chunk_hex, "79bf1cc87fa0e2c02de4546945aa3d1e48");
+
+  plaintext_payload = "40c8";
+  EXPECT_TRUE(
+      absl::HexStringToBytes(plaintext_payload, &plaintext_payload_bytes));
+  chunk_nonce = "fead854635d2d5527d64f547";
+  EXPECT_TRUE(absl::HexStringToBytes(chunk_nonce, &chunk_nonce_bytes));
+
+  encrypted_chunk = ObliviousHttpResponse::EncryptChunk(
+      context, aead_context_data, plaintext_payload_bytes, chunk_nonce_bytes,
+      /*is_final_chunk=*/false);
+  QUICHE_EXPECT_OK(encrypted_chunk);
+  encrypted_chunk_hex = absl::BytesToHexString(*encrypted_chunk);
+  EXPECT_EQ(encrypted_chunk_hex, "b348b5bd4c594c16b6170b07b475845d1f32");
+
+  chunk_nonce = "fead854635d2d5527d64f544";
+  EXPECT_TRUE(absl::HexStringToBytes(chunk_nonce, &chunk_nonce_bytes));
+
+  encrypted_chunk = ObliviousHttpResponse::EncryptChunk(
+      context, aead_context_data, /*plaintext_payload=*/"", chunk_nonce_bytes,
+      /*is_final_chunk=*/true);
+  QUICHE_EXPECT_OK(encrypted_chunk);
+  encrypted_chunk_hex = absl::BytesToHexString(*encrypted_chunk);
+  EXPECT_EQ(encrypted_chunk_hex, "ed9d8a796617a5b27265f4d73247f639");
+}
+
+TEST(OblviousHttpResponse, EncryptNonFinalChunkWithEmptyPayloadError) {
+  auto test_params = SetUpEncryptChunkTest();
+  QUICHE_EXPECT_OK(test_params);
+  auto& [context, aead_params, aead_context_data] = *test_params;
+
+  EXPECT_EQ(ObliviousHttpResponse::EncryptChunk(context, aead_context_data,
+                                                /*plaintext_payload=*/"", "",
+                                                /*is_final_chunk=*/false)
+                .status()
+                .code(),
+            absl::StatusCode::kInvalidArgument);
+}
+
+TEST(OblviousHttpResponse, EncryptChunkWithEmptyNonceError) {
+  auto test_params = SetUpEncryptChunkTest();
+  QUICHE_EXPECT_OK(test_params);
+  auto& [context, aead_params, aead_context_data] = *test_params;
+
+  EXPECT_EQ(ObliviousHttpResponse::EncryptChunk(context, aead_context_data,
+                                                /*plaintext_payload=*/"111", "",
+                                                /*is_final_chunk=*/false)
+                .status()
+                .code(),
+            absl::StatusCode::kInvalidArgument);
+}
+
+TEST(ChunkCounter, EmptyNonceIsInvalid) {
+  EXPECT_EQ(ObliviousHttpResponse::ChunkCounter::Create("").status().code(),
+            absl::StatusCode::kInvalidArgument);
+}
+
+TEST(ChunkCounter, GetChunkNonce) {
+  // Chunk nonces from
+  // https://www.ietf.org/archive/id/draft-ietf-ohai-chunked-ohttp-05.html#appendix-A
+  std::string nonce_hex = "fead854635d2d5527d64f546";
+  std::string nonce;
+  EXPECT_TRUE(absl::HexStringToBytes(nonce_hex, &nonce));
+  auto chunk_counter = ObliviousHttpResponse::ChunkCounter::Create(nonce);
+  EXPECT_TRUE(chunk_counter.ok());
+
+  std::string expected_chunk_nonce_hex = "fead854635d2d5527d64f546";
+  std::string chunk_nonce;
+  EXPECT_TRUE(absl::HexStringToBytes(expected_chunk_nonce_hex, &chunk_nonce));
+  EXPECT_EQ(chunk_counter->GetChunkNonce(), chunk_nonce);
+
+  chunk_counter->Increment();
+  expected_chunk_nonce_hex = "fead854635d2d5527d64f547";
+  EXPECT_TRUE(absl::HexStringToBytes(expected_chunk_nonce_hex, &chunk_nonce));
+  EXPECT_EQ(chunk_counter->GetChunkNonce(), chunk_nonce);
+
+  chunk_counter->Increment();
+  expected_chunk_nonce_hex = "fead854635d2d5527d64f544";
+  EXPECT_TRUE(absl::HexStringToBytes(expected_chunk_nonce_hex, &chunk_nonce));
+  EXPECT_EQ(chunk_counter->GetChunkNonce(), chunk_nonce);
+}
+
+TEST(ChunkCounter, LimitExceeded) {
+  std::string nonce_hex = "00";
+  std::string nonce;
+  EXPECT_TRUE(absl::HexStringToBytes(nonce_hex, &nonce));
+  auto chunk_counter = ObliviousHttpResponse::ChunkCounter::Create(nonce);
+  EXPECT_TRUE(chunk_counter.ok());
+
+  for (int i = 0; i < 256; ++i) {
+    EXPECT_FALSE(chunk_counter->LimitExceeded());
+    chunk_counter->Increment();
+  }
+
+  // Counter limit reached at 2^(nonce_size * 8)
+  EXPECT_TRUE(chunk_counter->LimitExceeded());
+}
+
 }  // namespace quiche
diff --git a/quiche/oblivious_http/common/oblivious_http_chunk_handler.h b/quiche/oblivious_http/common/oblivious_http_chunk_handler.h
new file mode 100644
index 0000000..7c7bd77
--- /dev/null
+++ b/quiche/oblivious_http/common/oblivious_http_chunk_handler.h
@@ -0,0 +1,25 @@
+#ifndef QUICHE_OBLIVIOUS_HTTP_COMMON_CHUNK_HANDLER_H_
+#define QUICHE_OBLIVIOUS_HTTP_COMMON_CHUNK_HANDLER_H_
+
+#include "absl/status/status.h"
+#include "absl/strings/string_view.h"
+#include "quiche/common/platform/api/quiche_export.h"
+
+namespace quiche {
+
+// Methods to be invoked upon decryption of request/response OHTTP chunks.
+class QUICHE_EXPORT ObliviousHttpChunkHandler {
+ public:
+  virtual ~ObliviousHttpChunkHandler() = default;
+  // This method is invoked once a chunk of data has been decrypted. It returns
+  // a Status to allow the implementation to signal a potential error, such as a
+  // decoding issue with the decrypted data.
+  virtual absl::Status OnDecryptedChunk(absl::string_view decrypted_chunk) = 0;
+  // This method is invoked once all chunks have been decrypted. It returns
+  // a Status to allow the implementation to signal a potential error.
+  virtual absl::Status OnChunksDone() = 0;
+};
+
+}  // namespace quiche
+
+#endif  // QUICHE_OBLIVIOUS_HTTP_COMMON_CHUNK_HANDLER_H_
diff --git a/quiche/oblivious_http/common/oblivious_http_definitions.h b/quiche/oblivious_http/common/oblivious_http_definitions.h
new file mode 100644
index 0000000..6403fee
--- /dev/null
+++ b/quiche/oblivious_http/common/oblivious_http_definitions.h
@@ -0,0 +1,14 @@
+#ifndef QUICHE_OBLIVIOUS_HTTP_COMMON_OBLIVIOUS_HTTP_DEFINITIONS_H_
+#define QUICHE_OBLIVIOUS_HTTP_COMMON_OBLIVIOUS_HTTP_DEFINITIONS_H_
+
+#include <stdint.h>
+
+namespace quiche {
+// This 5-byte array represents the string "final", which is used as AD when
+// encrypting/decrypting the final OHTTP chunk. See sections 6.1 and 6.2 of
+// https://www.ietf.org/archive/id/draft-ietf-ohai-chunked-ohttp-05.html
+constexpr uint8_t kFinalAdBytes[] = {0x66, 0x69, 0x6E, 0x61, 0x6C};
+
+}  // namespace quiche
+
+#endif  // QUICHE_OBLIVIOUS_HTTP_COMMON_OBLIVIOUS_HTTP_DEFINITIONS_H_
diff --git a/quiche/oblivious_http/common/oblivious_http_header_key_config.h b/quiche/oblivious_http/common/oblivious_http_header_key_config.h
index f57efbd..9e48336 100644
--- a/quiche/oblivious_http/common/oblivious_http_header_key_config.h
+++ b/quiche/oblivious_http/common/oblivious_http_header_key_config.h
@@ -25,6 +25,10 @@
       "message/bhttp request";
   static constexpr absl::string_view kOhttpResponseLabel =
       "message/bhttp response";
+  static constexpr absl::string_view kChunkedOhttpRequestLabel =
+      "message/bhttp chunked request";
+  static constexpr absl::string_view kChunkedOhttpResponseLabel =
+      "message/bhttp chunked response";
   // Length of the Oblivious HTTP header.
   static constexpr uint32_t kHeaderLength =
       sizeof(uint8_t) + (3 * sizeof(uint16_t));
diff --git a/quiche/oblivious_http/common/oblivious_http_header_key_config_test.cc b/quiche/oblivious_http/common/oblivious_http_header_key_config_test.cc
index 67a78d3..fb3688d 100644
--- a/quiche/oblivious_http/common/oblivious_http_header_key_config_test.cc
+++ b/quiche/oblivious_http/common/oblivious_http_header_key_config_test.cc
@@ -60,12 +60,11 @@
   return ohttp_key;
 }
 
-TEST(ObliviousHttpHeaderKeyConfig, TestSerializeRecipientContextInfo) {
+void ExpectSerializedRecipientContextInfo(absl::string_view ohttp_req_label) {
   uint8_t key_id = 3;
   uint16_t kem_id = EVP_HPKE_DHKEM_X25519_HKDF_SHA256;
   uint16_t kdf_id = EVP_HPKE_HKDF_SHA256;
   uint16_t aead_id = EVP_HPKE_AES_256_GCM;
-  absl::string_view ohttp_req_label = "message/bhttp request";
   std::string expected(ohttp_req_label);
   uint8_t zero_byte = 0x00;
   int buf_len = ohttp_req_label.size() + sizeof(zero_byte) + sizeof(key_id) +
@@ -77,10 +76,21 @@
   auto instance =
       ObliviousHttpHeaderKeyConfig::Create(key_id, kem_id, kdf_id, aead_id);
   ASSERT_TRUE(instance.ok());
-  EXPECT_EQ(instance.value().SerializeRecipientContextInfo(), expected);
+  EXPECT_EQ(instance.value().SerializeRecipientContextInfo(ohttp_req_label),
+            expected);
   EXPECT_THAT(instance->DebugString(), HasSubstr("AES-256-GCM"));
 }
 
+TEST(ObliviousHttpHeaderKeyConfig,
+     TestSerializeRecipientContextInfoStandardLabel) {
+  ExpectSerializedRecipientContextInfo("message/bhttp request");
+}
+
+TEST(ObliviousHttpHeaderKeyConfig,
+     TestSerializeRecipientContextInfoChunkedLabel) {
+  ExpectSerializedRecipientContextInfo("message/bhttp chunked request");
+}
+
 TEST(ObliviousHttpHeaderKeyConfig, TestValidKeyConfig) {
   auto valid_key_config = ObliviousHttpHeaderKeyConfig::Create(
       2, EVP_HPKE_DHKEM_X25519_HKDF_SHA256, EVP_HPKE_HKDF_SHA256,
diff --git a/quiche/oblivious_http/oblivious_http_gateway.cc b/quiche/oblivious_http/oblivious_http_gateway.cc
index 4a15cb6..5cb7b20 100644
--- a/quiche/oblivious_http/oblivious_http_gateway.cc
+++ b/quiche/oblivious_http/oblivious_http_gateway.cc
@@ -6,14 +6,28 @@
 #include <string>
 #include <utility>
 
+#include "absl/base/attributes.h"
 #include "absl/memory/memory.h"
 #include "absl/status/status.h"
 #include "absl/status/statusor.h"
+#include "absl/strings/str_cat.h"
 #include "absl/strings/string_view.h"
+#include "openssl/aead.h"
+#include "openssl/base.h"
+#include "openssl/hpke.h"
 #include "quiche/common/quiche_crypto_logging.h"
+#include "quiche/common/quiche_data_reader.h"
+#include "quiche/common/quiche_data_writer.h"
+#include "quiche/common/quiche_endian.h"
 #include "quiche/common/quiche_random.h"
+#include "quiche/oblivious_http/buffers/oblivious_http_request.h"
+#include "quiche/oblivious_http/common/oblivious_http_chunk_handler.h"
+#include "quiche/oblivious_http/common/oblivious_http_header_key_config.h"
 
 namespace quiche {
+namespace {
+constexpr uint64_t kFinalChunkIndicator = 0;
+}
 
 // Constructor.
 ObliviousHttpGateway::ObliviousHttpGateway(
@@ -77,4 +91,276 @@
       quiche_random_);
 }
 
+// Constructor.
+ChunkedObliviousHttpGateway::ChunkedObliviousHttpGateway(
+    bssl::UniquePtr<EVP_HPKE_KEY> recipient_key,
+    const ObliviousHttpHeaderKeyConfig& ohttp_key_config,
+    ObliviousHttpChunkHandler& chunk_handler, QuicheRandom* quiche_random)
+    : server_hpke_key_(std::move(recipient_key)),
+      ohttp_key_config_(ohttp_key_config),
+      chunk_handler_(chunk_handler),
+      quiche_random_(quiche_random) {}
+
+absl::StatusOr<ChunkedObliviousHttpGateway> ChunkedObliviousHttpGateway::Create(
+    absl::string_view hpke_private_key,
+    const ObliviousHttpHeaderKeyConfig& ohttp_key_config,
+    ObliviousHttpChunkHandler& chunk_handler, QuicheRandom* quiche_random) {
+  absl::StatusOr<bssl::UniquePtr<EVP_HPKE_KEY>> recipient_key =
+      CreateServerRecipientKey(hpke_private_key, ohttp_key_config);
+  if (!recipient_key.ok()) {
+    return recipient_key.status();
+  }
+  if (quiche_random == nullptr) {
+    quiche_random = QuicheRandom::GetInstance();
+  }
+  return ChunkedObliviousHttpGateway(std::move(*recipient_key),
+                                     ohttp_key_config, chunk_handler,
+                                     quiche_random);
+}
+
+void ChunkedObliviousHttpGateway::InitializeRequestCheckpoint(
+    absl::string_view data) {
+  request_checkpoint_view_ = data;
+  // Prepend buffered data if present. This is the data from a previous call to
+  // DecryptRequest that could not finish because it needed this new data.
+  if (!request_buffer_.empty()) {
+    if (!data.empty()) {
+      absl::StrAppend(&request_buffer_, data);
+    }
+    request_checkpoint_view_ = request_buffer_;
+  }
+}
+
+absl::Status ChunkedObliviousHttpGateway::DecryptRequestCheckpoint(
+    bool end_stream) {
+  QuicheDataReader reader(request_checkpoint_view_);
+  switch (request_current_section_) {
+    case RequestMessageSection::kEnd:
+      return absl::InternalError("Request is invalid.");
+    case RequestMessageSection::kHeader: {
+      // Check there is enough data for the chunked request header.
+      // https://www.ietf.org/archive/id/draft-ietf-ohai-chunked-ohttp-05.html#name-request-format
+      if (reader.PeekRemainingPayload().size() <
+          ObliviousHttpHeaderKeyConfig::kHeaderLength +
+              EVP_HPKE_KEM_enc_len(EVP_HPKE_KEY_kem(server_hpke_key_.get()))) {
+        return absl::OutOfRangeError("Not enough data to read header.");
+      }
+      absl::StatusOr<ObliviousHttpRequest::Context> context =
+          ObliviousHttpRequest::DecodeEncapsulatedRequestHeader(
+              reader, *server_hpke_key_, ohttp_key_config_,
+              ObliviousHttpHeaderKeyConfig::kChunkedOhttpRequestLabel);
+      if (!context.ok()) {
+        return context.status();
+      }
+
+      oblivious_http_request_context_ = std::move(*context);
+      SaveCheckpoint(reader);
+      request_current_section_ = RequestMessageSection::kChunk;
+    }
+      ABSL_FALLTHROUGH_INTENDED;
+    case RequestMessageSection::kChunk: {
+      uint64_t length_or_final_chunk_indicator;
+      do {
+        if (!reader.ReadVarInt62(&length_or_final_chunk_indicator)) {
+          return absl::OutOfRangeError("Not enough data to read chunk length.");
+        }
+        absl::string_view chunk;
+        if (length_or_final_chunk_indicator != kFinalChunkIndicator) {
+          if (!reader.ReadStringPiece(&chunk,
+                                      length_or_final_chunk_indicator)) {
+            return absl::OutOfRangeError("Not enough data to read chunk.");
+          }
+          if (!oblivious_http_request_context_.has_value()) {
+            return absl::InternalError(
+                "HPKE context has not been derived from an encrypted request.");
+          }
+          absl::StatusOr<std::string> decrypted_chunk =
+              ObliviousHttpRequest::DecryptChunk(
+                  *oblivious_http_request_context_, chunk,
+                  /*is_final_chunk=*/false);
+          if (!decrypted_chunk.ok()) {
+            return decrypted_chunk.status();
+          }
+          absl::Status handle_chunk_status =
+              chunk_handler_.OnDecryptedChunk(*decrypted_chunk);
+          if (!handle_chunk_status.ok()) {
+            return handle_chunk_status;
+          }
+        }
+
+        SaveCheckpoint(reader);
+      } while (length_or_final_chunk_indicator != kFinalChunkIndicator);
+
+      request_current_section_ = RequestMessageSection::kFinalChunk;
+    }
+      ABSL_FALLTHROUGH_INTENDED;
+    case RequestMessageSection::kFinalChunk: {
+      if (!end_stream) {
+        return absl::OutOfRangeError("Not enough data to read final chunk.");
+      }
+      if (!oblivious_http_request_context_.has_value()) {
+        return absl::InternalError(
+            "HPKE context has not been derived from an encrypted request.");
+      }
+      absl::StatusOr<std::string> decrypted_chunk =
+          ObliviousHttpRequest::DecryptChunk(*oblivious_http_request_context_,
+                                             reader.PeekRemainingPayload(),
+                                             /*is_final_chunk=*/true);
+      if (!decrypted_chunk.ok()) {
+        return decrypted_chunk.status();
+      }
+      absl::Status handle_chunk_status =
+          chunk_handler_.OnDecryptedChunk(*decrypted_chunk);
+      if (!handle_chunk_status.ok()) {
+        return handle_chunk_status;
+      }
+      handle_chunk_status = chunk_handler_.OnChunksDone();
+      if (!handle_chunk_status.ok()) {
+        return handle_chunk_status;
+      }
+    }
+  }
+  return absl::OkStatus();
+}
+
+absl::Status ChunkedObliviousHttpGateway::DecryptRequest(absl::string_view data,
+                                                         bool end_stream) {
+  if (request_current_section_ == RequestMessageSection::kEnd) {
+    return absl::InternalError("Decrypting is marked as invalid.");
+  }
+  InitializeRequestCheckpoint(data);
+  absl::Status status = DecryptRequestCheckpoint(end_stream);
+  if (end_stream) {
+    request_current_section_ = RequestMessageSection::kEnd;
+    if (absl::IsOutOfRange(status)) {
+      // OutOfRange only used internally for buffering, so return
+      // InvalidArgument if this is the end of the stream.
+      status = absl::InvalidArgumentError(status.message());
+    }
+    return status;
+  }
+  if (absl::IsOutOfRange(status)) {
+    BufferRequestCheckpoint();
+    return absl::OkStatus();
+  }
+  if (!status.ok()) {
+    request_current_section_ = RequestMessageSection::kEnd;
+  }
+
+  request_buffer_.clear();
+  return status;
+}
+
+absl::StatusOr<std::string> ChunkedObliviousHttpGateway::EncryptResponse(
+    absl::string_view plaintext_payload, bool is_final_chunk) {
+  if (response_current_section_ == ResponseMessageSection::kEnd) {
+    return absl::InvalidArgumentError("Encrypting is marked as invalid.");
+  }
+  absl::StatusOr<std::string> response_chunk =
+      EncryptResponseChunk(plaintext_payload, is_final_chunk);
+  if (!response_chunk.ok()) {
+    response_current_section_ = ResponseMessageSection::kEnd;
+  }
+  return response_chunk;
+}
+
+absl::StatusOr<std::string> ChunkedObliviousHttpGateway::EncryptResponseChunk(
+    absl::string_view plaintext_payload, bool is_final_chunk) {
+  if (response_chunk_counter_.has_value() &&
+      response_chunk_counter_->LimitExceeded()) {
+    return absl::InternalError(
+        "Response chunk counter has exceeded the maximum allowed value.");
+  }
+  if (!oblivious_http_request_context_.has_value()) {
+    return absl::InternalError(
+        "HPKE context has not been derived from an encrypted request.");
+  }
+
+  if (!aead_context_data_.has_value()) {
+    absl::StatusOr<ObliviousHttpResponse::CommonAeadParamsResult> aead_params =
+        ObliviousHttpResponse::GetCommonAeadParams(
+            *oblivious_http_request_context_);
+    if (!aead_params.ok()) {
+      return aead_params.status();
+    }
+
+    // secret_len represents max(Nn, Nk))
+    response_nonce_ = std::string(aead_params->secret_len, '\0');
+    quiche_random_->RandBytes(response_nonce_.data(), response_nonce_.size());
+
+    auto aead_context_data = ObliviousHttpResponse::GetAeadContextData(
+        *oblivious_http_request_context_, *aead_params,
+        ObliviousHttpHeaderKeyConfig::kChunkedOhttpResponseLabel,
+        response_nonce_);
+    if (!aead_context_data.ok()) {
+      return aead_context_data.status();
+    }
+    aead_context_data_.emplace(std::move(*aead_context_data));
+
+    auto response_chunk_counter = ObliviousHttpResponse::ChunkCounter::Create(
+        aead_context_data_->aead_nonce);
+    if (!response_chunk_counter.ok()) {
+      return response_chunk_counter.status();
+    }
+    response_chunk_counter_.emplace(std::move(*response_chunk_counter));
+  }
+
+  if (!response_chunk_counter_.has_value()) {
+    return absl::InternalError(
+        "Response chunk counter has not been initialized.");
+  }
+
+  absl::StatusOr<std::string> encrypted_data =
+      ObliviousHttpResponse::EncryptChunk(
+          *oblivious_http_request_context_, *aead_context_data_,
+          plaintext_payload, response_chunk_counter_->GetChunkNonce(),
+          is_final_chunk);
+  if (!encrypted_data.ok()) {
+    return encrypted_data.status();
+  }
+
+  absl::string_view maybe_nonce;
+  if (response_current_section_ == ResponseMessageSection::kNonce) {
+    maybe_nonce = response_nonce_;
+    response_current_section_ = ResponseMessageSection::kChunk;
+  }
+
+  uint8_t chunk_var_int_length =
+      QuicheDataWriter::GetVarInt62Len(encrypted_data->size());
+  uint64_t chunk_var_int = encrypted_data->size();
+  if (is_final_chunk) {
+    response_current_section_ = ResponseMessageSection::kEnd;
+    chunk_var_int_length =
+        QuicheDataWriter::GetVarInt62Len(kFinalChunkIndicator);
+    // encrypted_data is guaranteed to be non-empty, so chunk_var_int_length
+    // should never be 0.
+    if (chunk_var_int_length == 0) {
+      return absl::InvalidArgumentError(
+          "Encrypted data is too large to be represented as a varint.");
+    }
+    chunk_var_int = kFinalChunkIndicator;
+  }
+
+  std::string response_buffer(
+      maybe_nonce.size() + chunk_var_int_length + encrypted_data->size(), '\0');
+  QuicheDataWriter writer(response_buffer.size(), response_buffer.data());
+
+  if (!writer.WriteStringPiece(maybe_nonce)) {
+    return absl::InternalError("Failed to write response nonce to buffer.");
+  }
+  if (!writer.WriteVarInt62(chunk_var_int)) {
+    return absl::InternalError("Failed to write chunk to buffer.");
+  }
+  if (!writer.WriteStringPiece(*encrypted_data)) {
+    return absl::InternalError("Failed to write encrypted data to buffer.");
+  }
+
+  if (writer.remaining() != 0) {
+    return absl::InternalError("Failed to write all data.");
+  }
+
+  response_chunk_counter_->Increment();
+  return response_buffer;
+}
+
 }  // namespace quiche
diff --git a/quiche/oblivious_http/oblivious_http_gateway.h b/quiche/oblivious_http/oblivious_http_gateway.h
index fce1f04..6c84c5c 100644
--- a/quiche/oblivious_http/oblivious_http_gateway.h
+++ b/quiche/oblivious_http/oblivious_http_gateway.h
@@ -1,17 +1,23 @@
 #ifndef QUICHE_OBLIVIOUS_HTTP_OBLIVIOUS_HTTP_GATEWAY_H_
 #define QUICHE_OBLIVIOUS_HTTP_OBLIVIOUS_HTTP_GATEWAY_H_
 
+#include <cmath>
 #include <memory>
+#include <optional>
 #include <string>
+#include <utility>
 
+#include "absl/status/status.h"
 #include "absl/status/statusor.h"
 #include "absl/strings/string_view.h"
 #include "openssl/base.h"
 #include "openssl/hpke.h"
 #include "quiche/common/platform/api/quiche_export.h"
+#include "quiche/common/quiche_data_reader.h"
 #include "quiche/common/quiche_random.h"
 #include "quiche/oblivious_http/buffers/oblivious_http_request.h"
 #include "quiche/oblivious_http/buffers/oblivious_http_response.h"
+#include "quiche/oblivious_http/common/oblivious_http_chunk_handler.h"
 #include "quiche/oblivious_http/common/oblivious_http_header_key_config.h"
 
 namespace quiche {
@@ -82,6 +88,116 @@
   QuicheRandom* quiche_random_;
 };
 
+// Manages a chunked Oblivious HTTP request and response.
+// It's designed to process incoming request data in chunks, decrypting each one
+// as it arrives and passing it to a handler function. It then continuously
+// encrypts and sends back response chunks. This object maintains an internal
+// state, so it can only be used for one complete request-response cycle.
+class QUICHE_EXPORT ChunkedObliviousHttpGateway {
+ public:
+  // Creates a ChunkedObliviousHttpGateway. Like `ObliviousHttpGateway`,
+  // `hpke_private_key` must outlive the gateway. `quiche_random` can be
+  // initialized to nullptr, in which case the default
+  // `QuicheRandom::GetInstance()` will be used.
+  static absl::StatusOr<ChunkedObliviousHttpGateway> Create(
+      absl::string_view hpke_private_key,
+      const ObliviousHttpHeaderKeyConfig& ohttp_key_config,
+      ObliviousHttpChunkHandler& chunk_handler,
+      QuicheRandom* quiche_random = nullptr);
+
+  // only Movable (due to `UniquePtr server_hpke_key_`).
+  ChunkedObliviousHttpGateway(ChunkedObliviousHttpGateway&& other) = default;
+
+  ~ChunkedObliviousHttpGateway() = default;
+
+  // Parses the data into the corresponding chunks and decrypts them. This can
+  // be invoked multiple times as data arrives, incomplete chunks will be
+  // buffered. The first time it is called it will also decode the HPKE header.
+  // On successful decryption, the chunk handler will be invoked. The
+  // `end_stream` parameter must be set to true if the data contains the final
+  // portion of the final chunk.
+  absl::Status DecryptRequest(absl::string_view data, bool end_stream);
+
+  // Encrypts the data as a single chunk. If `is_final_chunk` is true, the
+  // response will be encoded with the final chunk indicator.
+  absl::StatusOr<std::string> EncryptResponse(
+      absl::string_view plaintext_payload, bool is_final_chunk);
+
+ private:
+  enum class RequestMessageSection {
+    kHeader,
+    kChunk,
+    kFinalChunk,
+    // Set by end_stream or if there is an error.
+    kEnd,
+  };
+  enum class ResponseMessageSection {
+    kNonce,
+    kChunk,
+    // Set after the final chunk is encrypted or if there is an error.
+    kEnd,
+  };
+
+  explicit ChunkedObliviousHttpGateway(
+      bssl::UniquePtr<EVP_HPKE_KEY> recipient_key,
+      const ObliviousHttpHeaderKeyConfig& ohttp_key_config,
+      ObliviousHttpChunkHandler& chunk_handler, QuicheRandom* quiche_random);
+
+  // Initializes the checkpoint with the provided data and any buffered data.
+  void InitializeRequestCheckpoint(absl::string_view data);
+  // Carries out the decrypting logic from the checkpoint. Returns
+  // OutOfRangeError if there is not enough data to process the current
+  // section. When a section is fully processed, the checkpoint is updated.
+  absl::Status DecryptRequestCheckpoint(bool end_stream);
+  // Saves the checkpoint based on the current position of the reader.
+  void SaveCheckpoint(const QuicheDataReader& reader) {
+    request_checkpoint_view_ = reader.PeekRemainingPayload();
+  }
+  // Buffers the request checkpoint.
+  void BufferRequestCheckpoint() {
+    if (request_buffer_ != request_checkpoint_view_) {
+      request_buffer_ = std::string(request_checkpoint_view_);
+    }
+  }
+  absl::StatusOr<std::string> EncryptResponseChunk(
+      absl::string_view plaintext_payload, bool is_final_chunk);
+
+  bssl::UniquePtr<EVP_HPKE_KEY> server_hpke_key_;
+  // Holds server's keyID and HPKE related IDs that's published under HPKE
+  // public Key configuration.
+  // https://www.rfc-editor.org/rfc/rfc9458.html#section-3
+  ObliviousHttpHeaderKeyConfig ohttp_key_config_;
+  // The handler to invoke when a chunk is decrypted successfully.
+  ObliviousHttpChunkHandler& chunk_handler_;
+  QuicheRandom* quiche_random_;
+
+  std::string request_buffer_;
+  // Tracks the remaining data to be processed or buffered.
+  // When decoding fails due to missing data, we buffer based on this
+  // checkpoint and return. When decoding succeeds, we update the checkpoint
+  // to not buffer the already processed data.
+  absl::string_view request_checkpoint_view_;
+  RequestMessageSection request_current_section_ =
+      RequestMessageSection::kHeader;
+  ResponseMessageSection response_current_section_ =
+      ResponseMessageSection::kNonce;
+
+  // HPKE data derived from successfully decoding the chunked
+  // request header when calling `DecryptRequest`.
+  std::optional<ObliviousHttpRequest::Context> oblivious_http_request_context_;
+  // The nonce for the response.
+  std::string response_nonce_;
+  // AEAD context data for the response. This is derived from the request HPKE
+  // context data and response nonce.
+  std::optional<ObliviousHttpResponse::AeadContextData> aead_context_data_;
+
+  // Used to determine whether the response nonce has already been encoded.
+  bool has_encoded_response_nonce_ = false;
+  // Counter to keep track of the number of response chunks generated and to
+  // generate the corresponding chunk nonce.
+  std::optional<ObliviousHttpResponse::ChunkCounter> response_chunk_counter_;
+};
+
 }  // namespace quiche
 
 #endif  // QUICHE_OBLIVIOUS_HTTP_OBLIVIOUS_HTTP_GATEWAY_H_
diff --git a/quiche/oblivious_http/oblivious_http_gateway_test.cc b/quiche/oblivious_http/oblivious_http_gateway_test.cc
index 209af36..04360f0 100644
--- a/quiche/oblivious_http/oblivious_http_gateway_test.cc
+++ b/quiche/oblivious_http/oblivious_http_gateway_test.cc
@@ -2,21 +2,66 @@
 
 #include <stdint.h>
 
+#include <algorithm>
+#include <cstddef>
+#include <cstring>
 #include <string>
 #include <utility>
+#include <vector>
 
 #include "absl/status/status.h"
 #include "absl/status/statusor.h"
 #include "absl/strings/escaping.h"
+#include "absl/strings/str_cat.h"
 #include "absl/strings/string_view.h"
+#include "openssl/hpke.h"
 #include "quiche/common/platform/api/quiche_test.h"
 #include "quiche/common/platform/api/quiche_thread.h"
 #include "quiche/common/quiche_random.h"
+#include "quiche/common/test_tools/quiche_test_utils.h"
 #include "quiche/oblivious_http/buffers/oblivious_http_request.h"
+#include "quiche/oblivious_http/common/oblivious_http_chunk_handler.h"
+#include "quiche/oblivious_http/common/oblivious_http_header_key_config.h"
 
 namespace quiche {
 namespace {
 
+constexpr absl::string_view kEncapsulatedChunkedRequest =
+    "01002000010001"
+    "8811eb457e100811c40a0aa71340a1b81d804bb986f736f2f566a7199761a032"
+    "1c2ad24942d4d692563012f2980c8fef437a336b9b2fc938ef77a5834f"
+    "1d2e33d8fd25577afe31bd1c79d094f76b6250ae6549b473ecd950501311"
+    "001c6c1395d0ef7c1022297966307b8a7f";
+
+class TestChunkHandler : public ObliviousHttpChunkHandler {
+ public:
+  TestChunkHandler() = default;
+  ~TestChunkHandler() override = default;
+  absl::Status OnDecryptedChunk(absl::string_view decrypted_chunk) override {
+    EXPECT_FALSE(on_chunks_done_called_);
+    chunk_count_++;
+    absl::StrAppend(&concatenated_decrypted_chunks_, decrypted_chunk);
+    return absl::OkStatus();
+  }
+  absl::Status OnChunksDone() override {
+    EXPECT_FALSE(on_chunks_done_called_);
+    on_chunks_done_called_ = true;
+    std::string expected_request;
+    EXPECT_TRUE(absl::HexStringToBytes(
+        "00034745540568747470730b6578616d706c652e636f6d012f",
+        &expected_request));
+    EXPECT_EQ(concatenated_decrypted_chunks_, expected_request);
+    return absl::OkStatus();
+  }
+  uint64_t GetChunkCount() const { return chunk_count_; }
+  bool GetOnChunksDoneCalled() const { return on_chunks_done_called_; }
+
+ private:
+  uint64_t chunk_count_ = 0;
+  bool on_chunks_done_called_ = false;
+  std::string concatenated_decrypted_chunks_;
+};
+
 std::string GetHpkePrivateKey() {
   // Dev/Test private key generated using Keystore.
   absl::string_view hpke_key_hex =
@@ -76,6 +121,167 @@
   ASSERT_FALSE(decrypted_req->GetPlaintextData().empty());
 }
 
+absl::StatusOr<ChunkedObliviousHttpGateway> CreateChunkedObliviousHttpGateway(
+    ObliviousHttpChunkHandler& chunk_handler,
+    QuicheRandom* quiche_random = nullptr) {
+  constexpr absl::string_view kX25519SecretKey =
+      "1c190d72acdbe4dbc69e680503bb781a932c70a12c8f3754434c67d8640d8698";
+  std::string x25519_secret_key_bytes;
+  EXPECT_TRUE(
+      absl::HexStringToBytes(kX25519SecretKey, &x25519_secret_key_bytes));
+
+  return ChunkedObliviousHttpGateway::Create(
+      x25519_secret_key_bytes,
+      GetOhttpKeyConfig(
+          /*key_id=*/1, EVP_HPKE_DHKEM_X25519_HKDF_SHA256, EVP_HPKE_HKDF_SHA256,
+          EVP_HPKE_AES_128_GCM),
+      chunk_handler, quiche_random);
+}
+
+TEST(ChunkedObliviousHttpGateway, ProvisionKeyAndDecapsulateFullRequest) {
+  // Example from
+  // https://www.ietf.org/archive/id/draft-ietf-ohai-chunked-ohttp-05.html#appendix-A
+  TestChunkHandler chunk_handler;
+  auto instance = CreateChunkedObliviousHttpGateway(chunk_handler);
+
+  std::string encapsulated_request_bytes;
+  ASSERT_TRUE(absl::HexStringToBytes(kEncapsulatedChunkedRequest,
+                                     &encapsulated_request_bytes));
+
+  QUICHE_EXPECT_OK(instance->DecryptRequest(encapsulated_request_bytes, true));
+  EXPECT_TRUE(chunk_handler.GetOnChunksDoneCalled());
+  EXPECT_EQ(chunk_handler.GetChunkCount(), 3);
+}
+
+TEST(ChunkedObliviousHttpGateway, ProvisionKeyAndDecapsulateBufferedRequest) {
+  // Example from
+  // https://www.ietf.org/archive/id/draft-ietf-ohai-chunked-ohttp-05.html#appendix-A
+  TestChunkHandler chunk_handler;
+  auto instance = CreateChunkedObliviousHttpGateway(chunk_handler);
+
+  std::string encapsulated_request_bytes;
+  ASSERT_TRUE(absl::HexStringToBytes(kEncapsulatedChunkedRequest,
+                                     &encapsulated_request_bytes));
+
+  for (size_t i = 0; i < encapsulated_request_bytes.size(); i++) {
+    absl::string_view current_byte(&encapsulated_request_bytes[i], 1);
+    QUICHE_EXPECT_OK(instance->DecryptRequest(current_byte, false));
+  }
+
+  QUICHE_EXPECT_OK(instance->DecryptRequest("", true));
+  EXPECT_TRUE(chunk_handler.GetOnChunksDoneCalled());
+  EXPECT_EQ(chunk_handler.GetChunkCount(), 3);
+}
+
+TEST(ChunkedObliviousHttpGateway, DecryptingAfterDoneReturnsInvalidArgument) {
+  TestChunkHandler chunk_handler;
+  auto instance = CreateChunkedObliviousHttpGateway(chunk_handler);
+
+  std::string encapsulated_request_bytes;
+  ASSERT_TRUE(absl::HexStringToBytes(kEncapsulatedChunkedRequest,
+                                     &encapsulated_request_bytes));
+
+  QUICHE_EXPECT_OK(instance->DecryptRequest(encapsulated_request_bytes, true));
+
+  auto second_decrypt =
+      instance->DecryptRequest(encapsulated_request_bytes, true);
+  EXPECT_EQ(second_decrypt.code(), absl::StatusCode::kInternal);
+  EXPECT_EQ(second_decrypt.message(), "Decrypting is marked as invalid.");
+}
+
+TEST(ChunkedObliviousHttpGateway, FinalChunkNotDoneReturnsInvalidArgument) {
+  TestChunkHandler chunk_handler;
+  auto instance = CreateChunkedObliviousHttpGateway(chunk_handler);
+
+  std::string encapsulated_request_bytes;
+  ASSERT_TRUE(absl::HexStringToBytes("010020", &encapsulated_request_bytes));
+
+  EXPECT_EQ(instance->DecryptRequest(encapsulated_request_bytes, true).code(),
+            absl::StatusCode::kInvalidArgument);
+}
+
+TEST(ChunkedObliviousHttpGateway, GettingDecryptErrorSetsGatewayToInvalid) {
+  TestChunkHandler chunk_handler;
+  auto instance = CreateChunkedObliviousHttpGateway(chunk_handler);
+
+  std::string invalid_key_request =
+      "020020000100014b28f881333e7c164ffc499ad9796f877f4e1051ee6d31bad19dec96c2"
+      "08b4726374e469135906992e";
+  std::string encapsulated_request_bytes;
+  ASSERT_TRUE(
+      absl::HexStringToBytes(invalid_key_request, &encapsulated_request_bytes));
+
+  EXPECT_EQ(instance->DecryptRequest(encapsulated_request_bytes, false).code(),
+            absl::StatusCode::kInvalidArgument);
+
+  auto second_decrypt =
+      instance->DecryptRequest(encapsulated_request_bytes, true);
+  EXPECT_EQ(second_decrypt.code(), absl::StatusCode::kInternal);
+  EXPECT_EQ(second_decrypt.message(), "Decrypting is marked as invalid.");
+}
+
+TEST(ChunkedObliviousHttpGateway, InvalidKeyConfigReturnsInvalidArgument) {
+  TestChunkHandler chunk_handler;
+  auto instance = CreateChunkedObliviousHttpGateway(chunk_handler);
+
+  std::string encapsulated_request_bytes;
+  ASSERT_TRUE(
+      absl::HexStringToBytes("990020000100018811eb457e100811c40a0aa71340a1b81d8"
+                             "04bb986f736f2f566a7199761a032",
+                             &encapsulated_request_bytes));
+
+  EXPECT_EQ(instance->DecryptRequest(encapsulated_request_bytes, false).code(),
+            absl::StatusCode::kInvalidArgument);
+}
+
+TEST(ChunkedObliviousHttpGateway, ChunkHandlerOnChunkErrorPropagates) {
+  class FailingChunkHandler : public ObliviousHttpChunkHandler {
+   public:
+    FailingChunkHandler() = default;
+    ~FailingChunkHandler() override = default;
+    absl::Status OnDecryptedChunk(
+        absl::string_view /*decrypted_chunk*/) override {
+      return absl::InvalidArgumentError("Invalid data");
+    }
+    absl::Status OnChunksDone() override {
+      return absl::InvalidArgumentError("Invalid data");
+    }
+  };
+  FailingChunkHandler chunk_handler;
+  auto instance = CreateChunkedObliviousHttpGateway(chunk_handler);
+
+  std::string encapsulated_request_bytes;
+  ASSERT_TRUE(absl::HexStringToBytes(kEncapsulatedChunkedRequest,
+                                     &encapsulated_request_bytes));
+
+  EXPECT_EQ(instance->DecryptRequest(encapsulated_request_bytes, true).code(),
+            absl::StatusCode::kInvalidArgument);
+}
+
+TEST(ChunkedObliviousHttpGateway, ChunkHandlerOnChunksDoneErrorPropagates) {
+  class FailingChunkHandler : public ObliviousHttpChunkHandler {
+   public:
+    FailingChunkHandler() = default;
+    ~FailingChunkHandler() override = default;
+    absl::Status OnDecryptedChunk(
+        absl::string_view /*decrypted_chunk*/) override {
+      return absl::OkStatus();
+    }
+    absl::Status OnChunksDone() override {
+      return absl::InvalidArgumentError("Invalid data");
+    }
+  };
+  FailingChunkHandler chunk_handler;
+  auto instance = CreateChunkedObliviousHttpGateway(chunk_handler);
+
+  std::string encapsulated_request_bytes;
+  ASSERT_TRUE(absl::HexStringToBytes(kEncapsulatedChunkedRequest,
+                                     &encapsulated_request_bytes));
+
+  EXPECT_EQ(instance->DecryptRequest(encapsulated_request_bytes, true).code(),
+            absl::StatusCode::kInvalidArgument);
+}
+
 TEST(ObliviousHttpGateway, TestDecryptingMultipleRequestsWithSingleInstance) {
   auto instance = ObliviousHttpGateway::Create(
       GetHpkePrivateKey(),
@@ -123,6 +329,28 @@
             absl::StatusCode::kInvalidArgument);
 }
 
+TEST(ChunkedObliviousHttpGateway, TestInvalidHPKEKey) {
+  TestChunkHandler chunk_handler;
+  // Invalid private key.
+  EXPECT_EQ(ChunkedObliviousHttpGateway::Create(
+                "Invalid HPKE key",
+                GetOhttpKeyConfig(70, EVP_HPKE_DHKEM_X25519_HKDF_SHA256,
+                                  EVP_HPKE_HKDF_SHA256, EVP_HPKE_AES_256_GCM),
+                chunk_handler)
+                .status()
+                .code(),
+            absl::StatusCode::kInternal);
+  // Empty private key.
+  EXPECT_EQ(ChunkedObliviousHttpGateway::Create(
+                /*hpke_private_key*/ "",
+                GetOhttpKeyConfig(70, EVP_HPKE_DHKEM_X25519_HKDF_SHA256,
+                                  EVP_HPKE_HKDF_SHA256, EVP_HPKE_AES_256_GCM),
+                chunk_handler)
+                .status()
+                .code(),
+            absl::StatusCode::kInvalidArgument);
+}
+
 TEST(ObliviousHttpGateway, TestObliviousResponseHandling) {
   auto ohttp_key_config =
       GetOhttpKeyConfig(3, EVP_HPKE_DHKEM_X25519_HKDF_SHA256,
@@ -147,6 +375,142 @@
   ASSERT_FALSE(encapsulate_resp_on_gateway->EncapsulateAndSerialize().empty());
 }
 
+class TestQuicheRandom : public QuicheRandom {
+ public:
+  TestQuicheRandom(std::string seed) : seed_(seed) {}
+  ~TestQuicheRandom() override {}
+
+  void RandBytes(void* data, size_t len) override {
+    size_t copy_len = std::min(len, seed_.length());
+    memcpy(data, seed_.c_str(), copy_len);
+  }
+
+  uint64_t RandUint64() override { return 0; }
+
+  void InsecureRandBytes(void* /*data*/, size_t /*len*/) override {}
+  uint64_t InsecureRandUint64() override { return 0; }
+
+ private:
+  std::string seed_;
+};
+
+TEST(ChunkedObliviousHttpGateway, SingleChunkResponse) {
+  TestChunkHandler chunk_handler;
+  auto instance = CreateChunkedObliviousHttpGateway(chunk_handler);
+
+  // Request decryption implicitly sets up the context for response encryption
+  std::string encapsulated_request_bytes;
+  ASSERT_TRUE(absl::HexStringToBytes(kEncapsulatedChunkedRequest,
+                                     &encapsulated_request_bytes));
+  QUICHE_EXPECT_OK(instance->DecryptRequest(encapsulated_request_bytes, true));
+
+  // 63 byte response to test final chunk indicator length.
+  std::string plaintext_response =
+      "111111111111111111111111111111111111111111111111111111111111111111111111"
+      "111111111111111111111111111111111111111111111111111111";
+  absl::StatusOr<std::string> encrypted_response =
+      instance->EncryptResponse(plaintext_response, true);
+  QUICHE_EXPECT_OK(encrypted_response);
+  EXPECT_FALSE(encrypted_response->empty());
+  EXPECT_NE(*encrypted_response, plaintext_response);
+}
+
+TEST(ChunkedObliviousHttpGateway, MultipleChunkResponse) {
+  // Example from
+  // https://www.ietf.org/archive/id/draft-ietf-ohai-chunked-ohttp-05.html#appendix-A
+  TestChunkHandler chunk_handler;
+  std::string response_nonce = "bcce7f4cb921309ba5d62edf1769ef09";
+  std::string response_nonce_bytes;
+  EXPECT_TRUE(absl::HexStringToBytes(response_nonce, &response_nonce_bytes));
+  TestQuicheRandom quiche_random(response_nonce_bytes);
+  auto instance =
+      CreateChunkedObliviousHttpGateway(chunk_handler, &quiche_random);
+
+  // Request decrypting implicitly sets up the context for response encryption
+  std::string encapsulated_request_bytes;
+  ASSERT_TRUE(absl::HexStringToBytes(kEncapsulatedChunkedRequest,
+                                     &encapsulated_request_bytes));
+  QUICHE_EXPECT_OK(instance->DecryptRequest(encapsulated_request_bytes, true));
+
+  std::string plaintext_response = "01";
+  std::string plaintext_response_bytes;
+  EXPECT_TRUE(
+      absl::HexStringToBytes(plaintext_response, &plaintext_response_bytes));
+  std::vector<std::string> encrypted_response_chunks;
+  absl::StatusOr<std::string> encrypted_response_chunk =
+      instance->EncryptResponse(plaintext_response_bytes, false);
+  QUICHE_EXPECT_OK(encrypted_response_chunk);
+  std::string encrypted_response_chunk_hex =
+      absl::BytesToHexString(*encrypted_response_chunk);
+  // The first chunk should contain the response nonce.
+  EXPECT_EQ(
+      encrypted_response_chunk_hex,
+      "bcce7f4cb921309ba5d62edf1769ef091179bf1cc87fa0e2c02de4546945aa3d1e48");
+
+  plaintext_response = "40c8";
+  EXPECT_TRUE(
+      absl::HexStringToBytes(plaintext_response, &plaintext_response_bytes));
+  encrypted_response_chunk =
+      instance->EncryptResponse(plaintext_response_bytes, false);
+  QUICHE_EXPECT_OK(encrypted_response_chunk);
+  encrypted_response_chunk_hex =
+      absl::BytesToHexString(*encrypted_response_chunk);
+  EXPECT_EQ(encrypted_response_chunk_hex,
+            "12b348b5bd4c594c16b6170b07b475845d1f32");
+
+  EXPECT_TRUE(
+      absl::HexStringToBytes(plaintext_response, &plaintext_response_bytes));
+  encrypted_response_chunk =
+      instance->EncryptResponse(/*plaintext_payload=*/"", true);
+  QUICHE_EXPECT_OK(encrypted_response_chunk);
+  encrypted_response_chunk_hex =
+      absl::BytesToHexString(*encrypted_response_chunk);
+  EXPECT_EQ(encrypted_response_chunk_hex, "00ed9d8a796617a5b27265f4d73247f639");
+}
+
+TEST(ChunkedObliviousHttpGateway, EncryptingAfterFinalChunkFails) {
+  TestChunkHandler chunk_handler;
+  auto instance = CreateChunkedObliviousHttpGateway(chunk_handler);
+
+  // Request decryption implicitly sets up the context for response encryption
+  std::string encapsulated_request_bytes;
+  ASSERT_TRUE(absl::HexStringToBytes(kEncapsulatedChunkedRequest,
+                                     &encapsulated_request_bytes));
+  QUICHE_EXPECT_OK(instance->DecryptRequest(encapsulated_request_bytes, true));
+
+  std::string plaintext_response = "0140c8";
+  absl::StatusOr<std::string> encrypted_response =
+      instance->EncryptResponse(plaintext_response, true);
+  QUICHE_EXPECT_OK(encrypted_response);
+  EXPECT_EQ(
+      instance->EncryptResponse(plaintext_response, false).status().code(),
+      absl::StatusCode::kInvalidArgument);
+}
+
+TEST(ChunkedObliviousHttpGateway, EncryptingBeforeDecryptingFails) {
+  TestChunkHandler chunk_handler;
+  auto instance = CreateChunkedObliviousHttpGateway(chunk_handler);
+
+  std::string plaintext_response = "0140c8";
+  EXPECT_EQ(
+      instance->EncryptResponse(plaintext_response, false).status().code(),
+      absl::StatusCode::kInternal);
+}
+
+TEST(ChunkedObliviousHttpGateway, EncryptionErrorMarksGatewayInvalid) {
+  TestChunkHandler chunk_handler;
+  auto instance = CreateChunkedObliviousHttpGateway(chunk_handler);
+
+  std::string plaintext_response = "0140c8";
+  EXPECT_EQ(
+      instance->EncryptResponse(plaintext_response, false).status().code(),
+      absl::StatusCode::kInternal);
+
+  EXPECT_EQ(
+      instance->EncryptResponse(plaintext_response, false).status().message(),
+      "Encrypting is marked as invalid.");
+}
+
 TEST(ObliviousHttpGateway,
      TestHandlingMultipleResponsesForMultipleRequestsWithSingleInstance) {
   auto instance = ObliviousHttpGateway::Create(