Files
BrowserOS/packages/browseros/build/modules/sign/windows.py
Nikhil c1b1e53a86 feat(ota): bundle full server resources tree in Sparkle payload (#726)
* feat(ota): bundle full server resources tree (server + third_party bins)

The OTA Sparkle payload now ships the complete resources/ tree the agent
build produced, not just browseros_server. Every third-party binary (bun,
ripgrep, podman, gvproxy, vfkit, krunkit, podman-mac-helper, win-sshproxy)
flows to OTA-updated installs so podman integration works for users on the
OTA channel, matching fresh Chromium-build installs.

Extract the per-binary sign table into build/common/server_binaries.py so
the Chromium-build sign path (modules/sign/) and OTA sign path (modules/ota/)
share a single source of truth. Adding a new third-party dep is now a
one-file edit that both paths pick up automatically; unknown executables
under resources/bin/ are a hard error at release time.

* fix(ota): address review comments on bundle signing flow

- Avoid double-zipping during notarization: add notarize_macos_zip for
  pre-built Sparkle bundles so notarytool submits the zip directly
  instead of re-wrapping it through ditto --keepParent (Apple's service
  does not descend into nested archives). Keep notarize_macos_binary for
  single-binary callers. Share credential setup + submit logic via
  internal helpers.
- Fail fast on unknown executables in sign_server_bundle_macos: collect
  the unknown-files list before any codesign call so a missing shared-
  table entry aborts in seconds, not after a full signing round.
- Drop dead get_entitlements_path helper (no callers remain after the
  bundle refactor).

* fix(ota): address PR review comments (greptile + claude)

- sign_server_bundle_macos filters to executables only (p.is_file() +
  not p.is_symlink() + os.access X_OK) before applying the unknown-file
  guard. Non-Mach-O files (configs, dylibs, etc.) under resources/bin/
  no longer cause misleading 'unknown executable' hard failures.
- sign_server_bundle_windows now hard-errors on a missing expected
  binary instead of silently skipping it. Symmetric with the macOS
  guard — an incomplete bundle must not publish.
- ServerOTAModule.execute() uses tempfile.TemporaryDirectory context
  managers for both the download and staging roots so they are cleaned
  up on every path, including failures.
- Per-platform sign/notarize/Sparkle-sign failures now raise RuntimeError
  instead of silently skipping the platform — a release pipeline can no
  longer omit a target while reporting success.
- Move import os and import shutil to the top of ota/sign_binary.py.
- Drop unused log_error import from ota/server.py.

* chore: bump server
2026-04-16 12:59:49 -07:00

277 lines
9.7 KiB
Python
Generated

#!/usr/bin/env python3
"""Windows signing module for BrowserOS"""
import subprocess
from pathlib import Path
from typing import List, Optional
from ...common.module import CommandModule, ValidationError
from ...common.context import Context
from ...common.env import EnvConfig
from ...common.server_binaries import expected_windows_binary_paths
from ...common.utils import (
log_info,
log_error,
log_success,
log_warning,
join_paths,
IS_WINDOWS,
)
class WindowsSignModule(CommandModule):
produces = ["signed_installer"]
requires = ["built_app"]
description = "Sign Windows binaries and create signed installer"
def validate(self, ctx: Context) -> None:
if not IS_WINDOWS():
raise ValidationError("Windows signing requires Windows")
build_output_dir = join_paths(ctx.chromium_src, ctx.out_dir)
if not build_output_dir.exists():
raise ValidationError(f"Build output directory not found: {build_output_dir}")
env = ctx.env
if not env.code_sign_tool_path:
raise ValidationError("CODE_SIGN_TOOL_PATH environment variable not set")
missing = []
if not env.esigner_username:
missing.append("ESIGNER_USERNAME")
if not env.esigner_password:
missing.append("ESIGNER_PASSWORD")
if not env.esigner_totp_secret:
missing.append("ESIGNER_TOTP_SECRET")
if missing:
raise ValidationError(f"Missing environment variables: {', '.join(missing)}")
def execute(self, ctx: Context) -> None:
log_info("\n🔏 Signing Windows binaries...")
build_output_dir = join_paths(ctx.chromium_src, ctx.out_dir)
self._sign_executables(build_output_dir, ctx.env)
self._build_mini_installer(ctx)
mini_installer_path = self._sign_installer(build_output_dir, ctx.env)
ctx.artifact_registry.add("signed_installer", mini_installer_path)
log_success("✅ All binaries signed successfully!")
def _sign_executables(self, build_output_dir: Path, env: EnvConfig) -> None:
log_info("\nStep 1/3: Signing executables before packaging...")
binaries_to_sign_first = [build_output_dir / "chrome.exe"]
binaries_to_sign_first.extend(get_browseros_server_binary_paths(build_output_dir))
existing_binaries = []
for binary in binaries_to_sign_first:
if binary.exists():
existing_binaries.append(binary)
log_info(f"Found binary to sign: {binary.name}")
else:
log_warning(f"Binary not found: {binary}")
if not existing_binaries:
raise RuntimeError("No binaries found to sign")
if not sign_with_codesigntool(existing_binaries, env):
raise RuntimeError("Failed to sign executables")
def _build_mini_installer(self, ctx: Context) -> None:
log_info("\nStep 2/3: Building mini_installer with signed binaries...")
if not build_mini_installer(ctx):
raise RuntimeError("Failed to build mini_installer")
def _sign_installer(self, build_output_dir: Path, env: EnvConfig) -> Path:
log_info("\nStep 3/3: Signing mini_installer.exe...")
mini_installer_path = build_output_dir / "mini_installer.exe"
if not mini_installer_path.exists():
raise RuntimeError(f"mini_installer.exe not found at: {mini_installer_path}")
if not sign_with_codesigntool([mini_installer_path], env):
raise RuntimeError("Failed to sign mini_installer.exe")
return mini_installer_path
def get_browseros_server_binary_paths(build_output_dir: Path) -> List[Path]:
"""Return absolute paths to BrowserOS Server binaries for signing."""
server_dir = build_output_dir / "BrowserOSServer" / "default" / "resources" / "bin"
return expected_windows_binary_paths(server_dir)
def build_mini_installer(ctx: Context) -> bool:
"""Build the mini_installer.exe"""
from ..compile import build_target
log_info("Building mini_installer target...")
return build_target(ctx, "mini_installer")
def sign_with_codesigntool(
binaries: List[Path],
env: Optional[EnvConfig] = None,
) -> bool:
"""Sign binaries using SSL.com CodeSignTool
Args:
binaries: List of binary paths to sign
env: Optional EnvConfig instance. If not provided, creates a new one.
"""
log_info("Using SSL.com CodeSignTool for signing...")
if env is None:
env = EnvConfig()
# Prefer CODE_SIGN_TOOL_EXE (direct path to executable), fall back to CODE_SIGN_TOOL_PATH + .bat
if env.code_sign_tool_exe:
codesigntool_path = Path(env.code_sign_tool_exe)
elif env.code_sign_tool_path:
codesigntool_path = Path(env.code_sign_tool_path) / "CodeSignTool.bat"
else:
log_error("CODE_SIGN_TOOL_EXE or CODE_SIGN_TOOL_PATH not set in .env file")
log_error("Set CODE_SIGN_TOOL_EXE=/path/to/CodeSignTool.sh (macOS/Linux)")
log_error("Or CODE_SIGN_TOOL_PATH=C:/src/CodeSignTool-v1.3.2-windows (Windows)")
return False
if not codesigntool_path.exists():
log_error(f"CodeSignTool not found at: {codesigntool_path}")
return False
if not all([env.esigner_username, env.esigner_password, env.esigner_totp_secret]):
log_error("Missing required eSigner environment variables in .env:")
log_error(" ESIGNER_USERNAME=your-email")
log_error(" ESIGNER_PASSWORD=your-password")
log_error(" ESIGNER_TOTP_SECRET=your-totp-secret")
if not env.esigner_credential_id:
log_warning(" ESIGNER_CREDENTIAL_ID is recommended but optional")
return False
all_success = True
for binary in binaries:
try:
log_info(f"Signing {binary.name}...")
temp_output_dir = binary.parent / "signed_temp"
temp_output_dir.mkdir(exist_ok=True)
cmd = [
str(codesigntool_path),
"sign",
"-username",
env.esigner_username,
"-password",
f'"{env.esigner_password}"',
]
if env.esigner_credential_id:
cmd.extend(["-credential_id", env.esigner_credential_id])
cmd.extend(
[
"-totp_secret",
env.esigner_totp_secret,
"-input_file_path",
str(binary),
"-output_dir_path",
str(temp_output_dir),
"-override",
]
)
cmd_str = " ".join(cmd)
log_info(f"Running: {cmd_str}")
result = subprocess.run(
cmd_str,
shell=True,
capture_output=True,
text=True,
cwd=str(codesigntool_path.parent),
)
if result.stdout:
for line in result.stdout.split("\n"):
if line.strip():
log_info(line.strip())
if result.stderr:
for line in result.stderr.split("\n"):
if line.strip() and "WARNING" not in line:
log_error(line.strip())
if result.stdout and "Error:" in result.stdout:
log_error(
f"✗ Failed to sign {binary.name} - Authentication or signing error"
)
all_success = False
continue
signed_file = temp_output_dir / binary.name
if signed_file.exists():
import shutil
shutil.move(str(signed_file), str(binary))
log_info(f"Moved signed {binary.name} to original location")
try:
temp_output_dir.rmdir()
except Exception:
pass
verify_cmd = [
"powershell",
"-Command",
f"(Get-AuthenticodeSignature '{binary}').Status",
]
try:
verify_result = subprocess.run(
verify_cmd, capture_output=True, text=True
)
if "Valid" in verify_result.stdout:
log_success(f"{binary.name} signed and verified successfully")
else:
log_error(
f"{binary.name} signing verification failed - Status: {verify_result.stdout.strip()}"
)
all_success = False
except Exception:
log_warning(f"Could not verify signature for {binary.name}")
except Exception as e:
log_error(f"Failed to sign {binary.name}: {e}")
all_success = False
return all_success
def sign_universal(contexts: List[Context]) -> bool:
"""Windows doesn't support universal binaries"""
log_warning("Universal signing is not supported on Windows")
return True
def check_signing_environment(env: Optional[EnvConfig] = None) -> bool:
"""Check if Windows signing environment is properly configured
Args:
env: Optional EnvConfig instance. If not provided, creates a new one.
"""
if env is None:
env = EnvConfig()
if not env.code_sign_tool_exe and not env.code_sign_tool_path:
log_error("CODE_SIGN_TOOL_EXE or CODE_SIGN_TOOL_PATH not set")
return False
missing = []
if not env.esigner_username:
missing.append("ESIGNER_USERNAME")
if not env.esigner_password:
missing.append("ESIGNER_PASSWORD")
if not env.esigner_totp_secret:
missing.append("ESIGNER_TOTP_SECRET")
if missing:
log_error(f"Missing environment variables: {', '.join(missing)}")
return False
return True