[go: nahoru, domu]

Add a helper tool to copy web app shims to their final destination

When using ad-hoc signing for web app shims, the final app shim must be
written to disk by this helper tool. This separate helper tool exists so
that binary authorization tools, such as Santa, can transitively trust
app shims that it creates without trusting all files written by Chrome.
This allows app shims to be trusted by the binary authorization tool
despite having only ad-hoc code signatures.

Care is taken to ensure that the helper tool is only invoked by a
program signed with the same code signing identity as the Chromium
framework to ensure that the helper tool cannot be used to arbitrarily
bypass binary authorization tools.

Bug: 1465647
Change-Id: I6fc993ca3cf74add2f68ba6e837007ec1ef0e7ba
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/5080017
Reviewed-by: Daniel Murphy <dmurph@chromium.org>
Reviewed-by: Mark Mentovai <mark@chromium.org>
Commit-Queue: Mark Rowe <markrowe@chromium.org>
Reviewed-by: Marijn Kruisselbrink <mek@chromium.org>
Reviewed-by: Dirk Pranke <dpranke@google.com>
Cr-Commit-Position: refs/heads/main@{#1258161}
diff --git a/chrome/BUILD.gn b/chrome/BUILD.gn
index c135142..27949e44 100644
--- a/chrome/BUILD.gn
+++ b/chrome/BUILD.gn
@@ -892,12 +892,14 @@
     sources = [
       "$root_out_dir/app_mode_loader",
       "$root_out_dir/chrome_crashpad_handler",
+      "$root_out_dir/web_app_shortcut_copier",
     ]
 
     outputs = [ "{{bundle_contents_dir}}/Helpers/{{source_file_part}}" ]
 
     public_deps = [
       "//chrome/app_shim:app_mode_loader",
+      "//chrome/browser/web_applications:web_app_shortcut_copier",
       "//components/crash/core/app:chrome_crashpad_handler",
     ]
 
diff --git a/chrome/app/framework.exports b/chrome/app/framework.exports
index a0b8771..f444c852d 100644
--- a/chrome/app/framework.exports
+++ b/chrome/app/framework.exports
@@ -18,3 +18,4 @@
 
 _ChromeAppModeStart_v7
 _ChromeMain
+_ChromeWebAppShortcutCopierMain
diff --git a/chrome/app/framework.order b/chrome/app/framework.order
index cc9bd695..b30b91a 100644
--- a/chrome/app/framework.order
+++ b/chrome/app/framework.order
@@ -21,5 +21,8 @@
 # Entry point from the app mode loader.
 _ChromeAppModeStart_v7
 
+# Entry point for the web app shortcut copier.
+_ChromeWebAppShortcutCopierMain
+
 # _ChromeMain must be listed last.  That's the whole point of this file.
 _ChromeMain
diff --git a/chrome/browser/BUILD.gn b/chrome/browser/BUILD.gn
index fb5527ec..07861291 100644
--- a/chrome/browser/BUILD.gn
+++ b/chrome/browser/BUILD.gn
@@ -4772,6 +4772,11 @@
       ]
     }
 
+    if (is_mac) {
+      deps +=
+          [ "//chrome/browser/web_applications:web_app_shortcut_copier_lib" ]
+    }
+
     if (enable_extensions) {
       deps += [
         "//extensions/browser",
diff --git a/chrome/browser/web_applications/BUILD.gn b/chrome/browser/web_applications/BUILD.gn
index 60121d6..d3d7a2f 100644
--- a/chrome/browser/web_applications/BUILD.gn
+++ b/chrome/browser/web_applications/BUILD.gn
@@ -4,7 +4,9 @@
 
 import("//build/config/chromeos/ui_mode.gni")
 import("//build/config/sanitizers/sanitizers.gni")
+import("//build/util/branding.gni")
 import("//chrome/browser/buildflags.gni")
+import("//chrome/version.gni")
 import("//mojo/public/tools/bindings/mojom.gni")
 import("//testing/test.gni")
 
@@ -573,6 +575,42 @@
   ]
 }
 
