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>
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.
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.
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/>.
26 from argparse import ArgumentParser
27 from collections import OrderedDict
29 from gettext import ngettext
35 from . import metadata
36 from .common import FDroidPopen, SdkToolsPopen
37 from .exception import BuildException, FDroidException
43 def publish_source_tarball(apkfilename, unsigned_dir, output_dir):
44 """Move the source tarball into the output directory..."""
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)
52 logging.debug('...no source tarball for %s', apkfilename)
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.
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('@'):
64 m.update(keyalias[1:].encode('utf-8'))
65 keyalias = m.hexdigest()[:8]
69 m.update(appid.encode('utf-8'))
70 return m.hexdigest()[:8]
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.
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)
85 raise FDroidException('could not read keysotre {}'.format(config['keystore']))
87 realias = re.compile('Alias name: (?P<alias>.+)\n')
88 resha256 = re.compile('\s+SHA256: (?P<sha256>[:0-9A-F]{95})\n')
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
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.
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))
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.
126 if not os.path.exists('stats'):
129 fps = read_fingerprints_from_keystore()
130 for appid in sorted(appids):
131 alias = key_alias(appid)
133 data[appid] = {'signer': fps[key_alias(appid)]}
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)
143 global config, options
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
154 config = common.read_config(options)
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!'))
160 common.assert_config_keystore(config)
163 if not os.path.isdir(log_dir):
164 logging.info(_("Creating log directory"))
168 if not os.path.isdir(tmp_dir):
169 logging.info(_("Creating temporary directory"))
173 if not os.path.isdir(output_dir):
174 logging.info(_("Creating output directory"))
175 os.makedirs(output_dir)
177 unsigned_dir = 'unsigned'
178 if not os.path.isdir(unsigned_dir):
179 logging.warning(_("No unsigned directory - nothing to do"))
182 if not os.path.exists(config['keystore']):
183 logging.error("Config error - missing '{0}'".format(config['keystore']))
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)
198 for appid in allapps:
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"))
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)))
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'))):
213 appid, vercode = common.publishednameinfo(apkfile)
214 apkfilename = os.path.basename(apkfile)
215 if vercodes and appid not in vercodes:
217 if appid in vercodes and vercodes[appid]:
218 if vercode not in vercodes[appid]:
220 logging.info(_("Processing {apkfilename}").format(apkfilename=apkfile))
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))
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
237 srcapk = re.sub(r'.apk$', '.binary.apk', apkfile)
239 # Compare our unsigned one with the downloaded one...
240 compare_result = common.verify_apks(srcapk, apkfile, tmp_dir)
242 logging.error("...verification failed - publish skipped : "
246 # Success! So move the downloaded file to the repo, and remove
248 shutil.move(srcapk, os.path.join(output_dir, apkfilename))
251 publish_source_tarball(apkfilename, unsigned_dir, output_dir)
252 logging.info('Published ' + apkfilename)
254 elif apkfile.endswith('.zip'):
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)
264 # It's a 'normal' app, i.e. we sign and publish it...
267 # First we handle signatures for this app from local metadata
268 signingfiles = common.metadata_find_developer_signing_files(appid, vercode)
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.
274 signaturefile, signedfile, manifest = signingfiles
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)
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))
287 os.remove(devsignedtmp)
288 logging.error('...verification failed - skipping: %s', devsigned)
291 # Now we sign with the F-Droid key.
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
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('@'):
305 m.update(keyalias[1:].encode('utf-8'))
306 keyalias = m.hexdigest()[:8]
309 m.update(appid.encode('utf-8'))
310 keyalias = m.hexdigest()[:8]
311 logging.info("Key alias: " + keyalias)
313 # See if we already have a key for this application, and
314 # if not generate one...
316 'FDROID_KEY_STORE_PASS': config['keystorepass'],
317 'FDROID_KEY_PASS': config['keypass'],
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'],
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)
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,
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)
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"))
359 publish_source_tarball(apkfilename, unsigned_dir, output_dir)
360 logging.info('Published ' + apkfilename)
362 store_stats_fdroid_signing_key_fingerprints(allapps.keys())
363 logging.info('published list signing-key fingerprints')
366 if __name__ == "__main__":