mirror of
https://github.com/mumble-voip/mumble.git
synced 2024-12-04 04:27:20 +00:00
330c356e71
Keeping these up-to-date is just super tedious and they don't really fulfill any purpose these days.
333 lines
10 KiB
Python
Executable File
333 lines
10 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
#
|
|
# Copyright The Mumble Developers. All rights reserved.
|
|
# Use of this source code is governed by a BSD-style license
|
|
# that can be found in the LICENSE file at the root of the
|
|
# Mumble source tree or at <https://www.mumble.info/LICENSE>.
|
|
#
|
|
# About the tool
|
|
# --------------
|
|
# sign_macOS.py is a tool that takes a Mumble .DMG file and digitally signs its content
|
|
# (executables, codecs, plugins, installers).
|
|
#
|
|
# The file(s) may already be signed. In this case, the tool will simply replace all the existing signatures.
|
|
#
|
|
# Using the tool
|
|
# --------------
|
|
# To sign Mumble-1.4.0.unsigned.dmg, one would do the following:
|
|
#
|
|
# $ ./sign_macOS.py -i Mumble-1.4.0.unsigned.dmg -o Mumble-1.4.0.dmg
|
|
#
|
|
# Configuration
|
|
# -------------
|
|
# The signing behavior of the tool can be configured via a configuration file.
|
|
# By default the tool will read its configuration from $HOME/.sign_macOS.cfg.
|
|
#
|
|
# The configuration file uses JSON. For example, to sign using a particular
|
|
# set of Developer ID certificates in your login keychain, you could use
|
|
# something like this:
|
|
#
|
|
# --->8---
|
|
# {
|
|
# # The keychain needs the .keychain extension explicitly typed out.
|
|
# "keychain": "login.keychain",
|
|
# # Your Application certificate.
|
|
# "developer-id-app": "Developer ID Application: John Appleseed",
|
|
# # Your Installer certificate.
|
|
# "developer-id-installer": "Developer ID Installer: John Appleseed"
|
|
# }
|
|
# --->8---
|
|
|
|
import argparse
|
|
import io
|
|
import json
|
|
import os
|
|
import pathlib
|
|
import plistlib
|
|
import shutil
|
|
import string
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
|
|
# Specify a set of codesign requirements for Mumble binaries.
|
|
#
|
|
# We require an Apple CA and a Developer ID leaf certificate (the one we're signing with).
|
|
# The 'designated' line is specifically tuned to work on older versions
|
|
# of macOS that aren't Developer ID-aware without breakage.
|
|
#
|
|
# We also explicitly require all shared libraries to be codesigned by Apple.
|
|
# We can reasonably do that because Mumble is statically built on macOS.
|
|
requirements = '''designated => anchor apple generic and identifier "${identifier}" and (certificate leaf[field.1.2.840.113635.100.6.1.9] /* exists */ or certificate 1[field.1.2.840.113635.100.6.2.6] /* exists */ and certificate leaf[field.1.2.840.113635.100.6.1.13] /* exists */ and certificate leaf[subject.OU] = "${subject_OU}")
|
|
library => anchor apple'''
|
|
|
|
class Signer:
|
|
def __init__(self, cfgFile):
|
|
with open(cfgFile) as file:
|
|
content = file.read()
|
|
self.cfg = json.loads(content)
|
|
|
|
@staticmethod
|
|
def cmd(args, cwd = None):
|
|
'''
|
|
Executes the requested program and throws an exception if the program returns a non-zero return code.
|
|
'''
|
|
ret = subprocess.Popen(args, cwd = cwd).wait()
|
|
if ret != 0:
|
|
raise Exception('command "%s" exited with exit status %i' % (args[0], ret))
|
|
|
|
def certificateSubjectOU(self):
|
|
'''
|
|
Extracts the subject OU from the Application DeveloperID certificate that is specified in the script's configuration.
|
|
'''
|
|
findCert = subprocess.Popen(('/usr/bin/security', 'find-certificate',
|
|
'-c', self.cfg['developer-id-app'],
|
|
'-p', self.cfg['keychain']),
|
|
stdout = subprocess.PIPE, text = True)
|
|
pem, _ = findCert.communicate()
|
|
|
|
openssl = subprocess.Popen(('/usr/bin/openssl', 'x509', '-subject', '-noout'),
|
|
stdout = subprocess.PIPE, stdin = subprocess.PIPE, text = True)
|
|
subject, _ = openssl.communicate(pem)
|
|
|
|
start = subject.split('/OU=', 1)[1]
|
|
end = start.index('/')
|
|
|
|
return start[:end]
|
|
|
|
@staticmethod
|
|
def lookupFileIdentifier(file):
|
|
'''
|
|
Looks up a bundle identifier suitable for use when signing the app bundle or binary.
|
|
'''
|
|
try:
|
|
d = plistlib.load(os.path.join(file, 'Contents', 'Info.plist'))
|
|
return d['CFBundleIdentifier']
|
|
except:
|
|
return os.path.basename(file)
|
|
|
|
def codesign(self, files, entitlements = None):
|
|
'''
|
|
Calls the codesign executable on the signable object(s) in the specified directory.
|
|
'''
|
|
OU = self.certificateSubjectOU()
|
|
|
|
if hasattr(files, 'isalpha'):
|
|
files = (files,)
|
|
|
|
for file in files:
|
|
identifier = self.lookupFileIdentifier(file)
|
|
reqs = string.Template(requirements).substitute({
|
|
'identifier': identifier,
|
|
'subject_OU': OU,
|
|
})
|
|
|
|
print("Identifier:", identifier)
|
|
print("Subject_OU:", OU)
|
|
print("Reqs:", reqs)
|
|
|
|
args = ['-vvvv', '--force',
|
|
'-r=' + reqs,
|
|
'--identifier', identifier,
|
|
'--keychain', self.cfg['keychain'],
|
|
'--sign', self.cfg['developer-id-app']]
|
|
|
|
if entitlements:
|
|
args += ['--options', 'runtime',
|
|
'--entitlements', entitlements]
|
|
|
|
self.cmd(['codesign'] + args + [file])
|
|
|
|
return 0
|
|
|
|
def prodsign(self, inFile, outFile):
|
|
'''
|
|
Calls the productsign executable to sign the specified product, outputting it signed.
|
|
'''
|
|
self.cmd(['productsign',
|
|
'--keychain', self.cfg['keychain'],
|
|
'--sign', self.cfg['developer-id-installer'],
|
|
inFile, outFile])
|
|
|
|
@staticmethod
|
|
def volNameForMountedDMG(mountPoint):
|
|
diskutil = subprocess.Popen(['diskutil', 'info', '-plist', mountPoint], stdout = subprocess.PIPE)
|
|
plist, _ = diskutil.communicate()
|
|
fileLikePlist = io.BytesIO(plist)
|
|
diskInfo = plistlib.load(fileLikePlist)
|
|
return diskInfo['VolumeName']
|
|
|
|
@staticmethod
|
|
def extractDMG(file, workDir):
|
|
'''
|
|
Extracts the specified DMG into the "content" subdirectory of workDir.
|
|
It returns the volume name of the extracted DMG.
|
|
'''
|
|
mountDir = os.path.join(workDir, 'mount')
|
|
os.mkdir(mountDir)
|
|
Signer.cmd(['hdiutil', 'mount', '-readonly', '-mountpoint', mountDir, file])
|
|
volName = Signer.volNameForMountedDMG(mountDir)
|
|
contentDir = os.path.join(workDir, 'content')
|
|
os.mkdir(contentDir)
|
|
for file in os.listdir(mountDir):
|
|
if file == '.Trashes':
|
|
continue
|
|
|
|
src = os.path.join(mountDir, file)
|
|
dst = os.path.join(contentDir, file)
|
|
|
|
if os.path.islink(src):
|
|
target = os.readlink(src)
|
|
os.symlink(target, dst)
|
|
elif os.path.isdir(src):
|
|
shutil.copytree(src, dst, True)
|
|
else:
|
|
shutil.copy(src, dst)
|
|
|
|
Signer.cmd(['umount', mountDir])
|
|
|
|
return volName
|
|
|
|
@staticmethod
|
|
def extractPKG(file, workDir):
|
|
'''
|
|
Expands the specified PKG into workDir.
|
|
'''
|
|
Signer.cmd(['pkgutil', '--expand-full', file, workDir])
|
|
|
|
def signApp(self, workDir, entitlements = None):
|
|
'''
|
|
Signs the app bundle in the "content" subdirectory of workDir.
|
|
'''
|
|
app = os.path.join(workDir, 'content', 'Mumble.app')
|
|
|
|
binaries = []
|
|
binariesDir = os.path.join(app, 'Contents', 'MacOS')
|
|
for binary in os.listdir(binariesDir):
|
|
binaries.append(os.path.join(binariesDir, binary))
|
|
|
|
codecs = []
|
|
codecsDir = os.path.join(app, 'Contents', 'Codecs')
|
|
for codec in os.listdir(codecsDir):
|
|
codecs.append(os.path.join(codecsDir, codec))
|
|
|
|
plugins = []
|
|
pluginsDir = os.path.join(app, 'Contents', 'Plugins')
|
|
for plugin in os.listdir(pluginsDir):
|
|
plugins.append(os.path.join(pluginsDir, plugin))
|
|
|
|
self.codesign(binaries)
|
|
self.codesign(codecs)
|
|
self.codesign(plugins)
|
|
|
|
overlayInst = os.path.join(app, 'Contents', 'Resources', 'MumbleOverlay.pkg')
|
|
os.rename(overlayInst, overlayInst + '.intermediate')
|
|
self.prodsign(overlayInst + '.intermediate', overlayInst)
|
|
os.remove(overlayInst + '.intermediate')
|
|
|
|
self.codesign(app, entitlements)
|
|
|
|
def signOverlay(self, workDir):
|
|
'''
|
|
Extracts "MumbleOverlay.pkg" (which is extracted from the app bundle),
|
|
signs the relevant binaries and then repacks it.
|
|
'''
|
|
app = os.path.join(workDir, 'content', 'Mumble.app')
|
|
|
|
overlayPKG = os.path.join(app, 'Contents', 'Resources', 'MumbleOverlay.pkg')
|
|
overlayDir = os.path.join(workDir, 'MumbleOverlay')
|
|
|
|
self.extractPKG(overlayPKG, overlayDir)
|
|
|
|
binaries = []
|
|
binariesDir = os.path.join(overlayDir, 'net.sourceforge.mumble.OverlayScriptingAddition.pkg',
|
|
'Payload', 'MumbleOverlay.osax',
|
|
'Contents', 'MacOS')
|
|
for binary in os.listdir(binariesDir):
|
|
binaries.append(os.path.join(binariesDir, binary))
|
|
|
|
self.codesign(binaries)
|
|
|
|
self.makePKG(overlayDir, overlayPKG)
|
|
|
|
@staticmethod
|
|
def makeDMG(workDir, volName, outFile):
|
|
'''
|
|
Makes a new DMG for the Mumble app that resides in the workDir's content subdirectory.
|
|
'''
|
|
Signer.cmd(['hdiutil', 'create', '-srcfolder', os.path.join(workDir, 'content'),
|
|
'-format', 'UDBZ', '-size', '1g', '-volname', volName, outFile])
|
|
|
|
@staticmethod
|
|
def makePKG(workDir, file):
|
|
'''
|
|
Flattens workDir's content into a PKG file.
|
|
'''
|
|
Signer.cmd(['pkgutil', '--flatten-full', workDir, file])
|
|
|
|
def main():
|
|
p = argparse.ArgumentParser(usage='sign_macOS.py --input=<in.dmg> --output=<out.dmg> [--keep-tree]')
|
|
p.add_argument('-c', '--config', help = 'Configuration file', default = os.path.join(pathlib.Path.home(), '.sign_macOS.cfg'))
|
|
p.add_argument('-e', '--entitlements', help = 'Entitlements file')
|
|
p.add_argument('-i', '--input', help = 'Input file')
|
|
p.add_argument('-o', '--output', help = 'Output file')
|
|
p.add_argument('-kt', '--keep-tree', action = 'store_true', dest = 'keepTree',
|
|
help = 'Keep the working tree after signing')
|
|
args = p.parse_args()
|
|
|
|
if not args.input:
|
|
print('No input specified')
|
|
sys.exit(1)
|
|
|
|
if not args.output:
|
|
print('No output specified')
|
|
sys.exit(1)
|
|
|
|
signer = Signer(args.config)
|
|
|
|
inFile = os.path.abspath(args.input)
|
|
outFile = os.path.abspath(args.output)
|
|
workDir = tempfile.mkdtemp()
|
|
|
|
print('Input: ' + inFile)
|
|
print('Output: ' + outFile)
|
|
print('Working dir: ' + workDir + '\n')
|
|
|
|
if inFile.lower().endswith('.dmg'):
|
|
volName = signer.extractDMG(inFile, workDir)
|
|
|
|
signer.signOverlay(workDir)
|
|
signer.signApp(workDir, args.entitlements)
|
|
|
|
signer.makeDMG(workDir, volName, outFile)
|
|
|
|
signer.codesign(outFile)
|
|
elif inFile.lower().endswith('.pkg'):
|
|
signer.extractPKG(inFile, workDir)
|
|
|
|
files = []
|
|
for file in os.listdir(workDir):
|
|
files.append(os.path.join(workDir, file))
|
|
|
|
signer.codesign(files)
|
|
|
|
signer.makePKG(workDir, outFile)
|
|
|
|
os.rename(outFile, outFile + '.intermediate')
|
|
signer.prodsign(outFile + '.intermediate', outFile)
|
|
os.remove(outFile + '.intermediate')
|
|
else:
|
|
shutil.copy(inFile, outFile)
|
|
signer.codesign(outFile)
|
|
|
|
print('')
|
|
|
|
if not args.keepTree:
|
|
shutil.rmtree(workDir, ignore_errors = True)
|
|
print('Working tree removed\n')
|
|
|
|
print('Signed file available at ' + args.output)
|
|
|
|
if __name__ == '__main__':
|
|
main()
|