Reland "iwa: Optimistically update IWAs when key rotation happens"

This is a reland of commit ec89d463d3901ff14afd0a4a87de7e62c31e7c4f

What changed: ScopedBundledIWA's destructor was never called which led
to a memory leak.

Original change's description:
> iwa: Optimistically update IWAs when key rotation happens
>
> This CL wires up IsolatedWebAppUpdateManager to listen to component
> installations and check for updates of affected IWAs accordingly.
>
> Same-version update attempts are now allowed if they might lead to the
> set of signing keys changing.
>
> Faulty updates (when the new bundle doesn't contain the rotated key) are
> simply ignored for now -- the exact handling is TBD.
>
> Bug: 353489152
> Change-Id: I16b00e7a5d51e653192ccff7e8726ba212281001
> Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/5729755
> Reviewed-by: Robbie McElrath <rmcelrath@chromium.org>
> Commit-Queue: Andrew Rayskiy <greengrape@google.com>
> Cr-Commit-Position: refs/heads/main@{#1336147}

Bug: 353489152
Change-Id: I4f1c179a685deeb188d18de1159a686283c15d0f
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/5757202
Auto-Submit: Andrew Rayskiy <greengrape@google.com>
Commit-Queue: Andrew Rayskiy <greengrape@google.com>
Reviewed-by: Robbie McElrath <rmcelrath@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1336691}
diff --git a/chrome/browser/web_applications/isolated_web_apps/isolated_web_app_apply_update_command.cc b/chrome/browser/web_applications/isolated_web_apps/isolated_web_app_apply_update_command.cc
index ac34a7a2..b30a53d2 100644
--- a/chrome/browser/web_applications/isolated_web_apps/isolated_web_app_apply_update_command.cc
+++ b/chrome/browser/web_applications/isolated_web_apps/isolated_web_app_apply_update_command.cc
@@ -138,13 +138,41 @@
   GetMutableDebugValue().Set("pending_update_info",
                              pending_update_info_->AsDebugValue());
 
-  if (isolation_data.version >= pending_update_info_->version) {
+  bool same_version_update_allowed_by_key_rotation = false;
+  switch (LookupRotatedKey(url_info_.web_bundle_id(), GetMutableDebugValue())) {
+    case KeyRotationLookupResult::kNoKeyRotation:
+      break;
+    case KeyRotationLookupResult::kKeyBlocked:
+      ReportFailure(
+          "The web bundle id for this app's bundle has been blocked by the key "
+          "distribution component.");
+      return;
+    case KeyRotationLookupResult::kKeyFound: {
+      KeyRotationData data =
+          GetKeyRotationData(url_info_.web_bundle_id(), isolation_data);
+      if (!data.pending_update_has_rk) {
+        ReportFailure(
+            "The update's integrity block data doesn't contain the required "
+            "public key as instructed by the key distribution component -- the "
+            "update won't succeed.");
+        return;
+      }
+      if (!data.current_installation_has_rk) {
+        same_version_update_allowed_by_key_rotation = true;
+      }
+    } break;
+  }
+
+  if (isolation_data.version > pending_update_info_->version ||
+      (isolation_data.version == pending_update_info_->version &&
+       !same_version_update_allowed_by_key_rotation)) {
     ReportFailure(base::StrCat({"Installed app is already on version ",
                                 isolation_data.version.GetString(),
                                 ". Cannot update to version ",
                                 pending_update_info_->version.GetString()}));
     return;
   }
+
   if (isolation_data.location.dev_mode() !=
       pending_update_info_->location.dev_mode()) {
     std::stringstream s;
@@ -281,17 +309,17 @@
           : std::move(next_step_callback);
 
   ScopedRegistryUpdate update = lock_->sync_bridge().BeginUpdate(
-      // We don't really care whether committing the update succeeds or fails.
-      // However, we want to wait for the write of the database to disk, so that
-      // a potential crash during that write happens before the
-      // to-be-implemented cleanup system for no longer referenced Web Bundles
-      // kicks in.
+      // We don't really care whether committing the update succeeds or
+      // fails. However, we want to wait for the write of the database to
+      // disk, so that a potential crash during that write happens before
+      // the to-be-implemented cleanup system for no longer referenced Web
+      // Bundles kicks in.
       base::IgnoreArgs<bool>(std::move(update_callback)));
 
   WebApp* web_app = update->UpdateApp(url_info_.app_id());
 
-  // This command might fail because the app is no longer installed, or because
-  // it does not have `WebApp::IsolationData` or
+  // This command might fail because the app is no longer installed, or
+  // because it does not have `WebApp::IsolationData` or
   // `WebApp::IsolationData::PendingUpdateInfo`, in which case there is no
   // pending update info for us to delete.
   if (!web_app || !web_app->isolation_data().has_value() ||
diff --git a/chrome/browser/web_applications/isolated_web_apps/isolated_web_app_install_command_helper.cc b/chrome/browser/web_applications/isolated_web_apps/isolated_web_app_install_command_helper.cc
index 7297cd58..ddca8eb 100644
--- a/chrome/browser/web_applications/isolated_web_apps/isolated_web_app_install_command_helper.cc
+++ b/chrome/browser/web_applications/isolated_web_apps/isolated_web_app_install_command_helper.cc
@@ -9,6 +9,7 @@
 #include <string>
 #include <vector>
 
+#include "base/base64.h"
 #include "base/check_op.h"
 #include "base/files/file_path.h"
 #include "base/files/file_util.h"
@@ -26,6 +27,7 @@
 #include "chrome/browser/profiles/profile.h"
 #include "chrome/browser/web_applications/isolated_web_apps/isolated_web_app_features.h"
 #include "chrome/browser/web_applications/isolated_web_apps/isolated_web_app_install_source.h"
+#include "chrome/browser/web_applications/isolated_web_apps/isolated_web_app_integrity_block_data.h"
 #include "chrome/browser/web_applications/isolated_web_apps/isolated_web_app_response_reader_factory.h"
 #include "chrome/browser/web_applications/isolated_web_apps/isolated_web_app_source.h"
 #include "chrome/browser/web_applications/isolated_web_apps/isolated_web_app_storage_location.h"
@@ -33,6 +35,7 @@
 #include "chrome/browser/web_applications/isolated_web_apps/isolated_web_app_url_info.h"
 #include "chrome/browser/web_applications/isolated_web_apps/isolated_web_app_validator.h"
 #include "chrome/browser/web_applications/isolated_web_apps/isolated_web_app_version.h"
+#include "chrome/browser/web_applications/isolated_web_apps/key_distribution/iwa_key_distribution_info_provider.h"
 #include "chrome/browser/web_applications/isolated_web_apps/pending_install_info.h"
 #include "chrome/browser/web_applications/web_app_icon_operations.h"
 #include "chrome/browser/web_applications/web_app_install_info.h"
@@ -137,6 +140,14 @@
   return result == webapps::WebAppUrlLoaderResult::kUrlLoaded;
 }
 
+bool IntegrityBlockDataHasRotatedKey(
+    base::optional_ref<const IsolatedWebAppIntegrityBlockData>
+        integrity_block_data,
+    base::span<const uint8_t> rotated_key) {
+  return integrity_block_data &&
+         integrity_block_data->HasPublicKey(rotated_key);
+}
+
 }  // namespace
 
 void CleanupLocationIfOwned(const base::FilePath& profile_dir,
@@ -222,6 +233,59 @@
   return *iwa;
 }
 
+KeyRotationLookupResult LookupRotatedKey(
+    const web_package::SignedWebBundleId& web_bundle_id,
+    base::optional_ref<base::Value::Dict> debug_log) {
+  auto log_rotated_key = [&](const std::string& value) {
+    if (debug_log) {
+      debug_log->Set("rotated_key", value);
+    }
+  };
+
+  const auto* kr_info =
+      IwaKeyDistributionInfoProvider::GetInstance()->GetKeyRotationInfo(
+          web_bundle_id.id());
+  if (!kr_info) {
+    return KeyRotationLookupResult::kNoKeyRotation;
+  }
+
+  if (!kr_info->public_key) {
+    log_rotated_key("<disabled>");
+    return KeyRotationLookupResult::kKeyBlocked;
+  }
+  log_rotated_key(base::Base64Encode(*kr_info->public_key));
+  return KeyRotationLookupResult::kKeyFound;
+}
+
+KeyRotationData GetKeyRotationData(
+    const web_package::SignedWebBundleId& web_bundle_id,
+    const WebApp::IsolationData& isolation_data) {
+  const auto* kr_info =
+      IwaKeyDistributionInfoProvider::GetInstance()->GetKeyRotationInfo(
+          web_bundle_id.id());
+  CHECK(kr_info && kr_info->public_key)
+      << "`GetKeyRotationData()` must only be called if `LookupRotatedKey()` "
+         "has previously reported `KeyRotationLookupResult::kKeyFound`.";
+
+  const auto& rotated_key = *kr_info->public_key;
+
+  // Checks whether `rotated_key` is contained in
+  // `isolation_data.integrity_block_data`.
+  const bool current_installation_has_rk = IntegrityBlockDataHasRotatedKey(
+      isolation_data.integrity_block_data, rotated_key);
+  const auto& pending_update = isolation_data.pending_update_info();
+
+  // Checks whether `rotated_key` is contained in
+  // `isolation_data.pending_update_info.integrity_block_data`.
+  const bool pending_update_has_rk =
+      pending_update && IntegrityBlockDataHasRotatedKey(
+                            pending_update->integrity_block_data, rotated_key);
+
+  return {.rotated_key = raw_ref(rotated_key),
+          .current_installation_has_rk = current_installation_has_rk,
+          .pending_update_has_rk = pending_update_has_rk};
+}
+
 // static
 std::unique_ptr<content::WebContents>
 IsolatedWebAppInstallCommandHelper::CreateIsolatedWebAppWebContents(
diff --git a/chrome/browser/web_applications/isolated_web_apps/isolated_web_app_install_command_helper.h b/chrome/browser/web_applications/isolated_web_apps/isolated_web_app_install_command_helper.h
index abbc878..e0aaff5 100644
--- a/chrome/browser/web_applications/isolated_web_apps/isolated_web_app_install_command_helper.h
+++ b/chrome/browser/web_applications/isolated_web_apps/isolated_web_app_install_command_helper.h
@@ -15,10 +15,12 @@
 #include "base/functional/callback_forward.h"
 #include "base/memory/weak_ptr.h"
 #include "base/types/expected.h"
+#include "base/types/optional_ref.h"
 #include "base/version.h"
 #include "chrome/browser/web_applications/isolated_web_apps/isolated_web_app_url_info.h"
 #include "chrome/browser/web_applications/web_app_install_info.h"
 #include "chrome/browser/web_applications/web_app_install_utils.h"
+#include "components/web_package/signed_web_bundles/signed_web_bundle_id.h"
 #include "components/web_package/signed_web_bundles/signed_web_bundle_integrity_block.h"
 #include "components/webapps/browser/installable/installable_logging.h"
 #include "third_party/blink/public/mojom/manifest/manifest.mojom-forward.h"
@@ -68,6 +70,38 @@
 GetIsolatedWebAppById(const WebAppRegistrar& registrar,
                       const webapps::AppId& iwa_id);
 
+enum class KeyRotationLookupResult { kNoKeyRotation, kKeyFound, kKeyBlocked };
+
+// Queries the `IwaKeyDistributionInfoProvider` whether there's
+// `KeyRotationInfo` associated with the given `web_bundle_id`.
+//   * If there's no key found, returns `kNoKeyRotation`.
+//   * If the rotated key is null, reflects this in `debug_log` and returns
+//     `kKeyBlocked`.
+//   * Otherwise, writes the key data into `debug_log` and returns `kKeyFound.`
+KeyRotationLookupResult LookupRotatedKey(
+    const web_package::SignedWebBundleId& web_bundle_id,
+    base::optional_ref<base::Value::Dict> debug_log = std::nullopt);
+
+// Provides the key rotation data associated with a particular IWA.
+struct KeyRotationData {
+  const raw_ref<const std::vector<uint8_t>> rotated_key;
+
+  // Tells whether the current app installation contains the rotated key
+  // (`iwa.isolation_data.integrity_block_data`).
+  bool current_installation_has_rk;
+
+  // Tells whether the pending update (if any) for the app contains the rotated
+  // key (`iwa.isolation_data.pending_update_info.integrity_block_data`).
+  bool pending_update_has_rk;
+};
+
+// Computes the key rotation data as outlined above.
+// This function must only be called if the result of invoking
+// `LookupRotatedKey()` has yielded `kKeyFound` (will CHECK otherwise).
+KeyRotationData GetKeyRotationData(
+    const web_package::SignedWebBundleId& web_bundle_id,
+    const WebApp::IsolationData& isolation_data);
+
 // This is a helper class that contains methods which are shared between both
 // install and update commands.
 class IsolatedWebAppInstallCommandHelper {
diff --git a/chrome/browser/web_applications/isolated_web_apps/isolated_web_app_install_prepare_apply_update_browsertest.cc b/chrome/browser/web_applications/isolated_web_apps/isolated_web_app_install_prepare_apply_update_browsertest.cc
index ba5927d..e990938 100644
--- a/chrome/browser/web_applications/isolated_web_apps/isolated_web_app_install_prepare_apply_update_browsertest.cc
+++ b/chrome/browser/web_applications/isolated_web_apps/isolated_web_app_install_prepare_apply_update_browsertest.cc
@@ -9,8 +9,10 @@
 #include "base/files/file_util.h"
 #include "base/files/scoped_temp_dir.h"
 #include "base/test/gmock_expected_support.h"
+#include "base/test/scoped_feature_list.h"
 #include "base/test/test_future.h"
 #include "base/threading/thread_restrictions.h"
+#include "chrome/browser/component_updater/iwa_key_distribution_component_installer.h"
 #include "chrome/browser/ui/web_applications/test/isolated_web_app_test_utils.h"
 #include "chrome/browser/web_applications/isolated_web_apps/install_isolated_web_app_command.h"
 #include "chrome/browser/web_applications/isolated_web_apps/isolated_web_app_apply_update_command.h"
@@ -20,6 +22,7 @@
 #include "chrome/browser/web_applications/isolated_web_apps/isolated_web_app_url_info.h"
 #include "chrome/browser/web_applications/isolated_web_apps/test/integrity_block_data_matcher.h"
 #include "chrome/browser/web_applications/isolated_web_apps/test/isolated_web_app_builder.h"
+#include "chrome/browser/web_applications/isolated_web_apps/test/key_distribution/test_utils.h"
 #include "chrome/browser/web_applications/isolated_web_apps/test/test_signed_web_bundle_builder.h"
 #include "chrome/browser/web_applications/test/web_app_install_test_utils.h"
 #include "chrome/browser/web_applications/web_app_command_scheduler.h"
@@ -35,11 +38,13 @@
 namespace web_app {
 namespace {
 
+using base::test::ErrorIs;
 using base::test::HasValue;
 using base::test::ValueIs;
 using ::testing::_;
 using ::testing::Eq;
 using ::testing::Field;
+using ::testing::HasSubstr;
 using ::testing::IsTrue;
 
 class IsolatedWebAppInstallPrepareApplyUpdateCommandBrowserTest
@@ -120,6 +125,9 @@
   }
 
   bool is_dev_mode_ = GetParam();
+
+  base::test::ScopedFeatureList features_{
+      component_updater::kIwaKeyDistributionComponent};
 };
 
 IN_PROC_BROWSER_TEST_P(
@@ -198,6 +206,96 @@
                           test::GetDefaultEcdsaP256KeyPair().public_key))));
 }
 
