cli: add cros_sign_image.py

Add a wrapper script for sign_official_cos_build.sh to make it easier to
sign image artifacts locally. This acts as syntax sugar for developers
and doesn't expose all functionality of the underlying shell script.

BUG=b/465211305
TEST=presubmit
RELEASE_NOTE=None

cos-patch: bug
Change-Id: Ic57caabc68dcaf69c926d651867bc39cfb53154f
diff --git a/cli/cros/cros_sign_image.py b/cli/cros/cros_sign_image.py
new file mode 100644
index 0000000..9a1a8ad
--- /dev/null
+++ b/cli/cros/cros_sign_image.py
@@ -0,0 +1,128 @@
+# Copyright 2026 The ChromiumOS Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+"""Sign image artifacts
+
+Mostly just a nice wrapper for sign_official_cos_build.sh.
+This script is intended as syntax sugar for local developers, and as such
+does not expose all functionalities of the underlying shell script.
+
+Examples:
+  cros sign-image --board=<board> --ima-mode=hash test - sign a test image with IMA hash.
+  cros sign-image --board=<board> --ima-mode=sign      - sign a  dev image with IMA signature.
+"""
+
+import argparse
+import glob
+import logging
+import os
+import sys
+
+from chromite.cli import command
+from chromite.lib import build_target_lib
+from chromite.lib import commandline
+from chromite.lib import constants
+from chromite.lib import cros_build_lib
+
+
+@command.command_decorator("sign-image")
+class SignImageCommand(command.CliCommand):
+    """Sign a ChromiumOS image with dev/local keys (including IMA)."""
+
+    @classmethod
+    def AddParser(cls, parser: commandline.ArgumentParser):
+        """Build the parser."""
+        super().AddParser(parser)
+        parser.description = __doc__
+
+        parser.add_argument(
+            "-b",
+            "--board",
+            "--build-target",
+            dest="board",
+            default=cros_build_lib.GetDefaultBoard(),
+            help="The board of the image to sign.",
+        )
+        parser.add_argument(
+            "--keys-dir",
+            default="/usr/share/vboot/devkeys",
+            help="Directory containing developer/local keys (Default: %(default)s).",
+        )
+        parser.add_argument(
+            "--key-origin",
+            default="local",
+            help="Key origin: local or kms (Default: %(default)s).",
+        )
+        parser.add_argument(
+            "--type",
+            default="base",
+            help="The signing script TYPE argument (Default: %(default)s).",
+        )
+        parser.add_argument(
+            "--ima-mode",
+            choices=["disabled", "hash", "sign"],
+            default="disabled",
+            help="IMA signing mode: disabled, hash (digest-based file labeling), "
+                 "or sign (signature-based file labeling) (Default: %(default)s).",
+        )
+        parser.add_argument(
+            "--service-account",
+            default="",
+            help="Name of GCP service account to use when signing with Cloud KMS (Default: %(default)s).",
+        )
+        parser.add_argument(
+            "images",
+            nargs="*",
+            default=["dev"],
+            help="List of images to sign (e.g. test, base, dev) (Default: %(default)s).",
+        )
+
+    def Run(self):
+        # Require that the command is executed inside the chroot
+        commandline.RunInsideChroot()
+
+        if not self.options.board:
+            self.options.parser.error("--board is required")
+
+        if self.options.type == "update_payload":
+            cros_build_lib.Die("update_payload not supported - use sign_official_cos_build.sh directly.")
+        script_path = os.path.join(
+            constants.SOURCE_ROOT,
+            "src/platform/vboot_reference/scripts/image_signing/sign_official_cos_build.sh",
+        )
+
+        # NOTE: The input and output paths are currently hardcoded to the board's build
+        # directory. This is sufficient for local developer usage. However, if we ever
+        # want to support `--type update_payload`, these paths will need to be made
+        # configurable (e.g. via command line options) to allow passing a custom 32-byte
+        # update payload hash file.
+        image_dir = os.path.join(constants.DEFAULT_BUILD_ROOT, "images", self.options.board, "latest")
+
+        for image_name in self.options.images:
+            image_file = f"chromiumos_{image_name}_image.bin"
+            input_image = os.path.join(image_dir, image_file)
+            output_image = os.path.join(image_dir, f"chromiumos_{image_name}_image_signed.bin")
+
+            if not os.path.exists(input_image):
+                cros_build_lib.Die(f"Image not found at {input_image}")
+
+            # Advanced parameters of the underlying script, such as version_file are currently
+            # omitted because local developers do not typically need to sign images with
+            # custom/incremented rollback protection versions.
+            cmd = [
+                "sudo",
+                script_path,
+                self.options.type,
+                self.options.key_origin,
+                input_image,
+                self.options.keys_dir,
+                output_image,
+                self.options.service_account,
+                f"ima_{self.options.ima_mode}",
+            ]
+
+            cros_build_lib.run(cmd)
+
+            logging.info("Successfully signed %s image.", image_name)
+            logging.info("Output image: %s", output_image)