Repository: rotki/rotki

Issue:
Sign the Windows binary by obtaining a windows approved certificate
## Abstract

Windows users need to disregard some really annoying warnings that the software may be harmful and that it's from unverified sources. We are definitely losing users from this and we need to correct this.

## Task

- Obtain a windows approved certificate
- Sign the generated windows binaries with it


Discussion:

Resolving patch:
diff --git a/package.py b/package.py
--- a/package.py
+++ b/package.py
@@ -8,6 +8,7 @@
 import stat
 import subprocess
 from pathlib import Path
+from tempfile import NamedTemporaryFile
 from typing import Any, Callable, Dict, Literal, Optional
 from zipfile import ZipFile
 
@@ -35,6 +36,12 @@
 )
 logger = logging.getLogger('package')
 
+MAC_CERTIFICATE = 'CERTIFICATE_OSX_APPLICATION'
+WIN_CERTIFICATE = 'CERTIFICATE_WIN_APPLICATION'
+CERTIFICATE_KEY = 'CSC_KEY_PASSWORD'
+APPLE_ID = 'APPLEID'
+APPLE_ID_PASS = 'APPLEIDPASS'
+
 
 def log_group(name: str) -> Callable:
     def start_group(group_name: str) -> None:
@@ -93,6 +100,65 @@ def __init__(self) -> None:
         if os.environ.get('ROTKI_VERSION') is None:
             os.environ.setdefault('ROTKI_VERSION', self.rotki_version)
 