+IN_PROC_BROWSER_TEST_P(
+    IsolatedWebAppInstallPrepareApplyUpdateCommandBrowserTest,
+    SucceedsSameVersionWithKeyRotation) {
+  auto web_bundle_id = test::GetDefaultEd25519WebBundleId();
+  SetTrustedWebBundleIdsForTesting({web_bundle_id});
+
+  base::Version version("1.0.0");
+
+  // IWA signed by a Ed25519 key.
+  auto iwa =
+      IsolatedWebAppBuilder(ManifestBuilder()
+                                .SetName("installed app")
+                                .SetVersion(version.GetString()))
+          .BuildBundle(web_bundle_id, {test::GetDefaultEd25519KeyPair()});
+
+  // Same-version IWA signed by a Ecdsa P-256 key.
+  auto update_iwa =
+      IsolatedWebAppBuilder(ManifestBuilder()
+                                .SetName("updated app")
+                                .SetVersion(version.GetString()))
+          .BuildBundle(web_bundle_id, {test::GetDefaultEcdsaP256KeyPair()});
+
+  auto ed25519_pk = test::GetDefaultEd25519KeyPair().public_key;
+  auto ecdsa_p256_pk = test::GetDefaultEcdsaP256KeyPair().public_key;
+
+  // Step 1: Install `iwa` and validate web app data.
+  ASSERT_OK_AND_ASSIGN(auto install_result,
+                       Install(web_bundle_id, iwa->path(), version));
+
+  ASSERT_THAT(
+      GetIsolatedWebAppFor(web_bundle_id),
+      test::IwaIs(Eq("installed app"),
+                  test::IsolationDataIs(
+                      install_result.location, version,
+                      /*controlled_frame_partitions=*/_,
+                      /*pending_update_info=*/std::nullopt,
+                      /*integrity_block_data=*/
+                      test::IntegrityBlockDataPublicKeysAre(ed25519_pk))));
+
+  // Step 2: Ensure that update fails without key rotation.
+  ASSERT_THAT(
+      PrepareAndStoreUpdateInfo(web_bundle_id, update_iwa->path(), version),
+      ErrorIs(_));
+
+  // Step 3: point `web_bundle_id` (which is Ed25519-based) to the default
+  // Ecdsa P-256 public key via the Key Distribution Component. This should
+  // allow us to perform a same-version update to replace the underlying
+  // bundle signed by a corrupted key.
+  EXPECT_THAT(
+      test::InstallIwaKeyDistributionComponent(
+          base::Version("0.1.0"), web_bundle_id.id(), ecdsa_p256_pk.bytes()),
+      HasValue());
+
+  // Step 4: Prepare the update based on `update_iwa` and validate pending info.
+  ASSERT_OK_AND_ASSIGN(
+      auto prep_store_update_result,
+      PrepareAndStoreUpdateInfo(web_bundle_id, update_iwa->path(), version));
+  ASSERT_THAT(
+      prep_store_update_result,
+      Field(&IsolatedWebAppUpdatePrepareAndStoreCommandSuccess::update_version,
+            Eq(version)));
+
+  ASSERT_THAT(
+      GetIsolatedWebAppFor(web_bundle_id),
+      test::IwaIs(Eq("installed app"),
+                  test::IsolationDataIs(
+                      install_result.location, version,
+                      /*controlled_frame_partitions=*/_,
+                      /*pending_update_info=*/
+                      test::PendingUpdateInfoIs(
+                          prep_store_update_result.location, version,
+                          test::IntegrityBlockDataPublicKeysAre(ecdsa_p256_pk)),
+                      /*integrity_block_data=*/
+                      test::IntegrityBlockDataPublicKeysAre(ed25519_pk))));
+
+  // Step 5: Apply the update and ensure that pending info has been
+  // successfully transferred.
+  ASSERT_THAT(ApplyUpdate(web_bundle_id), HasValue());
+
+  ASSERT_THAT(
+      GetIsolatedWebAppFor(web_bundle_id),
+      test::IwaIs(Eq("updated app"),
+                  test::IsolationDataIs(
+                      prep_store_update_result.location, version,
+                      /*controlled_frame_partitions=*/_,
+                      /*pending_update_info=*/std::nullopt,
+                      /*integrity_block_data=*/
+                      test::IntegrityBlockDataPublicKeysAre(ecdsa_p256_pk))));
+}
+
 INSTANTIATE_TEST_SUITE_P(
     /* no prefix */,
     IsolatedWebAppInstallPrepareApplyUpdateCommandBrowserTest,
diff --git a/chrome/browser/web_applications/isolated_web_apps/isolated_web_app_integrity_block_data.cc b/chrome/browser/web_applications/isolated_web_apps/isolated_web_app_integrity_block_data.cc
index d31dd22..172b91c 100644
--- a/chrome/browser/web_applications/isolated_web_apps/isolated_web_app_integrity_block_data.cc
+++ b/chrome/browser/web_applications/isolated_web_apps/isolated_web_app_integrity_block_data.cc
@@ -193,4 +193,20 @@
       })));
 }
 
