chiark / gitweb /
makebuildserver: quiet rsync for copy_caches_from_host
[fdroidserver.git] / fdroidserver / publish.py
1 #!/usr/bin/env python3
2 #
3 # publish.py - part of the FDroid server tools
4 # Copyright (C) 2010-13, Ciaran Gultnieks, ciaran@ciarang.com
5 # Copyright (C) 2013-2014 Daniel Martí <mvdan@mvdan.cc>
6 #
7 # This program is free software: you can redistribute it and/or modify
8 # it under the terms of the GNU Affero General Public License as published by
9 # the Free Software Foundation, either version 3 of the License, or
10 # (at your option) any later version.
11 #
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15 # GNU Affero General Public License for more details.
16 #
17 # You should have received a copy of the GNU Affero General Public License
18 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
19
20 import sys
21 import os
22 import re
23 import shutil
24 import glob
25 import hashlib
26 from argparse import ArgumentParser
27 from collections import OrderedDict
28 import logging
29 from gettext import ngettext
30 import json
31 import zipfile
32
33 from . import _
34 from . import common
35 from . import metadata
36 from .common import FDroidPopen, SdkToolsPopen
37 from .exception import BuildException, FDroidException
38
39 config = None
40 options = None
41
42
43 def publish_source_tarball(apkfilename, unsigned_dir, output_dir):
44     """Move the source tarball into the output directory..."""
45
46     tarfilename = apkfilename[:-4] + '_src.tar.gz'
47     tarfile = os.path.join(unsigned_dir, tarfilename)
48     if os.path.exists(tarfile):
49         shutil.move(tarfile, os.path.join(output_dir, tarfilename))
50         logging.debug('...published %s', tarfilename)
51     else:
52         logging.debug('...no source tarball for %s', apkfilename)
53
54
55 def key_alias(appid, resolve=False):
56     """Get the alias which F-Droid uses to indentify the singing key
57     for this App in F-Droids keystore.
58     """
59     if config and 'keyaliases' in config and appid in config['keyaliases']:
60         # For this particular app, the key alias is overridden...
61         keyalias = config['keyaliases'][appid]
62         if keyalias.startswith('@'):
63             m = hashlib.md5()
64             m.update(keyalias[1:].encode('utf-8'))
65             keyalias = m.hexdigest()[:8]
66         return keyalias
67     else:
68         m = hashlib.md5()
69         m.update(appid.encode('utf-8'))
70         return m.hexdigest()[:8]
71
72
73 def read_fingerprints_from_keystore():
74     """Obtain a dictionary containing all singning-key fingerprints which
75     are managed by F-Droid, grouped by appid.
76     """
77     env_vars = {'LC_ALL': 'C',
78                 'FDROID_KEY_STORE_PASS': config['keystorepass'],
79                 'FDROID_KEY_PASS': config['keypass']}
80     p = FDroidPopen([config['keytool'], '-list',
81                      '-v', '-keystore', config['keystore'],
82                      '-storepass:env', 'FDROID_KEY_STORE_PASS'],
83                     envs=env_vars, output=False)
84     if p.returncode != 0:
85         raise FDroidException('could not read keysotre {}'.format(config['keystore']))
86
87     realias = re.compile('Alias name: (?P<alias>.+)\n')
88     resha256 = re.compile('\s+SHA256: (?P<sha256>[:0-9A-F]{95})\n')
89     fps = {}
90     for block in p.output.split(('*' * 43) + '\n' + '*' * 43):
91         s_alias = realias.search(block)
92         s_sha256 = resha256.search(block)
93         if s_alias and s_sha256:
94             sigfp = s_sha256.group('sha256').replace(':', '').lower()
95             fps[s_alias.group('alias')] = sigfp
96     return fps
97
98
99 def sign_sig_key_fingerprint_list(jar_file):
100     """sign the list of app-signing key fingerprints which is
101     used primaryily by fdroid update to determine which APKs
102     where built and signed by F-Droid and which ones were
103     manually added by users.
104     """
105     cmd = [config['jarsigner']]
106     cmd += '-keystore', config['keystore']
107     cmd += '-storepass:env', 'FDROID_KEY_STORE_PASS'
108     cmd += '-digestalg', 'SHA1'
109     cmd += '-sigalg', 'SHA1withRSA'
110     cmd += jar_file, config['repo_keyalias']
111     if config['keystore'] == 'NONE':
112         cmd += config['smartcardoptions']
113     else:  # smardcards never use -keypass
114         cmd += '-keypass:env', 'FDROID_KEY_PASS'
115     env_vars = {'FDROID_KEY_STORE_PASS': config['keystorepass'],
116                 'FDROID_KEY_PASS': config['keypass']}
117     p = common.FDroidPopen(cmd, envs=env_vars)
118     if p.returncode != 0:
119         raise FDroidException("Failed to sign '{}'!".format(jar_file))
120
121
122 def store_stats_fdroid_signing_key_fingerprints(appids, indent=None):
123     """Store list of all signing-key fingerprints for given appids to HD.
124     This list will later on be needed by fdroid update.
125     """
126     if not os.path.exists('stats'):
127         os.makedirs('stats')
128     data = OrderedDict()
129     fps = read_fingerprints_from_keystore()
130     for appid in sorted(appids):
131         alias = key_alias(appid)
132         if alias in fps:
133             data[appid] = {'signer': fps[key_alias(appid)]}
134
135     jar_file = os.path.join('stats', 'publishsigkeys.jar')
136     with zipfile.ZipFile(jar_file, 'w', zipfile.ZIP_DEFLATED) as jar:
137         jar.writestr('publishsigkeys.json', json.dumps(data, indent=indent))
138     sign_sig_key_fingerprint_list(jar_file)
139
140
141 def main():
142
143     global config, options
144
145     # Parse command line...
146     parser = ArgumentParser(usage="%(prog)s [options] "
147                             "[APPID[:VERCODE] [APPID[:VERCODE] ...]]")
148     common.setup_global_opts(parser)
149     parser.add_argument("appid", nargs='*', help=_("applicationId with optional versionCode in the form APPID[:VERCODE]"))
150     metadata.add_metadata_arguments(parser)
151     options = parser.parse_args()
152     metadata.warnings_action = options.W
153
154     config = common.read_config(options)
155
156     if not ('jarsigner' in config and 'keytool' in config):
157         logging.critical(_('Java JDK not found! Install in standard location or set java_paths!'))
158         sys.exit(1)
159
160     common.assert_config_keystore(config)
161
162     log_dir = 'logs'
163     if not os.path.isdir(log_dir):
164         logging.info(_("Creating log directory"))
165         os.makedirs(log_dir)
166
167     tmp_dir = 'tmp'
168     if not os.path.isdir(tmp_dir):
169         logging.info(_("Creating temporary directory"))
170         os.makedirs(tmp_dir)
171
172     output_dir = 'repo'
173     if not os.path.isdir(output_dir):
174         logging.info(_("Creating output directory"))
175         os.makedirs(output_dir)
176
177     unsigned_dir = 'unsigned'
178     if not os.path.isdir(unsigned_dir):
179         logging.warning(_("No unsigned directory - nothing to do"))
180         sys.exit(1)
181
182     if not os.path.exists(config['keystore']):
183         logging.error("Config error - missing '{0}'".format(config['keystore']))
184         sys.exit(1)
185
186     # It was suggested at
187     #    https://dev.guardianproject.info/projects/bazaar/wiki/FDroid_Audit
188     # that a package could be crafted, such that it would use the same signing
189     # key as an existing app. While it may be theoretically possible for such a
190     # colliding package ID to be generated, it seems virtually impossible that
191     # the colliding ID would be something that would be a) a valid package ID,
192     # and b) a sane-looking ID that would make its way into the repo.
193     # Nonetheless, to be sure, before publishing we check that there are no
194     # collisions, and refuse to do any publishing if that's the case...
195     allapps = metadata.read_metadata()
196     vercodes = common.read_pkg_args(options.appid, True)
197     allaliases = []
198     for appid in allapps:
199         m = hashlib.md5()
200         m.update(appid.encode('utf-8'))
201         keyalias = m.hexdigest()[:8]
202         if keyalias in allaliases:
203             logging.error(_("There is a keyalias collision - publishing halted"))
204             sys.exit(1)
205         allaliases.append(keyalias)
206     logging.info(ngettext('{0} app, {1} key aliases',
207                           '{0} apps, {1} key aliases', len(allapps)).format(len(allapps), len(allaliases)))
208
209     # Process any APKs or ZIPs that are waiting to be signed...
210     for apkfile in sorted(glob.glob(os.path.join(unsigned_dir, '*.apk'))
211                           + glob.glob(os.path.join(unsigned_dir, '*.zip'))):
212
213         appid, vercode = common.publishednameinfo(apkfile)
214         apkfilename = os.path.basename(apkfile)
215         if vercodes and appid not in vercodes:
216             continue
217         if appid in vercodes and vercodes[appid]:
218             if vercode not in vercodes[appid]:
219                 continue
220         logging.info(_("Processing {apkfilename}").format(apkfilename=apkfile))
221
222         # There ought to be valid metadata for this app, otherwise why are we
223         # trying to publish it?
224         if appid not in allapps:
225             logging.error("Unexpected {0} found in unsigned directory"
226                           .format(apkfilename))
227             sys.exit(1)
228         app = allapps[appid]
229
230         if app.Binaries:
231
232             # It's an app where we build from source, and verify the apk
233             # contents against a developer's binary, and then publish their
234             # version if everything checks out.
235             # The binary should already have been retrieved during the build
236             # process.
237             srcapk = re.sub(r'.apk$', '.binary.apk', apkfile)
238
239             # Compare our unsigned one with the downloaded one...
240             compare_result = common.verify_apks(srcapk, apkfile, tmp_dir)
241             if compare_result:
242                 logging.error("...verification failed - publish skipped : "
243                               + compare_result)
244             else:
245
246                 # Success! So move the downloaded file to the repo, and remove
247                 # our built version.
248                 shutil.move(srcapk, os.path.join(output_dir, apkfilename))
249                 os.remove(apkfile)
250
251                 publish_source_tarball(apkfilename, unsigned_dir, output_dir)
252                 logging.info('Published ' + apkfilename)
253
254         elif apkfile.endswith('.zip'):
255
256             # OTA ZIPs built by fdroid do not need to be signed by jarsigner,
257             # just to be moved into place in the repo
258             shutil.move(apkfile, os.path.join(output_dir, apkfilename))
259             publish_source_tarball(apkfilename, unsigned_dir, output_dir)
260             logging.info('Published ' + apkfilename)
261
262         else:
263
264             # It's a 'normal' app, i.e. we sign and publish it...
265             skipsigning = False
266
267             # First we handle signatures for this app from local metadata
268             signingfiles = common.metadata_find_developer_signing_files(appid, vercode)
269             if signingfiles:
270                 # There's a signature of the app developer present in our
271                 # metadata. This means we're going to prepare both a locally
272                 # signed APK and a version signed with the developers key.
273
274                 signaturefile, signedfile, manifest = signingfiles
275
276                 with open(signaturefile, 'rb') as f:
277                     devfp = common.signer_fingerprint_short(f.read())
278                 devsigned = '{}_{}_{}.apk'.format(appid, vercode, devfp)
279                 devsignedtmp = os.path.join(tmp_dir, devsigned)
280                 shutil.copy(apkfile, devsignedtmp)
281
282                 common.apk_implant_signatures(devsignedtmp, signaturefile,
283                                               signedfile, manifest)
284                 if common.verify_apk_signature(devsignedtmp):
285                     shutil.move(devsignedtmp, os.path.join(output_dir, devsigned))
286                 else:
287                     os.remove(devsignedtmp)
288                     logging.error('...verification failed - skipping: %s', devsigned)
289                     skipsigning = True
290
291             # Now we sign with the F-Droid key.
292
293             # Figure out the key alias name we'll use. Only the first 8
294             # characters are significant, so we'll use the first 8 from
295             # the MD5 of the app's ID and hope there are no collisions.
296             # If a collision does occur later, we're going to have to
297             # come up with a new alogrithm, AND rename all existing keys
298             # in the keystore!
299             if not skipsigning:
300                 if appid in config['keyaliases']:
301                     # For this particular app, the key alias is overridden...
302                     keyalias = config['keyaliases'][appid]
303                     if keyalias.startswith('@'):
304                         m = hashlib.md5()
305                         m.update(keyalias[1:].encode('utf-8'))
306                         keyalias = m.hexdigest()[:8]
307                 else:
308                     m = hashlib.md5()
309                     m.update(appid.encode('utf-8'))
310                     keyalias = m.hexdigest()[:8]
311                 logging.info("Key alias: " + keyalias)
312
313                 # See if we already have a key for this application, and
314                 # if not generate one...
315                 env_vars = {
316                     'FDROID_KEY_STORE_PASS': config['keystorepass'],
317                     'FDROID_KEY_PASS': config['keypass'],
318                 }
319                 p = FDroidPopen([config['keytool'], '-list',
320                                  '-alias', keyalias, '-keystore', config['keystore'],
321                                  '-storepass:env', 'FDROID_KEY_STORE_PASS'], envs=env_vars)
322                 if p.returncode != 0:
323                     logging.info("Key does not exist - generating...")
324                     p = FDroidPopen([config['keytool'], '-genkey',
325                                      '-keystore', config['keystore'],
326                                      '-alias', keyalias,
327                                      '-keyalg', 'RSA', '-keysize', '2048',
328                                      '-validity', '10000',
329                                      '-storepass:env', 'FDROID_KEY_STORE_PASS',
330                                      '-keypass:env', 'FDROID_KEY_PASS',
331                                      '-dname', config['keydname']], envs=env_vars)
332                     if p.returncode != 0:
333                         raise BuildException("Failed to generate key", p.output)
334
335                 signed_apk_path = os.path.join(output_dir, apkfilename)
336                 if os.path.exists(signed_apk_path):
337                     raise BuildException("Refusing to sign '{0}' file exists in both "
338                                          "{1} and {2} folder.".format(apkfilename,
339                                                                       unsigned_dir,
340                                                                       output_dir))
341
342                 # TODO replace below with common.sign_apk() once it has proven stable
343                 # Sign the application...
344                 p = FDroidPopen([config['jarsigner'], '-keystore', config['keystore'],
345                                  '-storepass:env', 'FDROID_KEY_STORE_PASS',
346                                  '-keypass:env', 'FDROID_KEY_PASS', '-sigalg',
347                                  'SHA1withRSA', '-digestalg', 'SHA1',
348                                  apkfile, keyalias], envs=env_vars)
349                 if p.returncode != 0:
350                     raise BuildException(_("Failed to sign application"), p.output)
351
352                 # Zipalign it...
353                 p = SdkToolsPopen(['zipalign', '-v', '4', apkfile,
354                                    os.path.join(output_dir, apkfilename)])
355                 if p.returncode != 0:
356                     raise BuildException(_("Failed to align application"))
357                 os.remove(apkfile)
358
359                 publish_source_tarball(apkfilename, unsigned_dir, output_dir)
360                 logging.info('Published ' + apkfilename)
361
362     store_stats_fdroid_signing_key_fingerprints(allapps.keys())
363     logging.info('published list signing-key fingerprints')
364
365
366 if __name__ == "__main__":
367     main()