+        self.__certificate_mac = os.environ.get(MAC_CERTIFICATE)
+        self.__certificate_win = os.environ.get(WIN_CERTIFICATE)
+        self.__csc_password = os.environ.get(CERTIFICATE_KEY)
+        self.__appleid = os.environ.get(APPLE_ID)
+        self.__appleidpass = os.environ.get(APPLE_ID_PASS)
+
+        os.environ.pop(MAC_CERTIFICATE, None)
+        os.environ.pop(WIN_CERTIFICATE, None)
+        os.environ.pop(CERTIFICATE_KEY, None)
+        os.environ.pop(APPLE_ID, None)
+        os.environ.pop(APPLE_ID_PASS, None)
+
+    def macos_sign_env(self) -> Dict[str, str]:
+        env = os.environ.copy()
+        if self.__csc_password is not None:
+            env.setdefault(CERTIFICATE_KEY, self.__csc_password)
+        if self.__appleid is not None:
+            env.setdefault(APPLE_ID, self.__appleid)
+        if self.__appleidpass is not None:
+            env.setdefault(APPLE_ID_PASS, self.__appleidpass)
+        return env
+
+    def macos_sign_vars(self) -> Dict[str, Optional[str]]:
+        return {
+            'certificate': self.__certificate_mac,
+            'key': self.__csc_password,
+            'appleid': self.__appleid,
+            'appleidpass': self.__appleidpass,
+        }
+
+    def win_sign_env(self) -> Dict[str, str]:
+        env = os.environ.copy()
+        if self.__csc_password is not None:
+            env.setdefault(CERTIFICATE_KEY, self.__csc_password)
+        return env
+
+    def win_sign_vars(self) -> Dict[str, Optional[str]]:
+        return {
+            'certificate': self.__certificate_win,
+            'key': self.__csc_password,
+        }
+
+    @staticmethod
+    def sanity_check() -> None:
+        """
+        Sanity check that exits if any os the secret environment variables is set when called.
+        """
+        mac_cert = os.environ.get(MAC_CERTIFICATE)
+        win_cert = os.environ.get(WIN_CERTIFICATE)
+        key_pass = os.environ.get(CERTIFICATE_KEY)
+        appleid = os.environ.get(APPLE_ID)
+        appleidpass = os.environ.get(APPLE_ID_PASS)
+
+        sign_vars = [mac_cert, win_cert, key_pass, appleid, appleidpass]
+
+        if any(sign_vars):
+            logger.error('at least one of the secrets was set in the environment')
+            exit(1)
+
     @staticmethod
     def check_repo() -> None:
         output = subprocess.check_output(
@@ -259,6 +325,7 @@ class WindowsPackaging:
     def __init__(self, storage: Storage, env: Environment) -> None:
         self.__storage = storage
         self.__env = env
+        self.__p12 = ''
 
     @log_group('miniupnpc windows')
     def setup_miniupnpc(self) -> None:
@@ -303,6 +370,46 @@ def setup_miniupnpc(self) -> None:
         zip_path.unlink(missing_ok=True)
         shutil.rmtree(extraction_dir)
 
+    @log_group('certificates')
+    def import_signing_certificates(self) -> bool:
+        """
+        Imports the signing certificates from the environment variables
+        and prepares for signing.
+
+        The function will bail (exit 1) when the certificate is set but
+        no key has been passed in the configuration.
+
+        :return: True when the certificate and key are properly setup,
+        False when the certificate is not configured.
+        """
+        sign_vars = self.__env.win_sign_vars()
+        certificate = sign_vars.get('certificate')
+        csc_password = sign_vars.get('key')
+
+        if os.environ.get('WIN_CSC_LINK') is not None and csc_password is not None:
+            logger.info('WIN_CSC_LINK already set skipping')
+            return True
+
+        if certificate is None:
+            logger.info(f'{WIN_CERTIFICATE} is not set skipping signing')
+            return False
+
+        if csc_password is None:
+            logger.error(f'Missing {CERTIFICATE_KEY}')
+            exit(1)
+
+        logger.info('preparing to sign windows installer')
+        with NamedTemporaryFile(delete=False, suffix='.p12') as p12:
+            self.__p12 = p12.name
+            os.environ.setdefault('WIN_CSC_LINK', self.__p12)
+            certificate_data = base64.b64decode(certificate)
+            p12.write(certificate_data)
+
+        return True
+
+    def cleanup_certificate(self) -> None:
+        Path(self.__p12).unlink(missing_ok=True)
+
 
 class MacPackaging:
     def __init__(self, storage: Storage, environment: Environment) -> None:
@@ -609,15 +716,20 @@ def import_signing_certificates(self) -> bool:
         Imports the signing certificates from the environment variables
         and prepares the keychain for signing
         """
-        certificate = os.environ.get('CERTIFICATE_OSX_APPLICATION')
-        csc_password = os.environ.get('CSC_KEY_PASSWORD')
+        sign_vars = self.__environment.macos_sign_vars()
+        certificate = sign_vars.get('certificate')
+        csc_password = sign_vars.get('key')
+
+        if os.environ.get('CSC_LINK') is not None and csc_password is not None:
+            logger.info('CSC_LINK already set skipping')
+            return True
 
         if certificate is None:
-            logger.info('CERTIFICATE_OSX_APPLICATION is not set skipping signing')
+            logger.info(f'{MAC_CERTIFICATE} is not set skipping signing')
             return False
 
         if csc_password is None:
-            logger.error('Missing CSC_KEY_PASSWORD')
+            logger.error(f'Missing {CERTIFICATE_KEY}')
             exit(1)
 
         logger.info('preparing to sign macOS binary')
@@ -848,6 +960,8 @@ def build(self) -> None:
         github_ref = os.environ.get('GITHUB_REF')
         os.environ.pop('GITHUB_REF', None)
 
+        self.__env.sanity_check()
+
         mac = self.__mac
         if mac is not None and self.__env.is_universal2():
             logger.info('Doing preparation for universal2 wheels')
@@ -929,11 +1043,18 @@ def __install_pyinstaller(self) -> None:
 
 
 class FrontendBuilder:
-    def __init__(self, storage: Storage, env: Environment, mac: Optional[MacPackaging]):
+    def __init__(
+            self,
+            storage: Storage,
+            env: Environment,
+            mac: Optional[MacPackaging],
+            win: Optional[WindowsPackaging],
+    ):
         self.__storage = storage
         self.__frontend_directory = storage.working_directory / 'frontend'
         self.__env = env
         self.__mac = mac
+        self.__win = win
 
     def __ensure_backend_executable(self) -> None:
         backend_directory = self.__storage.backend_directory
@@ -956,21 +1077,37 @@ def check_npm_version() -> None:
 
     @log_group('electron app build')
     def build(self) -> None:
+        self.__env.sanity_check()
         self.check_npm_version()
         self.__storage.check_backend()
         os.chdir(self.__frontend_directory)
-        if self.__mac is not None:
-            self.__mac.import_signing_certificates()
-
         frontend_env = self.__env.get_frontend_env()
 
         self.__restore_npm_dependencies()
         self.__ensure_backend_executable()
 
-        logger.info('Calling electron:build')
-        ret_code = subprocess.call('npm run electron:build', shell=True, env=frontend_env)
+        sign_env = {}
+        if self.__mac is not None:
+            self.__mac.import_signing_certificates()
+            sign_env = self.__env.macos_sign_env()
+
+        if self.__win is not None:
+            self.__win.import_signing_certificates()
+            sign_env = self.__env.win_sign_env()
+
+        logger.info('Calling build')
+        ret_code = subprocess.call('npm run build', shell=True, env=frontend_env)
         if ret_code != 0:
-            logger.error('electron build failed')
+            logger.error('build failed')
+            exit(1)
+
+        package_ret_code = subprocess.call(
+            'npm run electron:package',
+            shell=True,
+            env=frontend_env | sign_env,
+        )
+        if package_ret_code != 0:
+            logger.error('package failed')
             exit(1)
 
         app_path = self.__frontend_directory / 'app'
@@ -990,6 +1127,8 @@ def build(self) -> None:
 
         if self.__mac is not None:
             self.__mac.cleanup_keychain()
+        if self.__win is not None:
+            self.__win.cleanup_certificate()
 
     @staticmethod
     @log_group('npm ci')
@@ -1035,7 +1174,7 @@ def main() -> None:
         builder.build()
 
     if args.build in ['full', 'frontend']:
-        frontend_builder = FrontendBuilder(storage, environment, mac)
+        frontend_builder = FrontendBuilder(storage, environment, mac, win)
         frontend_builder.build()
 
 


Test patch: