mirror of
https://gitlab.com/fdroid/fdroidserver.git
synced 2024-11-18 20:50:10 +01:00
update: AllowedAPKSigningKeys metadata to enforce APK signers
This field lets you specify which signing certificates should be trusted for APKs in a binary repo.
This commit is contained in:
parent
074ea8cae3
commit
3b95d3de64
@ -47,6 +47,7 @@ metadata_v0:
|
|||||||
- cd fdroiddata
|
- cd fdroiddata
|
||||||
- ../tests/dump_internal_metadata_format.py
|
- ../tests/dump_internal_metadata_format.py
|
||||||
- sed -i
|
- sed -i
|
||||||
|
-e '/AllowedAPKSigningKeys:/d'
|
||||||
-e '/Liberapay:/d'
|
-e '/Liberapay:/d'
|
||||||
-e '/OpenCollective/d'
|
-e '/OpenCollective/d'
|
||||||
metadata/dump_*/*.yaml
|
metadata/dump_*/*.yaml
|
||||||
|
@ -90,6 +90,8 @@ yaml_app_field_order = [
|
|||||||
'\n',
|
'\n',
|
||||||
'Builds',
|
'Builds',
|
||||||
'\n',
|
'\n',
|
||||||
|
'AllowedAPKSigningKeys',
|
||||||
|
'\n',
|
||||||
'MaintainerNotes',
|
'MaintainerNotes',
|
||||||
'\n',
|
'\n',
|
||||||
'ArchivePolicy',
|
'ArchivePolicy',
|
||||||
@ -145,6 +147,7 @@ class App(dict):
|
|||||||
self.RepoType = ''
|
self.RepoType = ''
|
||||||
self.Repo = ''
|
self.Repo = ''
|
||||||
self.Binaries = None
|
self.Binaries = None
|
||||||
|
self.AllowedAPKSigningKeys = []
|
||||||
self.MaintainerNotes = ''
|
self.MaintainerNotes = ''
|
||||||
self.ArchivePolicy = None
|
self.ArchivePolicy = None
|
||||||
self.AutoUpdateMode = 'None'
|
self.AutoUpdateMode = 'None'
|
||||||
@ -199,6 +202,7 @@ fieldtypes = {
|
|||||||
'MaintainerNotes': TYPE_MULTILINE,
|
'MaintainerNotes': TYPE_MULTILINE,
|
||||||
'Categories': TYPE_LIST,
|
'Categories': TYPE_LIST,
|
||||||
'AntiFeatures': TYPE_LIST,
|
'AntiFeatures': TYPE_LIST,
|
||||||
|
'AllowedAPKSigningKeys': TYPE_LIST,
|
||||||
'Build': TYPE_BUILD,
|
'Build': TYPE_BUILD,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -433,6 +437,10 @@ valuetypes = {
|
|||||||
r'^http[s]?://',
|
r'^http[s]?://',
|
||||||
["Binaries"]),
|
["Binaries"]),
|
||||||
|
|
||||||
|
FieldValidator("AllowedAPKSigningKeys",
|
||||||
|
r'^[a-fA-F0-9]{64}$',
|
||||||
|
["AllowedAPKSigningKeys"]),
|
||||||
|
|
||||||
FieldValidator("Archive Policy",
|
FieldValidator("Archive Policy",
|
||||||
r'^[0-9]+ versions$',
|
r'^[0-9]+ versions$',
|
||||||
["ArchivePolicy"]),
|
["ArchivePolicy"]),
|
||||||
@ -927,6 +935,14 @@ def write_yaml(mf, app):
|
|||||||
cm.update({field: _builds_to_yaml(app)})
|
cm.update({field: _builds_to_yaml(app)})
|
||||||
elif field == 'CurrentVersionCode':
|
elif field == 'CurrentVersionCode':
|
||||||
cm.update({field: _field_to_yaml(TYPE_INT, getattr(app, field))})
|
cm.update({field: _field_to_yaml(TYPE_INT, getattr(app, field))})
|
||||||
|
elif field == 'AllowedAPKSigningKeys':
|
||||||
|
value = getattr(app, field)
|
||||||
|
if value:
|
||||||
|
value = [str(i).lower() for i in value]
|
||||||
|
if len(value) == 1:
|
||||||
|
cm.update({field: _field_to_yaml(TYPE_STRING, value[0])})
|
||||||
|
else:
|
||||||
|
cm.update({field: _field_to_yaml(TYPE_LIST, value)})
|
||||||
else:
|
else:
|
||||||
cm.update({field: _field_to_yaml(fieldtype(field), getattr(app, field))})
|
cm.update({field: _field_to_yaml(fieldtype(field), getattr(app, field))})
|
||||||
|
|
||||||
|
@ -36,6 +36,8 @@ import yaml
|
|||||||
import copy
|
import copy
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from argparse import ArgumentParser
|
from argparse import ArgumentParser
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from yaml import CSafeLoader as SafeLoader
|
from yaml import CSafeLoader as SafeLoader
|
||||||
except ImportError:
|
except ImportError:
|
||||||
@ -51,6 +53,7 @@ from . import metadata
|
|||||||
from .exception import BuildException, FDroidException
|
from .exception import BuildException, FDroidException
|
||||||
|
|
||||||
from PIL import Image, PngImagePlugin
|
from PIL import Image, PngImagePlugin
|
||||||
|
|
||||||
if hasattr(Image, 'DecompressionBombWarning'):
|
if hasattr(Image, 'DecompressionBombWarning'):
|
||||||
warnings.simplefilter('error', Image.DecompressionBombWarning)
|
warnings.simplefilter('error', Image.DecompressionBombWarning)
|
||||||
Image.MAX_IMAGE_PIXELS = 0xffffff # 4096x4096
|
Image.MAX_IMAGE_PIXELS = 0xffffff # 4096x4096
|
||||||
@ -688,7 +691,6 @@ def insert_obbs(repodir, apps, apks):
|
|||||||
list of current, valid apps
|
list of current, valid apps
|
||||||
apks
|
apks
|
||||||
current information on all APKs
|
current information on all APKs
|
||||||
|
|
||||||
"""
|
"""
|
||||||
def obbWarnDelete(f, msg):
|
def obbWarnDelete(f, msg):
|
||||||
logging.warning(msg + ' ' + f)
|
logging.warning(msg + ' ' + f)
|
||||||
@ -2223,6 +2225,31 @@ def get_apps_with_packages(apps, apks):
|
|||||||
return appsWithPackages
|
return appsWithPackages
|
||||||
|
|
||||||
|
|
||||||
|
def get_apks_without_allowed_signatures(app, apk):
|
||||||
|
"""Check the APK or package has been signed by one of the allowed signing certificates.
|
||||||
|
|
||||||
|
The fingerprint of the signing certificate is the standard X.509
|
||||||
|
SHA-256 fingerprint as a hex string. It can be fetched from an
|
||||||
|
APK using:
|
||||||
|
|
||||||
|
apksigner verify --print-certs my.apk | grep SHA-256
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
app
|
||||||
|
The app which declares the AllowedSigningKey
|
||||||
|
apk
|
||||||
|
The APK to check
|
||||||
|
"""
|
||||||
|
if not app or not apk:
|
||||||
|
return
|
||||||
|
allowed_signer_keys = app.get('AllowedAPKSigningKeys', [])
|
||||||
|
if not allowed_signer_keys:
|
||||||
|
return
|
||||||
|
if apk['signer'] not in allowed_signer_keys:
|
||||||
|
return apk['apkName']
|
||||||
|
|
||||||
|
|
||||||
def prepare_apps(apps, apks, repodir):
|
def prepare_apps(apps, apks, repodir):
|
||||||
"""Encapsulate all necessary preparation steps before we can build an index out of apps and apks.
|
"""Encapsulate all necessary preparation steps before we can build an index out of apps and apks.
|
||||||
|
|
||||||
@ -2372,6 +2399,23 @@ def main():
|
|||||||
appid_has_apks = set()
|
appid_has_apks = set()
|
||||||
appid_has_repo_files = set()
|
appid_has_repo_files = set()
|
||||||
for apk in apks:
|
for apk in apks:
|
||||||
|
to_remove = get_apks_without_allowed_signatures(apps.get(apk['packageName']), apk)
|
||||||
|
if to_remove:
|
||||||
|
apks.remove(apk)
|
||||||
|
logging.warning(
|
||||||
|
_('"{path}" is signed by a key that is not allowed:').format(
|
||||||
|
path=to_remove
|
||||||
|
)
|
||||||
|
+ '\n'
|
||||||
|
+ apk['signer']
|
||||||
|
)
|
||||||
|
if options.delete_unknown:
|
||||||
|
for d in repodirs:
|
||||||
|
path = Path(d) / to_remove
|
||||||
|
if path.exists():
|
||||||
|
logging.warning(_('Removing {path}"').format(path=path))
|
||||||
|
path.unlink()
|
||||||
|
|
||||||
if apk['apkName'].endswith('.apk'):
|
if apk['apkName'].endswith('.apk'):
|
||||||
appid_has_apks.add(apk['packageName'])
|
appid_has_apks.add(apk['packageName'])
|
||||||
else:
|
else:
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
AllowedAPKSigningKeys: []
|
||||||
AntiFeatures: []
|
AntiFeatures: []
|
||||||
ArchivePolicy: 4 versions
|
ArchivePolicy: 4 versions
|
||||||
AuthorEmail: null
|
AuthorEmail: null
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
AllowedAPKSigningKeys: []
|
||||||
AntiFeatures: []
|
AntiFeatures: []
|
||||||
ArchivePolicy: null
|
ArchivePolicy: null
|
||||||
AuthorEmail: null
|
AuthorEmail: null
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
AllowedAPKSigningKeys: []
|
||||||
AntiFeatures: []
|
AntiFeatures: []
|
||||||
ArchivePolicy: null
|
ArchivePolicy: null
|
||||||
AuthorEmail: null
|
AuthorEmail: null
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
AllowedAPKSigningKeys: []
|
||||||
AntiFeatures: []
|
AntiFeatures: []
|
||||||
ArchivePolicy: 9 versions
|
ArchivePolicy: 9 versions
|
||||||
AuthorEmail: null
|
AuthorEmail: null
|
||||||
|
@ -62,6 +62,7 @@ DONATION_FIELDS = ('Donate', 'Liberapay', 'OpenCollective')
|
|||||||
class Options:
|
class Options:
|
||||||
allow_disabled_algorithms = False
|
allow_disabled_algorithms = False
|
||||||
clean = False
|
clean = False
|
||||||
|
nosign = False
|
||||||
pretty = True
|
pretty = True
|
||||||
rename_apks = False
|
rename_apks = False
|
||||||
|
|
||||||
@ -1026,6 +1027,104 @@ class UpdateTest(unittest.TestCase):
|
|||||||
self.assertIsNone(apk)
|
self.assertIsNone(apk)
|
||||||
self.assertFalse(cachechanged)
|
self.assertFalse(cachechanged)
|
||||||
|
|
||||||
|
def test_get_apks_without_allowed_signatures(self):
|
||||||
|
"""Test when no AllowedAPKSigningKeys is specified"""
|
||||||
|
config = dict()
|
||||||
|
fdroidserver.common.fill_config_defaults(config)
|
||||||
|
fdroidserver.common.config = config
|
||||||
|
fdroidserver.common.options = Options
|
||||||
|
|
||||||
|
app = fdroidserver.metadata.App()
|
||||||
|
knownapks = fdroidserver.common.KnownApks()
|
||||||
|
apkfile = 'v1.v2.sig_1020.apk'
|
||||||
|
(skip, apk, cachechanged) = fdroidserver.update.process_apk(
|
||||||
|
{}, apkfile, 'repo', knownapks, False
|
||||||
|
)
|
||||||
|
|
||||||
|
r = fdroidserver.update.get_apks_without_allowed_signatures(app, apk)
|
||||||
|
self.assertIsNone(r)
|
||||||
|
|
||||||
|
def test_get_apks_without_allowed_signatures_allowed(self):
|
||||||
|
"""Test when the APK matches the specified AllowedAPKSigningKeys"""
|
||||||
|
config = dict()
|
||||||
|
fdroidserver.common.fill_config_defaults(config)
|
||||||
|
fdroidserver.common.config = config
|
||||||
|
fdroidserver.common.options = Options
|
||||||
|
fdroidserver.update.options = fdroidserver.common.options
|
||||||
|
|
||||||
|
app = fdroidserver.metadata.App(
|
||||||
|
{
|
||||||
|
'AllowedAPKSigningKeys': '32a23624c201b949f085996ba5ed53d40f703aca4989476949cae891022e0ed6'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
knownapks = fdroidserver.common.KnownApks()
|
||||||
|
apkfile = 'v1.v2.sig_1020.apk'
|
||||||
|
(skip, apk, cachechanged) = fdroidserver.update.process_apk(
|
||||||
|
{}, apkfile, 'repo', knownapks, False
|
||||||
|
)
|
||||||
|
|
||||||
|
r = fdroidserver.update.get_apks_without_allowed_signatures(app, apk)
|
||||||
|
self.assertIsNone(r)
|
||||||
|
|
||||||
|
def test_get_apks_without_allowed_signatures_blocked(self):
|
||||||
|
"""Test when the APK does not match any specified AllowedAPKSigningKeys"""
|
||||||
|
config = dict()
|
||||||
|
fdroidserver.common.fill_config_defaults(config)
|
||||||
|
fdroidserver.common.config = config
|
||||||
|
fdroidserver.common.options = Options
|
||||||
|
fdroidserver.update.options = fdroidserver.common.options
|
||||||
|
|
||||||
|
app = fdroidserver.metadata.App(
|
||||||
|
{
|
||||||
|
'AllowedAPKSigningKeys': 'fa4edeadfa4edeadfa4edeadfa4edeadfa4edeadfa4edeadfa4edeadfa4edead'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
knownapks = fdroidserver.common.KnownApks()
|
||||||
|
apkfile = 'v1.v2.sig_1020.apk'
|
||||||
|
(skip, apk, cachechanged) = fdroidserver.update.process_apk(
|
||||||
|
{}, apkfile, 'repo', knownapks, False
|
||||||
|
)
|
||||||
|
|
||||||
|
r = fdroidserver.update.get_apks_without_allowed_signatures(app, apk)
|
||||||
|
self.assertEqual(apkfile, r)
|
||||||
|
|
||||||
|
def test_update_with_AllowedAPKSigningKeys(self):
|
||||||
|
"""Test that APKs without allowed signatures get deleted."""
|
||||||
|
testdir = tempfile.mkdtemp(
|
||||||
|
prefix=inspect.currentframe().f_code.co_name, dir=self.tmpdir
|
||||||
|
)
|
||||||
|
os.chdir(testdir)
|
||||||
|
os.mkdir('repo')
|
||||||
|
testapk = os.path.join('repo', 'com.politedroid_6.apk')
|
||||||
|
shutil.copy(os.path.join(self.basedir, testapk), testapk)
|
||||||
|
os.mkdir('metadata')
|
||||||
|
metadatafile = os.path.join('metadata', 'com.politedroid.yml')
|
||||||
|
shutil.copy(os.path.join(self.basedir, metadatafile), metadatafile)
|
||||||
|
with open(metadatafile, 'a') as fp:
|
||||||
|
fp.write(
|
||||||
|
'\n\nAllowedAPKSigningKeys: 32a23624c201b949f085996ba5ed53d40f703aca4989476949cae891022e0ed6\n'
|
||||||
|
)
|
||||||
|
|
||||||
|
fdroidserver.common.options = Options
|
||||||
|
config = fdroidserver.common.read_config(fdroidserver.common.options)
|
||||||
|
config['repo_keyalias'] = 'sova'
|
||||||
|
config['keystorepass'] = 'r9aquRHYoI8+dYz6jKrLntQ5/NJNASFBacJh7Jv2BlI='
|
||||||
|
config['keypass'] = 'r9aquRHYoI8+dYz6jKrLntQ5/NJNASFBacJh7Jv2BlI='
|
||||||
|
config['keystore'] = os.path.join(self.basedir, 'keystore.jks')
|
||||||
|
|
||||||
|
self.assertTrue(os.path.exists(testapk))
|
||||||
|
with mock.patch('sys.argv', ['fdroid update', '--delete-unknown']):
|
||||||
|
fdroidserver.update.main()
|
||||||
|
self.assertTrue(os.path.exists(testapk))
|
||||||
|
|
||||||
|
with open(metadatafile, 'a') as fp:
|
||||||
|
fp.write(
|
||||||
|
'\n\nAllowedAPKSigningKeys: fa4edeadfa4edeadfa4edeadfa4edeadfa4edeadfa4edeadfa4edeadfa4edead\n'
|
||||||
|
)
|
||||||
|
with mock.patch('sys.argv', ['fdroid update', '--delete-unknown']):
|
||||||
|
fdroidserver.update.main()
|
||||||
|
self.assertFalse(os.path.exists(testapk))
|
||||||
|
|
||||||
def test_translate_per_build_anti_features(self):
|
def test_translate_per_build_anti_features(self):
|
||||||
os.chdir(os.path.join(localmodule, 'tests'))
|
os.chdir(os.path.join(localmodule, 'tests'))
|
||||||
testdir = tempfile.mkdtemp(
|
testdir = tempfile.mkdtemp(
|
||||||
|
Loading…
Reference in New Issue
Block a user