+bool IsolatedWebAppIntegrityBlockData::HasPublicKey(
+    base::span<const uint8_t> public_key) const {
+  return base::ranges::any_of(signatures(), [&](const auto& signature_info) {
+    return absl::visit(
+        base::Overloaded{
+            [&](const auto& signature_info) {
+              return base::ranges::equal(signature_info.public_key().bytes(),
+                                         public_key);
+            },
+            [](const web_package::SignedWebBundleSignatureInfoUnknown&) {
+              return false;
+            }},
+        signature_info);
+  });
+}
+
 }  // namespace web_app
diff --git a/chrome/browser/web_applications/isolated_web_apps/isolated_web_app_integrity_block_data.h b/chrome/browser/web_applications/isolated_web_apps/isolated_web_app_integrity_block_data.h
index d46e357..10a1debe9 100644
--- a/chrome/browser/web_applications/isolated_web_apps/isolated_web_app_integrity_block_data.h
+++ b/chrome/browser/web_applications/isolated_web_apps/isolated_web_app_integrity_block_data.h
@@ -38,6 +38,8 @@
     return signatures_;
   }
 
+  bool HasPublicKey(base::span<const uint8_t> public_key) const;
+
   base::Value AsDebugValue() const;
 
  private:
diff --git a/chrome/browser/web_applications/isolated_web_apps/isolated_web_app_prepare_and_store_update_command.cc b/chrome/browser/web_applications/isolated_web_apps/isolated_web_app_prepare_and_store_update_command.cc
index 5a3da67a..f5dbbc2 100644
--- a/chrome/browser/web_applications/isolated_web_apps/isolated_web_app_prepare_and_store_update_command.cc
+++ b/chrome/browser/web_applications/isolated_web_apps/isolated_web_app_prepare_and_store_update_command.cc
@@ -29,7 +29,6 @@
 #include "chrome/browser/web_applications/commands/web_app_command.h"
 #include "chrome/browser/web_applications/isolated_web_apps/isolated_web_app_features.h"
 #include "chrome/browser/web_applications/isolated_web_apps/isolated_web_app_install_command_helper.h"
