Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions aw_windows_codesign.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
"""Windows code signing support for ActivityWatch releases.

Addresses issue #632: Code sign the Windows releases
"""

import os
import subprocess
import logging
from pathlib import Path
from typing import Optional
Comment on lines +1 to +10
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Script is not wired into the CI/CD pipeline

The new WindowsCodeSigner class has no call site in the existing build workflow. The build.yml Windows job installs Inno Setup and packages the release, but never imports or invokes this module. The macOS equivalent (codesign / xcnotary) is called inline in build.yml under the "Package dmg" step. Without a corresponding "Package Windows" step that calls sign_directory, this script will never actually sign any release artefact.


logger = logging.getLogger(__name__)


class WindowsCodeSigner:
"""Handles code signing for Windows releases."""

def __init__(
self,
certificate_path: Optional[str] = None,
certificate_password: Optional[str] = None,
timestamp_server: str = "http://timestamp.digicert.com",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The default timestamp server URL uses plain HTTP. Timestamp responses themselves are signed, but using HTTP means the channel integrity relies solely on the signature rather than transport security; some network-level policies also block unencrypted outbound connections. DigiCert provides an HTTPS endpoint.

tool: str = "auto",
):
self.certificate_path = certificate_path or os.environ.get("CODESIGN_CERT_PATH")
self.certificate_password = certificate_password or os.environ.get("CODESIGN_CERT_PASSWORD")
self.timestamp_server = timestamp_server
self.tool = self._detect_tool(tool)

def _detect_tool(self, tool: str) -> str:
if tool != "auto":
return tool
for t in ["signtool", "osslsigncode"]:
try:
subprocess.run([t, "--help"], capture_output=True, check=True)
return t
except (subprocess.CalledProcessError, FileNotFoundError):
continue
logger.warning("No code signing tool found")
return "none"
Comment on lines +33 to +40
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 signtool tool detection fails when signtool --help exits non-zero

signtool on Windows does not recognise Unix-style --help; it exits with a non-zero code, which raises CalledProcessError and is silently caught. This means signtool is skipped and the signer falls back to osslsigncode (or "none") even on a Windows runner where signtool is installed and should be the preferred tool. The check should not use check=True; instead, catch only FileNotFoundError (tool not installed) and treat any other return code as proof that the binary exists.


def sign_file(self, filepath: str) -> bool:
if self.tool == "none" or not self.certificate_path:
logger.error("Signing not configured")
return False
if not Path(filepath).exists():
logger.error(f"File not found: {filepath}")
return False
if self.tool == "signtool":
cmd = ["signtool", "sign", "/f", self.certificate_path, "/t", self.timestamp_server, filepath]
if self.certificate_password:
cmd.extend(["/p", self.certificate_password])
elif self.tool == "osslsigncode":
output = filepath + ".signed"
cmd = ["osslsigncode", "sign", "-pkcs12", self.certificate_path, "-t", self.timestamp_server, "-in", filepath, "-out", output]
if self.certificate_password:
cmd.extend(["-pass", self.certificate_password])
Comment on lines +51 to +57
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Certificate password exposed in process argument list

The certificate password is placed directly in the subprocess argument list (/p password / -pass password). On any multi-user system and in most CI environments, the full process command line is visible to other processes via /proc or the OS process list. GitHub Actions also prints command lines in debug mode. For signtool, consider writing the password to a temporary file and using /p with an env-var indirection, or passing it via stdin. For osslsigncode, the -pass env:VAR syntax reads the secret from an environment variable rather than the command line.

else:
return False
try:
subprocess.run(cmd, capture_output=True, text=True, check=True)
logger.info(f"Successfully signed: {filepath}")
return True
except subprocess.CalledProcessError as e:
logger.error(f"Signing failed: {e.stderr}")
return False
Comment on lines +53 to +66
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 osslsigncode signed file is never swapped back to the original path

After osslsigncode runs, the signed binary is written to filepath + ".signed" but the original unsigned file at filepath is never replaced. The function logs success and returns True, so callers believe the file is signed while the original unsigned binary remains in place. The .signed artifact is also silently leaked on disk. Path(output).replace(filepath) (or os.replace) must be called after the subprocess succeeds to atomically swap the files.


def sign_directory(self, directory: str, extensions: tuple = (".exe", ".dll", ".msi")) -> dict:
results = {"signed": [], "failed": [], "skipped": []}
for root, dirs, files in os.walk(directory):
for f in files:
fp = os.path.join(root, f)
if f.endswith(extensions):
if self.sign_file(fp):
results["signed"].append(fp)
else:
results["failed"].append(fp)
else:
results["skipped"].append(fp)
return results

def verify_signature(self, filepath: str) -> bool:
if self.tool == "signtool":
cmd = ["signtool", "verify", "/pa", filepath]
elif self.tool == "osslsigncode":
cmd = ["osslsigncode", "verify", "-in", filepath]
else:
return False
try:
subprocess.run(cmd, capture_output=True, text=True, check=True)
return True
except subprocess.CalledProcessError:
return False