+if (is_mac) {
+  source_set("web_app_shortcut_copier_lib") {
+    sources = [ "os_integration/web_app_shortcut_copier_mac.mm" ]
+
+    deps = [
+      "//base",
+      "//chrome/browser/apps/app_shim:app_shim",
+      "//content/public/common:main_function_params",
+    ]
+  }
+
+  executable("web_app_shortcut_copier") {
+    configs += [ "//build/config/compiler:wexit_time_destructors" ]
+
+    sources = [ "os_integration/web_app_shortcut_copier_main_mac.cc" ]
+
+    deps = [ "//chrome:chrome_framework+link_nested" ]
+
+    ldflags = []
+    if (is_component_build) {
+      ldflags += [
+        # The helper is in Chromium.app/Contents/Frameworks/Chromium Framework.framework/Versions/X/Helpers
+        # so set rpath up to the base.
+        "-Wl,-rpath,@executable_path/../../../../../../..",
+      ]
+    } else {
+      ldflags += [
+        # The helper is a standalone tool rather than a bundled app so the
+        # framework's install name does not work as-is. Rewrite it so the
+        # relative path is correct given the executable's location.
+        "-Wcrl,installnametool,-change,@executable_path/../Frameworks/${chrome_product_full_name} Framework.framework/Versions/${chrome_version_full}/${chrome_product_full_name} Framework,@executable_path/../${chrome_product_full_name} Framework",
+      ]
+    }
+  }
+}
+
 # This test_support library doesn't use extensions. It must not depend on
 # //chrome/test:test_support{_ui} as it is depended upon by those targets.
 source_set("web_applications_test_support") {
diff --git a/chrome/browser/web_applications/os_integration/web_app_shortcut_copier_mac.mm b/chrome/browser/web_applications/os_integration/web_app_shortcut_copier_mac.mm
new file mode 100644
index 0000000..8fe830d
--- /dev/null
+++ b/chrome/browser/web_applications/os_integration/web_app_shortcut_copier_mac.mm
@@ -0,0 +1,162 @@
+// Copyright 2024 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+//
+// Copies files from argv[1] to argv[2]
+
+#include <CoreFoundation/CoreFoundation.h>
+#include <Security/Security.h>
+#include <unistd.h>
+
+#include "base/apple/bundle_locations.h"
+#include "base/apple/foundation_util.h"
+#include "base/apple/scoped_cftyperef.h"
+#include "base/base_paths.h"
+#include "base/files/file_path.h"
+#include "base/files/file_util.h"
+#include "base/logging.h"
+#include "base/path_service.h"
+#include "base/strings/stringprintf.h"
+#include "base/types/expected_macros.h"
+#include "chrome/browser/apps/app_shim/code_signature_mac.h"
+
+namespace {
+
+base::apple::ScopedCFTypeRef<CFStringRef>
+BuildParentAppRequirementFromFrameworkRequirementString(
+    CFStringRef framwork_requirement) {
+  // Make sure the framework bundle requirement is in the expected format.
+  // It should start with 'identifier "' and have at least 2 quotes. This allows
+  // us to easily find the end of the "identifier" portion of the requirement so
+  // we can remove it.
+  CFIndex len = CFStringGetLength(framwork_requirement);
+  base::apple::ScopedCFTypeRef<CFArrayRef> quote_ranges(
+      CFStringCreateArrayWithFindResults(nullptr, framwork_requirement,
+                                         CFSTR("\""), CFRangeMake(0, len), 0));
+  if (!CFStringHasPrefix(framwork_requirement, CFSTR("identifier \"")) ||
+      !quote_ranges || CFArrayGetCount(quote_ranges.get()) < 2) {
+    LOG(ERROR) << "Framework bundle requirement is malformed.";
+    return base::apple::ScopedCFTypeRef<CFStringRef>(nullptr);
+  }
+
+  // Get the index of the second quote.
+  CFIndex second_quote_index =
+      static_cast<const CFRange*>(CFArrayGetValueAtIndex(quote_ranges.get(), 1))
+          ->location;
+
+  // Make sure there is something to read after the second quote.
+  if (second_quote_index + 1 >= len) {
+    LOG(ERROR) << "Framework bundle requirement is too short";
+    return base::apple::ScopedCFTypeRef<CFStringRef>(nullptr);
+  }
+
+  // Build the app shim requirement. Keep the data from the framework bundle
+  // requirement starting after second quote.
+  base::apple::ScopedCFTypeRef<CFStringRef> parent_app_requirement_string(
+      CFStringCreateWithSubstring(
+          nullptr, framwork_requirement,
+          CFRangeMake(second_quote_index + 5, len - second_quote_index - 5)));
+  return parent_app_requirement_string;
+}
+
+// Creates a requirement for the parent app based on the framework bundle's
+// designated requirement.
+//
+// Returns a non-null requirement or the reason why the requirement could not
+// be created.
+base::expected<base::apple::ScopedCFTypeRef<SecRequirementRef>,
+               apps::MissingRequirementReason>
+CreateParentAppRequirement() {
+  ASSIGN_OR_RETURN(auto framework_requirement_string,
+                   apps::FrameworkBundleDesignatedRequirementString());
+
+  base::apple::ScopedCFTypeRef<CFStringRef> parent_requirement_string =
+      BuildParentAppRequirementFromFrameworkRequirementString(
+          framework_requirement_string.get());
+  if (!parent_requirement_string) {
+    return base::unexpected(apps::MissingRequirementReason::Error);
+  }
+
+  return apps::RequirementFromString(parent_requirement_string.get());
+}
+
+// Ensure that the parent process is Chromium.
+// This prevents this tool from being used to bypass binary authorization tools
+// such as Santa.
+//
+// Returns whether the parent process's code signature is trusted:
+// - True if the framework bundle is unsigned (there's nothing to verify).
+// - True if the parent process satisfies the constructed designated requirement
+// tailored for the parent app based on the framework bundle's requirement.
+// - False otherwise.
+bool ValidateParentProcess() {
+  base::expected<base::apple::ScopedCFTypeRef<SecRequirementRef>,
+                 apps::MissingRequirementReason>
+      parent_app_requirement = CreateParentAppRequirement();
+  if (!parent_app_requirement.has_value()) {
+    switch (parent_app_requirement.error()) {
+      case apps::MissingRequirementReason::NoOrAdHocSignature:
+        // Parent validation is not required because framework bundle is not
+        // code-signed or is ad-hoc code-signed.
+        return true;
+      case apps::MissingRequirementReason::Error:
+        // Framework bundle is code-signed however we were unable to create the
+        // parent app requirement. Deny.
+        // CreateParentAppRequirement already did the
+        // base::debug::DumpWithoutCrashing, possibly on a previous call. We can
+        // return false here without any additional explanation.
+        return false;
+    }
+  }
+
+  OSStatus status = apps::ProcessIsSignedAndFulfillsRequirement(
+      getppid(), parent_app_requirement.value().get());
+  return status == errSecSuccess;
+}
+
+}  // namespace
+
+extern "C" {
+// The entry point into the shortcut copier process. This is not
+// a user API.
+__attribute__((visibility("default"))) int ChromeWebAppShortcutCopierMain(
+    int argc,
+    char** argv);
+}
+
+// Copies files from argv[1] to argv[2]
+//
+// When using ad-hoc signing for web app shims, the final app shim must be
+// written to disk by this helper tool. This separate helper tool exists so
+// that that binary authorization tools, such as Santa, can transitively trust
+// app shims that it creates without trusting all files written by Chrome. This
+// allows app shims to be trusted by the binary authorization tool despite
+// having only ad-hoc code signatures.
+int ChromeWebAppShortcutCopierMain(int argc, char** argv) {
+  if (argc != 3) {
+    return 1;
+  }
+
+  // Override the path to the framework value so that it has a sensible value.
+  // This tool lives within the Helpers subdirectory of the framework, so the
+  // versioned path is two levels upwards.
+  base::FilePath executable_path =
+      base::PathService::CheckedGet(base::FILE_EXE);
+  base::apple::SetOverrideFrameworkBundlePath(
+      executable_path.DirName().DirName());
+
+  if (!ValidateParentProcess()) {
+    return 1;
+  }
+
+  base::FilePath staging_path = base::FilePath::FromUTF8Unsafe(argv[1]);
+  base::FilePath dst_app_path = base::FilePath::FromUTF8Unsafe(argv[2]);
+
+  if (!base::CopyDirectory(staging_path, dst_app_path, true)) {
+    LOG(ERROR) << "Copying app from " << staging_path << " to " << dst_app_path
+               << " failed.";
+    return 2;
+  }
+
+  return 0;
+}
diff --git a/chrome/browser/web_applications/os_integration/web_app_shortcut_copier_main_mac.cc b/chrome/browser/web_applications/os_integration/web_app_shortcut_copier_main_mac.cc
new file mode 100644
index 0000000..e740834
--- /dev/null
+++ b/chrome/browser/web_applications/os_integration/web_app_shortcut_copier_main_mac.cc
@@ -0,0 +1,11 @@
+// Copyright 2024 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+//
+// Copies files from argv[1] to argv[2]
+
+extern "C" int ChromeWebAppShortcutCopierMain(int argc, char** argv);
+
+int main(int argc, char** argv) {
+  return ChromeWebAppShortcutCopierMain(argc, argv);
+}
diff --git a/chrome/browser/web_applications/os_integration/web_app_shortcut_mac.mm b/chrome/browser/web_applications/os_integration/web_app_shortcut_mac.mm
index 5066da2..02f91c0d 100644
--- a/chrome/browser/web_applications/os_integration/web_app_shortcut_mac.mm
+++ b/chrome/browser/web_applications/os_integration/web_app_shortcut_mac.mm
@@ -1419,6 +1419,38 @@
   return *lock;
 }
 