-#include "chrome/browser/web_applications/isolated_web_apps/isolated_web_app_integrity_block_data.h"
 #include "chrome/browser/web_applications/isolated_web_apps/pending_install_info.h"
 #include "chrome/browser/web_applications/locks/app_lock.h"
 #include "chrome/browser/web_applications/web_app.h"
@@ -169,12 +168,31 @@
       GetIsolatedWebAppById(lock_->registrar(), url_info_.app_id()),
       [&](const std::string& error) { ReportFailure(error); });
   const auto& isolation_data = *iwa.isolation_data();
-
   installed_version_ = isolation_data.version;
   GetMutableDebugValue().Set("installed_version",
                              installed_version_->GetString());
-  if (expected_version_.has_value() &&
-      *expected_version_ <= *installed_version_) {
+
+  switch (LookupRotatedKey(url_info_.web_bundle_id(), GetMutableDebugValue())) {
+    case KeyRotationLookupResult::kNoKeyRotation:
+      break;
+    case KeyRotationLookupResult::kKeyFound: {
+      KeyRotationData data =
+          GetKeyRotationData(url_info_.web_bundle_id(), isolation_data);
+      rotated_key_ = *data.rotated_key;
+      if (!data.current_installation_has_rk) {
+        same_version_update_allowed_by_key_rotation_ = true;
+      }
+    } break;
+    case KeyRotationLookupResult::kKeyBlocked:
+      ReportFailure(
+          "The web bundle id for this app's bundle has been blocked by the key "
+          "distribution component.");
+      return;
+  }
+
+  if (expected_version_ && (*expected_version_ < *installed_version_ ||
+                            (*expected_version_ == *installed_version_ &&
+                             !same_version_update_allowed_by_key_rotation_))) {
     ReportFailure(base::StrCat({"Installed app is already on version ",
                                 installed_version_->GetString(),
                                 ". Cannot update to version ",
@@ -245,6 +263,13 @@
   if (integrity_block) {
     integrity_block_data_ =
         IsolatedWebAppIntegrityBlockData::FromIntegrityBlock(*integrity_block);
+    if (rotated_key_ && !integrity_block_data_->HasPublicKey(*rotated_key_)) {
+      ReportFailure(
+          "The update's integrity block data doesn't contain the required "
+          "public key as instructed by the key distribution component -- the "
+          "update won't succeed.");
+      return;
+    }
   }
 
   // TODO(cmfcmf): Maybe we should log somewhere when the storage partition is
@@ -300,7 +325,9 @@
     CHECK_EQ(*expected_version_, install_info.isolated_web_app_version);
   }
 
-  if (install_info.isolated_web_app_version <= *installed_version_) {
+  if (install_info.isolated_web_app_version < *installed_version_ ||
+      (install_info.isolated_web_app_version == *installed_version_ &&
+       !same_version_update_allowed_by_key_rotation_)) {
     ReportFailure(base::StrCat(
         {"Installed app is already on version ",
          installed_version_->GetString(), ". Cannot update to version ",
diff --git a/chrome/browser/web_applications/isolated_web_apps/isolated_web_app_prepare_and_store_update_command.h b/chrome/browser/web_applications/isolated_web_apps/isolated_web_app_prepare_and_store_update_command.h
index 26a7479..f23c97ce 100644
--- a/chrome/browser/web_applications/isolated_web_apps/isolated_web_app_prepare_and_store_update_command.h
+++ b/chrome/browser/web_applications/isolated_web_apps/isolated_web_app_prepare_and_store_update_command.h
@@ -208,6 +208,10 @@
   // The inferred integrity block data of the update bundle being processed.
   std::optional<IsolatedWebAppIntegrityBlockData> integrity_block_data_;
 
+  bool same_version_update_allowed_by_key_rotation_ = false;
+  // Key Rotation data for this IWA.
+  std::optional<std::vector<uint8_t>> rotated_key_;
+
   std::optional<IwaSourceWithModeAndFileOp> update_source_;
   std::optional<IwaSourceWithMode> destination_location_;
   std::optional<IsolatedWebAppStorageLocation> destination_storage_location_;
diff --git a/chrome/browser/web_applications/isolated_web_apps/isolated_web_app_update_discovery_task.cc b/chrome/browser/web_applications/isolated_web_apps/isolated_web_app_update_discovery_task.cc
index 4ba72ff..2518d43 100644
--- a/chrome/browser/web_applications/isolated_web_apps/isolated_web_app_update_discovery_task.cc
+++ b/chrome/browser/web_applications/isolated_web_apps/isolated_web_app_update_discovery_task.cc
@@ -21,6 +21,7 @@
 #include "base/types/expected_macros.h"
 #include "base/version.h"
 #include "chrome/browser/web_applications/isolated_web_apps/isolated_web_app_downloader.h"
+#include "chrome/browser/web_applications/isolated_web_apps/isolated_web_app_install_command_helper.h"
 #include "chrome/browser/web_applications/isolated_web_apps/isolated_web_app_prepare_and_store_update_command.h"
 #include "chrome/browser/web_applications/isolated_web_apps/isolated_web_app_url_info.h"
 #include "chrome/browser/web_applications/isolated_web_apps/update_manifest/update_manifest.h"
@@ -180,27 +181,52 @@
       [&](const std::string&) { FailWith(Error::kIwaNotInstalled); });
   const auto& isolation_data = *iwa.isolation_data();
   base::Version currently_installed_version = isolation_data.version;
-
   debug_log_.Set("currently_installed_version",
                  currently_installed_version.GetString());
 
-  if (isolation_data.pending_update_info() &&
-      isolation_data.pending_update_info()->version ==
-          latest_version_entry->version()) {
-    // If we already have a pending update for this version, stop. However, we
-    // do allow overwriting a pending update with a different pending update
-    // version.
+  const auto& pending_update = isolation_data.pending_update_info();
+
+  bool same_version_update_allowed_by_key_rotation = false;
+  bool pending_info_overwrite_allowed_by_key_rotation = false;
+  switch (LookupRotatedKey(url_info_.web_bundle_id(), debug_log_)) {
+    case KeyRotationLookupResult::kNoKeyRotation:
+      break;
+    case KeyRotationLookupResult::kKeyFound: {
+      KeyRotationData data =
+          GetKeyRotationData(url_info_.web_bundle_id(), isolation_data);
+      if (!data.current_installation_has_rk) {
+        same_version_update_allowed_by_key_rotation = true;
+      }
+      if (!data.pending_update_has_rk) {
+        pending_info_overwrite_allowed_by_key_rotation = true;
+      }
+    } break;
+    case KeyRotationLookupResult::kKeyBlocked: {
+      FailWith(Error::kUpdateManifestNoApplicableVersion);
+      return;
+    }
+  }
+
+  if (pending_update &&
+      pending_update->version == latest_version_entry->version() &&
+      !pending_info_overwrite_allowed_by_key_rotation) {
+    // If we already have a pending update for this version, stop. However,
+    // we do allow overwriting a pending update with a different pending
+    // update version or if there's a chance that this will yield a bundle
+    // signed by a rotated key.
     SucceedWith(Success::kUpdateAlreadyPending);
     return;
   }
 
   // Since this task is not holding any `WebAppLock`s, there is no guarantee
-  // that the installed version of the IWA won't change in the time between now
-  // and when we schedule the `IsolatedWebAppUpdatePrepareAndStoreCommand`. This
-  // is not an issue, as `IsolatedWebAppUpdatePrepareAndStoreCommand` will
-  // re-check that the new version is indeed newer than the currently installed
-  // version.
-  if (currently_installed_version >= latest_version_entry->version()) {
+  // that the installed version of the IWA won't change in the time between
+  // now and when we schedule the
+  // `IsolatedWebAppUpdatePrepareAndStoreCommand`. This is not an issue, as
+  // `IsolatedWebAppUpdatePrepareAndStoreCommand` will re-check that the new
+  // version is indeed newer than the currently installed version.
+  if (currently_installed_version > latest_version_entry->version() ||
+      (currently_installed_version == latest_version_entry->version() &&
+       !same_version_update_allowed_by_key_rotation)) {
     // Never downgrade apps for now.
     SucceedWith(Success::kNoUpdateFound);
     return;
diff --git a/chrome/browser/web_applications/isolated_web_apps/isolated_web_app_update_manager.cc b/chrome/browser/web_applications/isolated_web_apps/isolated_web_app_update_manager.cc
index 3068782d..33532f1 100644
--- a/chrome/browser/web_applications/isolated_web_apps/isolated_web_app_update_manager.cc
+++ b/chrome/browser/web_applications/isolated_web_apps/isolated_web_app_update_manager.cc
@@ -134,7 +134,7 @@
           // Similar to extensions, we don't do any automatic updates in guest
           // sessions.
           !profile.IsGuestSession() &&
-          // Web Apps are not a thing in off the record profiles, but have this
+          // Web Apps are not a thing in off the record profiles, but have
           // here just in case - we also wouldn't want to automatically update
           // IWAs in incognito windows.
           !profile.IsOffTheRecord() &&
@@ -163,6 +163,8 @@
 
   has_started_ = true;
   install_manager_observation_.Observe(&provider_->install_manager());
+  key_distribution_info_observation_.Observe(
+      IwaKeyDistributionInfoProvider::GetInstance());
 
   if (!IsAnyIwaInstalled()) {
     // If no IWA is installed, then we do not need to regularly check for
@@ -353,6 +355,45 @@
                      std::move(callback)));
 }
 
+void IsolatedWebAppUpdateManager::OnComponentUpdateSuccess(
+    const base::Version& component_version) {
+  // The corresponding observer is added during `Start()`.
+  CHECK(has_started_);
+
+  if (!automatic_updates_enabled_) {
+    return;
+  }
+
+  // Queue updates for all apps affected by key rotation.
+  for (const WebApp& app : provider_->registrar_unsafe().GetApps()) {
+    if (!app.isolation_data()) {
+      continue;
+    }
+    const auto& isolation_data = *app.isolation_data();
+
+    auto url_info = IsolatedWebAppUrlInfo::Create(app.manifest_id());
+    if (!url_info.has_value()) {
+      continue;
+    }
+
+    auto result = LookupRotatedKey(url_info->web_bundle_id());
+    // If the rotated key is null, there's no point in updating the
+    // app (as the update won't succeed anyway).
+    if (result != KeyRotationLookupResult::kKeyFound) {
+      continue;
+    }
+    KeyRotationData data =
+        GetKeyRotationData(url_info->web_bundle_id(), isolation_data);
+    // If either the bundle or the pending update already includes the rotated
+    // key, there's no need to rush with updates.
+    if (data.current_installation_has_rk || data.pending_update_has_rk) {
+      continue;
+    }
+
+    MaybeDiscoverUpdatesForApp(app.app_id());
+  }
+}
+
 bool IsolatedWebAppUpdateManager::IsAnyIwaInstalled() {
   for (const WebApp& app : provider_->registrar_unsafe().GetApps()) {
     if (app.isolation_data().has_value()) {
diff --git a/chrome/browser/web_applications/isolated_web_apps/isolated_web_app_update_manager.h b/chrome/browser/web_applications/isolated_web_apps/isolated_web_app_update_manager.h
index df13ca3a..68dabe3 100644
--- a/chrome/browser/web_applications/isolated_web_apps/isolated_web_app_update_manager.h
+++ b/chrome/browser/web_applications/isolated_web_apps/isolated_web_app_update_manager.h
@@ -28,6 +28,7 @@
 #include "chrome/browser/web_applications/isolated_web_apps/isolated_web_app_update_apply_task.h"
 #include "chrome/browser/web_applications/isolated_web_apps/isolated_web_app_update_apply_waiter.h"
 #include "chrome/browser/web_applications/isolated_web_apps/isolated_web_app_update_discovery_task.h"
+#include "chrome/browser/web_applications/isolated_web_apps/key_distribution/iwa_key_distribution_info_provider.h"
 #include "chrome/browser/web_applications/web_app_install_manager_observer.h"
 #include "components/webapps/common/web_app_id.h"
 
@@ -78,7 +79,9 @@
 //
 // TODO(crbug.com/40274187): Consider only executing update discovery tasks when
 // the user is not on a metered/paid internet connection.
-class IsolatedWebAppUpdateManager : public WebAppInstallManagerObserver {
+class IsolatedWebAppUpdateManager
+    : public WebAppInstallManagerObserver,
+      public IwaKeyDistributionInfoProvider::Observer {
  public:
   explicit IsolatedWebAppUpdateManager(
       Profile& profile,
@@ -244,6 +247,10 @@
     base::Value::List update_apply_results_log_;
   };
 
+  // IwaKeyDistributionInfoProvider::Observer:
+  void OnComponentUpdateSuccess(
+      const base::Version& component_version) override;
+
   bool IsAnyIwaInstalled();
 
   // Queues new update discovery tasks and returns the number of new tasks that
@@ -345,6 +352,10 @@
   base::ScopedObservation<WebAppInstallManager, WebAppInstallManagerObserver>
       install_manager_observation_{this};
 
+  base::ScopedObservation<IwaKeyDistributionInfoProvider,
+                          IwaKeyDistributionInfoProvider::Observer>
+      key_distribution_info_observation_{this};
+
   class LocalDevModeUpdateDiscoverer;
   std::unique_ptr<LocalDevModeUpdateDiscoverer>
       local_dev_mode_update_discoverer_;
diff --git a/chrome/browser/web_applications/isolated_web_apps/isolated_web_app_update_manager_browsertest.cc b/chrome/browser/web_applications/isolated_web_apps/isolated_web_app_update_manager_browsertest.cc
index e3652242..58a64071 100644
--- a/chrome/browser/web_applications/isolated_web_apps/isolated_web_app_update_manager_browsertest.cc
+++ b/chrome/browser/web_applications/isolated_web_apps/isolated_web_app_update_manager_browsertest.cc
@@ -7,6 +7,7 @@
 #include <optional>
 #include <string_view>
 
+#include "base/base64.h"
 #include "base/files/file_util.h"
 #include "base/files/scoped_temp_dir.h"
 #include "base/scoped_observation.h"
@@ -20,6 +21,7 @@
 #include "base/test/test_future.h"
 #include "base/test/test_timeouts.h"
 #include "base/types/expected.h"
+#include "chrome/browser/component_updater/iwa_key_distribution_component_installer.h"
 #include "chrome/browser/prefs/session_startup_pref.h"
 #include "chrome/browser/ui/browser.h"
 #include "chrome/browser/ui/browser_window.h"
@@ -31,7 +33,9 @@
 #include "chrome/browser/web_applications/isolated_web_apps/isolated_web_app_update_server_mixin.h"
 #include "chrome/browser/web_applications/isolated_web_apps/isolated_web_app_url_info.h"
 #include "chrome/browser/web_applications/isolated_web_apps/policy/isolated_web_app_policy_constants.h"
+#include "chrome/browser/web_applications/isolated_web_apps/test/integrity_block_data_matcher.h"
 #include "chrome/browser/web_applications/isolated_web_apps/test/isolated_web_app_builder.h"
+#include "chrome/browser/web_applications/isolated_web_apps/test/key_distribution/test_utils.h"
 #include "chrome/browser/web_applications/isolated_web_apps/test/test_signed_web_bundle_builder.h"
 #include "chrome/browser/web_applications/test/web_app_icon_test_utils.h"
 #include "chrome/browser/web_applications/test/web_app_test_observers.h"
@@ -164,9 +168,6 @@
   web_package::SignedWebBundleId GetWebBundleId() const {
     return test::GetDefaultEd25519WebBundleId();
   }
-  GURL GetUpdateManifestUrl() const {
-    return update_server_mixin_.GetUpdateManifestUrl(GetWebBundleId());
-  }
 
   const WebApp* GetIsolatedWebApp(const webapps::AppId& app_id) {
     return provider().registrar_unsafe().GetAppById(app_id);
@@ -198,10 +199,8 @@
   profile()->GetPrefs()->SetList(
       prefs::kIsolatedWebAppInstallForceList,
       base::Value::List().Append(
-          base::Value::Dict()
-              .Set(kPolicyWebBundleIdKey, GetWebBundleId().id())
-              .Set(kPolicyUpdateManifestUrlKey,
-                   GetUpdateManifestUrl().spec())));
+          update_server_mixin_.CreateForceInstallPolicyEntry(
+              GetWebBundleId())));
 
   web_app::WebAppTestInstallObserver(browser()->profile())
       .BeginListeningAndWait({GetAppId()});
@@ -238,10 +237,8 @@
   profile()->GetPrefs()->SetList(
       prefs::kIsolatedWebAppInstallForceList,
       base::Value::List().Append(
-          base::Value::Dict()
-              .Set(kPolicyWebBundleIdKey, GetWebBundleId().id())
-              .Set(kPolicyUpdateManifestUrlKey,
-                   GetUpdateManifestUrl().spec())));
+          update_server_mixin_.CreateForceInstallPolicyEntry(
+              GetWebBundleId())));
 
   web_app::WebAppTestInstallObserver(browser()->profile())
       .BeginListeningAndWait({GetAppId()});
@@ -297,10 +294,8 @@
   profile()->GetPrefs()->SetList(
       prefs::kIsolatedWebAppInstallForceList,
       base::Value::List().Append(
-          base::Value::Dict()
-              .Set(kPolicyWebBundleIdKey, GetWebBundleId().id())
-              .Set(kPolicyUpdateManifestUrlKey,
-                   GetUpdateManifestUrl().spec())));
+          update_server_mixin_.CreateForceInstallPolicyEntry(
+              GetWebBundleId())));
 
   SessionStartupPref pref(SessionStartupPref::LAST);
   SessionStartupPref::SetStartupPref(profile(), pref);
@@ -358,5 +353,91 @@
 }
 #endif  // !BUILDFLAG(IS_CHROMEOS_LACROS)
 
+class IsolatedWebAppUpdateManagerWithKeyRotationBrowserTest
+    : public IsolatedWebAppBrowserTestHarness {
+ public:
+  IsolatedWebAppUpdateManagerWithKeyRotationBrowserTest() {
+    scoped_feature_list_.InitWithFeatures(
+        {features::kIsolatedWebAppAutomaticUpdates,
+         component_updater::kIwaKeyDistributionComponent},
+        {});
+  }
+
+  const WebApp* GetIsolatedWebApp(const webapps::AppId& app_id) {
+    return provider().registrar_unsafe().GetAppById(app_id);
+  }
+
+ protected:
+  void AddBundleSignedBy(
+      const web_package::WebBundleSigner::KeyPair& key_pair) {
+    update_server_mixin_.AddBundle(
+        IsolatedWebAppBuilder(
+            ManifestBuilder().SetName("app-1.0.0").SetVersion("1.0.0"))
+            .BuildBundle(web_bundle_id_, {key_pair}));
+  }
+
+  IsolatedWebAppUpdateServerMixin update_server_mixin_{&mixin_host_};
+  base::test::ScopedFeatureList scoped_feature_list_;
+
+  web_package::SignedWebBundleId web_bundle_id_ =
+      test::GetDefaultEd25519WebBundleId();
+};
+
+IN_PROC_BROWSER_TEST_F(IsolatedWebAppUpdateManagerWithKeyRotationBrowserTest,
+                       Succeeds) {
+  auto app_id =
+      IsolatedWebAppUrlInfo::CreateFromSignedWebBundleId(web_bundle_id_)
+          .app_id();
+
+  // Add a bundle with version 1.0.0 signed by the original key corresponding to
+  // `web_bundle_id_`.
+  AddBundleSignedBy(test::GetDefaultEd25519KeyPair());
+
+  profile()->GetPrefs()->SetList(
+      prefs::kIsolatedWebAppInstallForceList,
+      base::Value::List().Append(
+          update_server_mixin_.CreateForceInstallPolicyEntry(web_bundle_id_)));
+
+  web_app::WebAppTestInstallObserver(browser()->profile())
+      .BeginListeningAndWait({app_id});
+
+  EXPECT_THAT(
+      GetIsolatedWebApp(app_id),
+      test::IwaIs(Eq("app-1.0.0"),
+                  test::IsolationDataIs(
+                      /*location=*/_, Eq(base::Version("1.0.0")),
+                      /*controlled_frame_partitions=*/_,
+                      /*pending_update_info=*/Eq(std::nullopt),
+                      /*integrity_block_data=*/
+                      test::IntegrityBlockDataPublicKeysAre(
+                          test::GetDefaultEd25519KeyPair().public_key))));
+
+  // Add a bundle with version 1.0.0 signed by a rotated key.
+  AddBundleSignedBy(test::GetDefaultEcdsaP256KeyPair());
+
+  WebAppTestManifestUpdatedObserver manifest_updated_observer(
+      &provider().install_manager());
+  manifest_updated_observer.BeginListening({app_id});
+  // Key rotation should trigger a discovery in the update manager.
+  EXPECT_THAT(
+      test::InstallIwaKeyDistributionComponent(
+          base::Version("0.1.0"), test::GetDefaultEd25519WebBundleId().id(),
+          test::GetDefaultEcdsaP256KeyPair().public_key.bytes()),
+      HasValue());
+  manifest_updated_observer.Wait();
+
+  // The app's integrity block data must be different now due to an update.
+  EXPECT_THAT(
+      GetIsolatedWebApp(app_id),
+      test::IwaIs(Eq("app-1.0.0"),
+                  test::IsolationDataIs(
+                      /*location=*/_, Eq(base::Version("1.0.0")),
+                      /*controlled_frame_partitions=*/_,
+                      /*pending_update_info=*/Eq(std::nullopt),
+                      /*integrity_block_data=*/
+                      test::IntegrityBlockDataPublicKeysAre(
+                          test::GetDefaultEcdsaP256KeyPair().public_key))));
+}
+
 }  // namespace
 }  // namespace web_app
diff --git a/chrome/browser/web_applications/isolated_web_apps/isolated_web_app_update_server_mixin.cc b/chrome/browser/web_applications/isolated_web_apps/isolated_web_app_update_server_mixin.cc
index 41cd670..2b80552 100644
--- a/chrome/browser/web_applications/isolated_web_apps/isolated_web_app_update_server_mixin.cc
+++ b/chrome/browser/web_applications/isolated_web_apps/isolated_web_app_update_server_mixin.cc
@@ -11,6 +11,7 @@
 #include "base/strings/string_split.h"
 #include "base/types/expected_macros.h"
 #include "chrome/browser/web_applications/isolated_web_apps/isolated_web_app_url_info.h"
+#include "chrome/browser/web_applications/isolated_web_apps/policy/isolated_web_app_policy_constants.h"
 #include "chrome/browser/web_applications/isolated_web_apps/test/isolated_web_app_builder.h"
 #include "net/http/http_status_code.h"
 
@@ -45,6 +46,15 @@
       base::StrCat({"/", web_bundle_id.id(), "/", kUpdateManifestFileName}));
 }
 
+base::Value::Dict
+IsolatedWebAppUpdateServerMixin::CreateForceInstallPolicyEntry(
+    const web_package::SignedWebBundleId& web_bundle_id) const {
+  return base::Value::Dict()
+      .Set(kPolicyWebBundleIdKey, web_bundle_id.id())
+      .Set(kPolicyUpdateManifestUrlKey,
+           GetUpdateManifestUrl(web_bundle_id).spec());
+}
+
 void IsolatedWebAppUpdateServerMixin::AddBundle(
     std::unique_ptr<BundledIsolatedWebApp> bundle) {
   auto* bundle_ptr = bundle.get();
diff --git a/chrome/browser/web_applications/isolated_web_apps/isolated_web_app_update_server_mixin.h b/chrome/browser/web_applications/isolated_web_apps/isolated_web_app_update_server_mixin.h
index 39a412f0..2759762 100644
--- a/chrome/browser/web_applications/isolated_web_apps/isolated_web_app_update_server_mixin.h
+++ b/chrome/browser/web_applications/isolated_web_apps/isolated_web_app_update_server_mixin.h
@@ -36,6 +36,11 @@
   GURL GetUpdateManifestUrl(
       const web_package::SignedWebBundleId& web_bundle_id) const;
 
+  // Generates a policy entry that can be appended to
+  // `prefs::kIsolatedWebAppInstallForceList` in order to force-install the IWA.
+  base::Value::Dict CreateForceInstallPolicyEntry(
+      const web_package::SignedWebBundleId& web_bundle_id) const;
+
   // Adds a bundle to the update server and starts tracking it in the
   // corresponding update manifest.
   void AddBundle(std::unique_ptr<BundledIsolatedWebApp> bundle);
diff --git a/chrome/browser/web_applications/isolated_web_apps/test/isolated_web_app_builder.h b/chrome/browser/web_applications/isolated_web_apps/test/isolated_web_app_builder.h
index c0e875b..41419e4 100644
--- a/chrome/browser/web_applications/isolated_web_apps/test/isolated_web_app_builder.h
+++ b/chrome/browser/web_applications/isolated_web_apps/test/isolated_web_app_builder.h
@@ -133,7 +133,7 @@
                         const base::FilePath path,
                         ManifestBuilder manifest_builder);
 
-  ~BundledIsolatedWebApp();
+  virtual ~BundledIsolatedWebApp();
 
   const base::FilePath& path() const { return path_; }
 
@@ -168,7 +168,7 @@
       const std::vector<uint8_t> serialized_bundle,
       ManifestBuilder manifest_builder);
 
-  ~ScopedBundledIsolatedWebApp();
+  ~ScopedBundledIsolatedWebApp() override;
 
  private:
   ScopedBundledIsolatedWebApp(
diff --git a/chrome/browser/web_applications/isolated_web_apps/test/key_distribution/test_utils.cc b/chrome/browser/web_applications/isolated_web_apps/test/key_distribution/test_utils.cc
index a86a5ec0..aacf648af 100644
--- a/chrome/browser/web_applications/isolated_web_apps/test/key_distribution/test_utils.cc
+++ b/chrome/browser/web_applications/isolated_web_apps/test/key_distribution/test_utils.cc
@@ -76,11 +76,11 @@
 
 base::expected<void, IwaKeyDistributionInfoProvider::ComponentUpdateError>
 UpdateKeyDistributionInfo(const base::Version& version,
-                          const IwaKeyDistribution& kr_proto) {
+                          const IwaKeyDistribution& kd_proto) {
   base::ScopedTempDir component_install_dir;
   CHECK(component_install_dir.CreateUniqueTempDir());
   auto path = component_install_dir.GetPath().AppendASCII("krc");
-  CHECK(base::WriteFile(path, kr_proto.SerializeAsString()));
+  CHECK(base::WriteFile(path, kd_proto.SerializeAsString()));
   return UpdateKeyDistributionInfo(version, path);
 }
 
@@ -103,7 +103,12 @@
 
 base::expected<void, IwaKeyDistributionInfoProvider::ComponentUpdateError>
 InstallIwaKeyDistributionComponent(const base::Version& version,
-                                   const IwaKeyDistribution& kr_proto) {
+                                   const IwaKeyDistribution& kd_proto) {
+  CHECK(base::FeatureList::IsEnabled(
+      component_updater::kIwaKeyDistributionComponent))
+      << "The `IwaKeyDistribution` feature must be enabled for the component "
+         "installation to succeed.";
+
   using Installer =
       component_updater::IwaKeyDistributionComponentInstallerPolicy;
   base::ScopedAllowBlockingForTesting allow_blocking;
@@ -125,7 +130,7 @@
 
   CHECK(base::CreateDirectory(install_dir));
   CHECK(base::WriteFile(install_dir.Append(Installer::kDataFileName),
-                        kr_proto.SerializeAsString()));
+                        kd_proto.SerializeAsString()));
 
   // Write a manifest file. This is needed for component updater to detect any
   // existing component on disk.
@@ -146,4 +151,24 @@
   return result;
 }
 
+base::expected<void, IwaKeyDistributionInfoProvider::ComponentUpdateError>
+InstallIwaKeyDistributionComponent(
+    const base::Version& version,
+    const std::string& web_bundle_id,
+    std::optional<base::span<const uint8_t>> expected_key) {
+  IwaKeyRotations::KeyRotationInfo kr_info;
+  if (expected_key) {
+    kr_info.set_expected_key(base::Base64Encode(*expected_key));
+  }
+
+  IwaKeyRotations key_rotations;
+  key_rotations.mutable_key_rotations()->emplace(web_bundle_id,
+                                                 std::move(kr_info));
+
+  IwaKeyDistribution key_distribution;
+  *key_distribution.mutable_key_rotation_data() = std::move(key_rotations);
+
+  return InstallIwaKeyDistributionComponent(version, key_distribution);
+}
+
 }  // namespace web_app::test
diff --git a/chrome/browser/web_applications/isolated_web_apps/test/key_distribution/test_utils.h b/chrome/browser/web_applications/isolated_web_apps/test/key_distribution/test_utils.h
index 4e3f5d5c5..ff3044a 100644
--- a/chrome/browser/web_applications/isolated_web_apps/test/key_distribution/test_utils.h
+++ b/chrome/browser/web_applications/isolated_web_apps/test/key_distribution/test_utils.h
@@ -23,10 +23,10 @@
                           const base::FilePath& path);
 
 // Synchronously updates the key distribution info provider with the given
