Skip to content

Commit

Permalink
Implement code signing on Windows
Browse files Browse the repository at this point in the history
  • Loading branch information
mherrmann committed Sep 12, 2019
1 parent d080e87 commit 4b83cd2
Show file tree
Hide file tree
Showing 10 changed files with 185 additions and 24 deletions.
3 changes: 2 additions & 1 deletion .gitignore
Expand Up @@ -102,4 +102,5 @@ ENV/
.idea/

/src/
.DS_Store
.DS_Store
/cache/
2 changes: 1 addition & 1 deletion fbs/_defaults/requirements/base.txt
@@ -1 +1 @@
fbs==0.8.3
fbs==0.8.4
3 changes: 2 additions & 1 deletion fbs/_defaults/src/build/settings/windows.json
Expand Up @@ -3,5 +3,6 @@
"files_to_filter": [
"src/installer/windows/Installer.nsi"
],
"show_console_window": false
"show_console_window": false,
"url": ""
}
56 changes: 43 additions & 13 deletions fbs/builtin_commands/__init__.py
Expand Up @@ -5,7 +5,7 @@
"""
from fbs import path, SETTINGS, activate_profile
from fbs.builtin_commands._util import prompt_for_value, \
require_existing_project, update_json
require_existing_project, update_json, require_frozen_app, require_installer
from fbs.cmdline import command
from fbs.resources import copy_with_filtering
from fbs.upload import _upload_repo
Expand All @@ -15,7 +15,7 @@
from getpass import getuser
from importlib.util import find_spec
from os import listdir, remove, unlink, mkdir
from os.path import join, isfile, isdir, islink, dirname, exists
from os.path import join, isfile, isdir, islink, dirname, exists, relpath
from shutil import rmtree
from unittest import TestSuite, TextTestRunner, defaultTestLoader

Expand Down Expand Up @@ -139,17 +139,30 @@ def freeze(debug=False):
"https://build-system.fman.io/troubleshooting.", executable
)

@command
def sign():
"""
Sign your app, so the user's OS trusts it
"""
require_frozen_app()
if is_windows():
from fbs.sign.windows import sign_windows
sign_windows()
_LOG.info(
'Signed all binary files in %s and its subdirectories.',
relpath(path('${freeze_dir}'), path('.'))
)
elif is_mac():
_LOG.info('fbs does not yet implement `sign` on macOS.')
else:
_LOG.info('This platform does not support signing frozen apps.')

@command
def installer():
"""
Create an installer for your app
"""
require_existing_project()
if not exists(path('${freeze_dir}')):
raise FbsError(
'It seems your app has not yet been frozen. Please run:\n'
' fbs freeze'
)
require_frozen_app()
linux_distribution_not_supported_msg = \
"Your Linux distribution is not supported, sorry. " \
"You can run `fbs buildvm` followed by `fbs runvm` to start a Docker " \
Expand Down Expand Up @@ -203,15 +216,23 @@ def sign_installer():
"""
Sign installer, so the user's OS trusts it
"""
require_existing_project()
if is_arch_linux():
if is_mac():
_LOG.info('fbs does not yet implement `sign_installer` on macOS.')
return
if is_ubuntu():
_LOG.info('Ubuntu does not support signing installers.')
return
require_installer()
if is_windows():
from fbs.sign_installer.windows import sign_installer_windows
sign_installer_windows()
elif is_arch_linux():
from fbs.sign_installer.arch import sign_installer_arch
sign_installer_arch()
elif is_fedora():
from fbs.sign_installer.fedora import sign_installer_fedora
sign_installer_fedora()
else:
_LOG.info('This command is not (yet) supported on this platform.')
_LOG.info('Signed %s.', join('target', SETTINGS['installer']))

@command
def repo():
Expand Down Expand Up @@ -397,8 +418,12 @@ def release():
try:
clean()
freeze()
if is_windows() and _has_windows_codesigning_certificate():
sign()
installer()
sign_installer()
if (is_windows() and _has_windows_codesigning_certificate()) or \
is_arch_linux() or is_fedora():
sign_installer()
repo()
finally:
_LOG.setLevel(log_level)
Expand Down Expand Up @@ -458,6 +483,11 @@ def clean():
elif islink(fpath):
unlink(fpath)

def _has_windows_codesigning_certificate():
assert is_windows()
from fbs.sign.windows import _CERTIFICATE_PATH
return exists(path(_CERTIFICATE_PATH))

def _has_module(name):
return bool(find_spec(name))

Expand Down
15 changes: 15 additions & 0 deletions fbs/builtin_commands/_util.py
Expand Up @@ -42,6 +42,21 @@ def require_existing_project():
" fbs startproject ?"
)

def require_frozen_app():
if not exists(path('${freeze_dir}')):
raise FbsError(
'It seems your app has not yet been frozen. Please run:\n'
' fbs freeze'
)

def require_installer():
installer = path('target/${installer}')
if not exists(installer):
raise FbsError(
'Installer does not exist. Maybe you need to run:\n'
' fbs installer'
)

def update_json(f_path, dict_):
f = Path(f_path)
try:
Expand Down
Empty file added fbs/sign/__init__.py
Empty file.
115 changes: 115 additions & 0 deletions fbs/sign/windows.py
@@ -0,0 +1,115 @@
from fbs import path, SETTINGS
from fbs_runtime import FbsError
from os import makedirs
from os.path import join, splitext, dirname, basename, exists
from shutil import copy
from subprocess import call, run, DEVNULL

import hashlib
import json
import os

_CERTIFICATE_PATH = 'src/sign/windows/certificate.pfx'
_TO_SIGN = ('.exe', '.cab', '.dll', '.ocx', '.msi', '.xpi')

def sign_windows():
if not exists(path(_CERTIFICATE_PATH)):
raise FbsError(
'Could not find a code signing certificate at:\n '
+ _CERTIFICATE_PATH
)
if 'windows_sign_pass' not in SETTINGS:
raise FbsError(
"Please set 'windows_sign_pass' to the password of %s in either "
"src/build/settings/secret.json, .../windows.json or .../base.json."
% _CERTIFICATE_PATH
)
for subdir, _, files in os.walk(path('${freeze_dir}')):
for file_ in files:
extension = splitext(file_)[1]
if extension in _TO_SIGN:
sign_file(join(subdir, file_))

def sign_file(file_path, description='', url=''):
helper = _SignHelper.instance()
if not helper.is_signed(file_path):
helper.sign(file_path, description, url)

class _SignHelper:

_INSTANCE = None

@classmethod
def instance(cls):
if cls._INSTANCE is None:
cls._INSTANCE = cls(path('cache/signed'))
return cls._INSTANCE

def __init__(self, cache_dir):
self._cache_dir = cache_dir

def is_signed(self, file_path):
return not call(
['signtool', 'verify', '/pa', file_path], stdout=DEVNULL,
stderr=DEVNULL
)

def sign(self, file_path, description, url):
json_path = self._get_json_path(file_path)
try:
with open(json_path) as f:
cached = json.load(f)
is_in_cache = description == cached['description'] and \
url == cached['url'] and \
self._hash(file_path) == cached['hash']
except FileNotFoundError:
is_in_cache = False
if not is_in_cache:
self._sign(file_path, description, url)
copy(self._get_path_in_cache(file_path), file_path)

def _sign(self, file_path, description, url):
path_in_cache = self._get_path_in_cache(file_path)
makedirs(dirname(path_in_cache), exist_ok=True)
copy(file_path, path_in_cache)
hash_ = self._hash(path_in_cache)
self._run_signtool(path_in_cache)
with open(self._get_json_path(file_path), 'w') as f:
json.dump({
'description': description,
'url': url,
'hash': hash_
}, f)

def _get_json_path(self, file_path):
return self._get_path_in_cache(file_path) + '.json'

def _get_path_in_cache(self, file_path):
return join(self._cache_dir, basename(file_path))

def _run_signtool(self, file_path, description='', url=''):
password = SETTINGS['windows_sign_pass']
args = [
'signtool', 'sign', '/f', path(_CERTIFICATE_PATH), '/p', password
]
if 'windows_sign_server' in SETTINGS:
args.extend(['/tr', SETTINGS['windows_sign_server']])
if description:
args.extend(['/d', description])
if url:
args.extend(['/du', url])
args.append(file_path)
run(args, check=True, stdout=DEVNULL)
args_sha256 = \
args[:-1] + ['/as', '/fd', 'sha256', '/td', 'sha256'] + args[-1:]
run(args_sha256, check=True, stdout=DEVNULL)

def _hash(self, file_path):
bufsize = 65536
hasher = hashlib.md5()
with open(file_path, 'rb') as f:
buf = f.read(bufsize)
while buf:
hasher.update(buf)
buf = f.read(bufsize)
return hasher.hexdigest()
7 changes: 0 additions & 7 deletions fbs/sign_installer/arch.py
@@ -1,16 +1,9 @@
from fbs import path, SETTINGS
from fbs._gpg import preset_gpg_passphrase
from fbs_runtime import FbsError
from os.path import exists
from subprocess import check_call, DEVNULL

def sign_installer_arch():
installer = path('target/${installer}')
if not exists(installer):
raise FbsError(
'Installer does not exist. Maybe you need to run:\n'
' fbs installer'
)
# Prevent GPG from prompting us for the passphrase when signing:
preset_gpg_passphrase()
check_call(
Expand Down
6 changes: 6 additions & 0 deletions fbs/sign_installer/windows.py
@@ -0,0 +1,6 @@
from fbs import path, SETTINGS
from fbs.sign.windows import sign_file

def sign_installer_windows():
installer = path('target/${installer}')
sign_file(installer, SETTINGS['app_name'] + ' Setup', SETTINGS['url'])
2 changes: 1 addition & 1 deletion setup.py
Expand Up @@ -21,7 +21,7 @@ def _get_package_data(pkg_dir, data_subdir):
setup(
name='fbs',
# Also update fbs/_defaults/requirements/base.txt when you change this:
version='0.8.3',
version='0.8.4',
description=description,
long_description=
description + '\n\nHome page: https://build-system.fman.io',
Expand Down

0 comments on commit 4b83cd2

Please sign in to comment.