+bool CopyStagingBundleToDestination(base::FilePath staging_path,
+                                    base::FilePath dst_app_path) {
+  if (!app_mode::UseAdHocSigningForWebAppShims()) {
+    return base::CopyDirectory(staging_path, dst_app_path, true);
+  }
+
+  // When using ad-hoc signing for web app shims, the final app shim must be
+  // written to disk by a separate helper tool. This helper tool is used
+  // so that binary authorization tools, such as Santa, can transitively trust
+  // app shims that it creates without trusting all files written by Chrome.
+  // This allows app shims to be trusted by the binary authorization tool
+  // despite having only ad-hoc code signatures.
+
+  base::FilePath web_app_shortcut_copier_path =
+      base::apple::FrameworkBundlePath().Append("Helpers").Append(
+          "web_app_shortcut_copier");
+  base::CommandLine command_line(web_app_shortcut_copier_path);
+  command_line.AppendArgPath(staging_path);
+  command_line.AppendArgPath(dst_app_path);
+
+  // Synchronously wait for the copy to complete to match the semantics of
+  // `base::CopyDirectory`.
+  std::string command_output;
+  int exit_code;
+  if (base::GetAppOutputWithExitCode(command_line, &command_output,
+                                     &exit_code)) {
+    return !exit_code;
+  }
+
+  return false;
+}
+
 void WebAppShortcutCreator::CreateShortcutsAt(
     const std::vector<base::FilePath>& dst_app_paths,
     std::vector<base::FilePath>* updated_paths) const {
@@ -1465,7 +1497,7 @@
     base::DeletePathRecursively(dst_app_path);
 
     // Copy the bundle to |dst_app_path|.
-    if (!base::CopyDirectory(staging_path, dst_app_path, true)) {
+    if (!CopyStagingBundleToDestination(staging_path, dst_app_path)) {
       RecordCreateShortcut(CreateShortcutResult::kFailToCopyApp);
       LOG(ERROR) << "Copying app to dst dir: " << dst_parent_dir.value()
                  << " failed";
diff --git a/chrome/installer/mac/signing/parts.py b/chrome/installer/mac/signing/parts.py
index 75da7db..556b848 100644
--- a/chrome/installer/mac/signing/parts.py
+++ b/chrome/installer/mac/signing/parts.py
@@ -115,6 +115,14 @@
                 'app_mode_loader',
                 options=CodeSignOptions.FULL_HARDENED_RUNTIME_OPTIONS,
                 verify_options=verify_options),
+        'web-app-shortcut-copier':
+            CodeSignedProduct(
+                '{.framework_dir}/Helpers/web_app_shortcut_copier'.format(
+                    config),
+                '{}.web_app_shortcut_copier'.format(uncustomized_bundle_id),
+                options=CodeSignOptions.FULL_HARDENED_RUNTIME_OPTIONS,
+                sign_with_identifier=True,
+                verify_options=verify_options),
     }
 
     if config.enable_updater:
diff --git a/chrome/installer/mac/signing/parts_test.py b/chrome/installer/mac/signing/parts_test.py
index 0b64921..5403cd0 100644
--- a/chrome/installer/mac/signing/parts_test.py
+++ b/chrome/installer/mac/signing/parts_test.py
@@ -108,6 +108,12 @@
             | model.CodeSignOptions.LIBRARY_VALIDATION
             | model.CodeSignOptions.KILL
             | model.CodeSignOptions.HARDENED_RUNTIME,
+            all_parts['web-app-shortcut-copier'].options)
+        self.assertEqual(
+            model.CodeSignOptions.RESTRICT
+            | model.CodeSignOptions.LIBRARY_VALIDATION
+            | model.CodeSignOptions.KILL
+            | model.CodeSignOptions.HARDENED_RUNTIME,
             all_parts['privileged-helper'].options)