-// `kr_proto`.
+// `kd_proto`.
 base::expected<void, IwaKeyDistributionInfoProvider::ComponentUpdateError>
 UpdateKeyDistributionInfo(const base::Version& version,
-                          const IwaKeyDistribution& kr_proto);
+                          const IwaKeyDistribution& kd_proto);
 
 // Synchronously updates the key distribution info provider with a protobuf
 // that maps `web_bundle_id` to `expected_key`. If `expected_key` is a nullopt,
@@ -37,13 +37,21 @@
     const std::string& web_bundle_id,
     std::optional<base::span<const uint8_t>> expected_key);
 
-// Writes `kr_proto` into `DIR_COMPONENT_USER/IwaKeyDistribution/{version}` and
+// Writes `kd_proto` into `DIR_COMPONENT_USER/IwaKeyDistribution/{version}` and
 // triggers the registration process with the component updater. The directory
 // is deleted once IwaKeyDistributionInfoProvider has processed the update
 // (regardless of the outcome).
 base::expected<void, IwaKeyDistributionInfoProvider::ComponentUpdateError>
 InstallIwaKeyDistributionComponent(const base::Version& version,
-                                   const IwaKeyDistribution& kr_proto);
+                                   const IwaKeyDistribution& kd_proto);
+
+// A shortcut for the above function that populates only the key rotation part
+// of the proto.
+base::expected<void, IwaKeyDistributionInfoProvider::ComponentUpdateError>
+InstallIwaKeyDistributionComponent(
+    const base::Version& version,
+    const std::string& web_bundle_id,
+    std::optional<base::span<const uint8_t>> expected_key);
 
 }  // namespace web_app::test