From 768a91370cbf2d79a495b5a2bc4ba6ab52ec0971 Mon Sep 17 00:00:00 2001 From: Marcus Hoffmann Date: Wed, 9 Sep 2020 12:06:21 +0200 Subject: [PATCH] publish: use apksigner for signing apks with targetSDK>=30 This makes apksigner a hard requirement of the signing procedure. We'll first try to find a globally installed version from PATH and if that's not available fall back to using a version from build-tools. Future TODO: always sign with apksigner, blocked on signature transplant support for apksigv2/v3 Closes fdroid/fdroidserver#634 Closes fdroid/fdroidserver#827 --- fdroidserver/common.py | 109 ++++++++++++-------- fdroidserver/nightly.py | 2 +- tests/common.TestCase | 127 ++++++++++++------------ tests/minimal_targetsdk_30_unsigned.apk | Bin 0 -> 2294 bytes 4 files changed, 130 insertions(+), 108 deletions(-) create mode 100644 tests/minimal_targetsdk_30_unsigned.apk diff --git a/fdroidserver/common.py b/fdroidserver/common.py index dd259963..0e744c69 100644 --- a/fdroidserver/common.py +++ b/fdroidserver/common.py @@ -69,9 +69,8 @@ from .asynchronousfilereader import AsynchronousFileReader # The path to this fdroidserver distribution FDROID_PATH = os.path.realpath(os.path.join(os.path.dirname(__file__), '..')) -# this is the build-tools version, aapt has a separate version that -# has to be manually set in test_aapt_version() -MINIMUM_AAPT_VERSION = '26.0.0' +# We need 26.0.0 for aapt but that doesn't ship with apksigner, so take the next higher version +MINIMUM_BUILD_TOOLS_VERSION = '26.0.1' VERCODE_OPERATION_RE = re.compile(r'^([ 0-9/*+-]|%c)+$') @@ -111,7 +110,7 @@ default_config = { 'r16b': None, }, 'cachedir': os.path.join(os.getenv('HOME'), '.cache', 'fdroidserver'), - 'build_tools': MINIMUM_AAPT_VERSION, + 'build_tools': MINIMUM_BUILD_TOOLS_VERSION, 'force_build_tools': False, 'java_paths': None, 'scan_binary': False, @@ -466,13 +465,13 @@ def test_aapt_version(aapt): # the Debian package has the version string like "v0.2-23.0.2" too_old = False if '.' in bugfix: - if LooseVersion(bugfix) < LooseVersion(MINIMUM_AAPT_VERSION): + if LooseVersion(bugfix) < LooseVersion(MINIMUM_BUILD_TOOLS_VERSION): too_old = True elif LooseVersion('.'.join((major, minor, bugfix))) < LooseVersion('0.2.4062713'): too_old = True if too_old: logging.warning(_("'{aapt}' is too old, fdroid requires build-tools-{version} or newer!") - .format(aapt=aapt, version=MINIMUM_AAPT_VERSION)) + .format(aapt=aapt, version=MINIMUM_BUILD_TOOLS_VERSION)) else: logging.warning(_('Unknown version of aapt, might cause problems: ') + output) @@ -2333,7 +2332,7 @@ def _get_androguard_APK(apkfile): try: from androguard.core.bytecodes.apk import APK except ImportError: - raise FDroidException("androguard library is not installed and aapt not present") + raise FDroidException("androguard library is not installed") return APK(apkfile) @@ -2510,21 +2509,6 @@ def get_native_code(apkfile): return sorted(list(archset)) -def get_minSdkVersion(apkfile): - """Extract the minimum supported Android SDK from an APK using androguard - - :param apkfile: path to an APK file. - :returns: the integer representing the SDK version - """ - - try: - apk = _get_androguard_APK(apkfile) - except FileNotFoundError: - raise FDroidException(_('Reading minSdkVersion failed: "{apkfilename}"') - .format(apkfilename=apkfile)) - return int(apk.get_min_sdk_version()) - - class PopenResult: def __init__(self): self.returncode = None @@ -2954,7 +2938,7 @@ def metadata_find_developer_signing_files(appid, vercode): return None -def apk_strip_signatures(signed_apk, strip_manifest=False): +def apk_strip_v1_signatures(signed_apk, strip_manifest=False): """Removes signatures from APK. :param signed_apk: path to apk file. @@ -3037,36 +3021,73 @@ def apk_extract_signatures(apkpath, outdir, manifest=True): def sign_apk(unsigned_path, signed_path, keyalias): """Sign and zipalign an unsigned APK, then save to a new file, deleting the unsigned - android-18 (4.3) finally added support for reasonable hash - algorithms, like SHA-256, before then, the only options were MD5 - and SHA1 :-/ This aims to use SHA-256 when the APK does not target + Use apksigner for making v2 and v3 signature for apks with targetSDK >=30 as + otherwise they won't be installable on Android 11/R. + + Otherwise use jarsigner for v1 only signatures until we have apksig v2/v3 + signature transplantig support. + + When using jarsigner we need to manually select the hash algorithm, + apksigner does this automatically. Apksigner also does the zipalign for us. + + SHA-256 support was added in android-18 (4.3), before then, the only options were MD5 + and SHA1. This aims to use SHA-256 when the APK does not target older Android versions, and is therefore safe to do so. https://issuetracker.google.com/issues/36956587 https://android-review.googlesource.com/c/platform/libcore/+/44491 """ - - if get_minSdkVersion(unsigned_path) < 18: - signature_algorithm = ['-sigalg', 'SHA1withRSA', '-digestalg', 'SHA1'] + apk = _get_androguard_APK(unsigned_path) + if int(apk.get_target_sdk_version()) >= 30: + if config['keystore'] == 'NONE': + replacements = {'-storetype': '--ks-type', + '-providerName': '--ks-provider-name', + '-providerClass': '--ks-provider-class', + '-providerArg': '--ks-provider-arg'} + signing_args = [replacements.get(n, n) for n in config['smartcardoptions']] + else: + signing_args = ['--key-pass', 'env:FDROID_KEY_PASS'] + if not set_command_in_config('apksigner'): + config['apksigner'] = find_sdk_tools_cmd('apksigner') + cmd = [config['apksigner'], 'sign', + '--ks', config['keystore'], + '--ks-pass', 'env:FDROID_KEY_STORE_PASS'] + cmd += signing_args + cmd += ['--ks-key-alias', keyalias, + '--in', unsigned_path, + '--out', signed_path] + p = FDroidPopen(cmd, envs={ + 'FDROID_KEY_STORE_PASS': config['keystorepass'], + 'FDROID_KEY_PASS': config.get('keypass', "")}) + if p.returncode != 0: + raise BuildException(_("Failed to sign application"), p.output) + os.remove(unsigned_path) else: - signature_algorithm = ['-sigalg', 'SHA256withRSA', '-digestalg', 'SHA-256'] - cmd = [config['jarsigner'], '-keystore', config['keystore'], - '-storepass:env', 'FDROID_KEY_STORE_PASS'] - if config['keystore'] == 'NONE': - cmd += config['smartcardoptions'] - else: - cmd += '-keypass:env', 'FDROID_KEY_PASS' - p = FDroidPopen(cmd + signature_algorithm + [unsigned_path, keyalias], - envs={ - 'FDROID_KEY_STORE_PASS': config['keystorepass'], - 'FDROID_KEY_PASS': config.get('keypass', "")}) - if p.returncode != 0: - raise BuildException(_("Failed to sign application"), p.output) + if int(apk.get_min_sdk_version()) < 18: + signature_algorithm = ['-sigalg', 'SHA1withRSA', '-digestalg', 'SHA1'] + else: + signature_algorithm = ['-sigalg', 'SHA256withRSA', '-digestalg', 'SHA-256'] + if config['keystore'] == 'NONE': + signing_args = config['smartcardoptions'] + else: + signing_args = ['-keypass:env', 'FDROID_KEY_PASS'] - _zipalign(unsigned_path, signed_path) - os.remove(unsigned_path) + cmd = [config['jarsigner'], '-keystore', config['keystore'], + '-storepass:env', 'FDROID_KEY_STORE_PASS'] + cmd += signing_args + cmd += signature_algorithm + cmd += [unsigned_path, keyalias] + print(cmd) + p = FDroidPopen(cmd, envs={ + 'FDROID_KEY_STORE_PASS': config['keystorepass'], + 'FDROID_KEY_PASS': config.get('keypass', "")}) + if p.returncode != 0: + raise BuildException(_("Failed to sign application"), p.output) + + _zipalign(unsigned_path, signed_path) + os.remove(unsigned_path) def verify_apks(signed_apk, unsigned_apk, tmp_dir): diff --git a/fdroidserver/nightly.py b/fdroidserver/nightly.py index cedf29a4..daca2082 100644 --- a/fdroidserver/nightly.py +++ b/fdroidserver/nightly.py @@ -268,7 +268,7 @@ Last updated: {date}'''.format(repo_git_base=repo_git_base, os.chmod(apkfilename, 0o644) logging.debug(_('Resigning {apkfilename} with provided debug.keystore') .format(apkfilename=os.path.basename(apkfilename))) - common.apk_strip_signatures(apkfilename, strip_manifest=True) + common.apk_strip_v1_signatures(apkfilename, strip_manifest=True) common.sign_apk(apkfilename, destapk, KEY_ALIAS) if options.verbose: diff --git a/tests/common.TestCase b/tests/common.TestCase index 78ab8da3..eb67836c 100755 --- a/tests/common.TestCase +++ b/tests/common.TestCase @@ -633,15 +633,13 @@ class CommonTest(unittest.TestCase): fdroidserver.common.apk_signer_fingerprint_short(apkfile)) def test_sign_apk(self): - try: - fdroidserver.common.find_sdk_tools_cmd('aapt') - fdroidserver.common.find_sdk_tools_cmd('zipalign') - except fdroidserver.exception.FDroidException: - print('\n\nSKIPPING test_sign_apk, zipalign is not installed!\n') - return - fdroidserver.common.config = None config = fdroidserver.common.read_config(fdroidserver.common.options) + try: + fdroidserver.common.find_sdk_tools_cmd('zipalign') + except fdroidserver.exception.FDroidException: + self.skipTest('SKIPPING test_sign_apk, zipalign not installed!') + config['jarsigner'] = fdroidserver.common.find_sdk_tools_cmd('jarsigner') config['keyalias'] = 'sova' config['keystorepass'] = 'r9aquRHYoI8+dYz6jKrLntQ5/NJNASFBacJh7Jv2BlI=' @@ -653,10 +651,10 @@ class CommonTest(unittest.TestCase): testdir = tempfile.mkdtemp(prefix=inspect.currentframe().f_code.co_name, dir=self.tmpdir) unsigned = os.path.join(testdir, 'urzip-release-unsigned.apk') signed = os.path.join(testdir, 'urzip-release.apk') + shutil.copy(os.path.join(self.basedir, 'urzip-release-unsigned.apk'), testdir) self.assertFalse(fdroidserver.common.verify_apk_signature(unsigned)) - shutil.copy(os.path.join(self.basedir, 'urzip-release-unsigned.apk'), testdir) fdroidserver.common.sign_apk(unsigned, signed, config['keyalias']) self.assertTrue(os.path.isfile(signed)) self.assertFalse(os.path.isfile(unsigned)) @@ -667,20 +665,37 @@ class CommonTest(unittest.TestCase): signed = os.path.join(testdir, 'duplicate.permisssions_9999999.apk') shutil.copy(os.path.join(self.basedir, 'repo', 'duplicate.permisssions_9999999.apk'), os.path.join(unsigned)) - fdroidserver.common.apk_strip_signatures(unsigned, strip_manifest=True) + fdroidserver.common.apk_strip_v1_signatures(unsigned, strip_manifest=True) fdroidserver.common.sign_apk(unsigned, signed, config['keyalias']) self.assertTrue(os.path.isfile(signed)) self.assertFalse(os.path.isfile(unsigned)) self.assertTrue(fdroidserver.common.verify_apk_signature(signed)) - try: - fdroidserver.common.find_sdk_tools_cmd('aapt') - self.assertEqual(18, fdroidserver.common.get_minSdkVersion(signed)) - except fdroidserver.exception.FDroidException: - print('\n\nSKIPPING test_sign_apk min SDK check, aapt is not installed!\n') - return + self.assertEqual('18', fdroidserver.common._get_androguard_APK(signed).get_min_sdk_version()) + + def test_sign_apk_targetsdk_30(self): + fdroidserver.common.config = None + config = fdroidserver.common.read_config(fdroidserver.common.options) + config['jarsigner'] = fdroidserver.common.find_sdk_tools_cmd('jarsigner') + config['keyalias'] = 'sova' + config['keystorepass'] = 'r9aquRHYoI8+dYz6jKrLntQ5/NJNASFBacJh7Jv2BlI=' + config['keypass'] = 'r9aquRHYoI8+dYz6jKrLntQ5/NJNASFBacJh7Jv2BlI=' + config['keystore'] = os.path.join(self.basedir, 'keystore.jks') + testdir = tempfile.mkdtemp(prefix=inspect.currentframe().f_code.co_name, dir=self.tmpdir) + + shutil.copy(os.path.join(self.basedir, 'minimal_targetsdk_30_unsigned.apk'), testdir) + unsigned = os.path.join(testdir, 'minimal_targetsdk_30_unsigned.apk') + signed = os.path.join(testdir, 'minimal_targetsdk_30.apk') + + self.assertFalse(fdroidserver.common.verify_apk_signature(unsigned)) + fdroidserver.common.sign_apk(unsigned, signed, config['keyalias']) + + self.assertTrue(os.path.isfile(signed)) + self.assertFalse(os.path.isfile(unsigned)) + self.assertTrue(fdroidserver.common.verify_apk_signature(signed)) + # verify it has a v2 signature + self.assertTrue(fdroidserver.common._get_androguard_APK(signed).is_signed_v2()) def test_get_apk_id(self): - config = dict() fdroidserver.common.fill_config_defaults(config) fdroidserver.common.config = config @@ -775,54 +790,40 @@ class CommonTest(unittest.TestCase): nc = fdroidserver.common.get_native_code(apkfilename) self.assertEqual(native_code, nc) - def test_get_minSdkVersion_androguard(self): - minSdkVersion = fdroidserver.common.get_minSdkVersion('bad-unicode-πÇÇ现代通用字-български-عربي1.apk') - self.assertEqual(4, minSdkVersion) - minSdkVersion = fdroidserver.common.get_minSdkVersion('org.bitbucket.tickytacky.mirrormirror_1.apk') - self.assertEqual(14, minSdkVersion) - minSdkVersion = fdroidserver.common.get_minSdkVersion('org.bitbucket.tickytacky.mirrormirror_2.apk') - self.assertEqual(14, minSdkVersion) - minSdkVersion = fdroidserver.common.get_minSdkVersion('org.bitbucket.tickytacky.mirrormirror_3.apk') - self.assertEqual(14, minSdkVersion) - minSdkVersion = fdroidserver.common.get_minSdkVersion('org.bitbucket.tickytacky.mirrormirror_4.apk') - self.assertEqual(14, minSdkVersion) - minSdkVersion = fdroidserver.common.get_minSdkVersion('org.dyndns.fules.ck_20.apk') - self.assertEqual(7, minSdkVersion) - minSdkVersion = fdroidserver.common.get_minSdkVersion('urzip.apk') - self.assertEqual(4, minSdkVersion) - minSdkVersion = fdroidserver.common.get_minSdkVersion('urzip-badcert.apk') - self.assertEqual(4, minSdkVersion) - minSdkVersion = fdroidserver.common.get_minSdkVersion('urzip-badsig.apk') - self.assertEqual(4, minSdkVersion) - minSdkVersion = fdroidserver.common.get_minSdkVersion('urzip-release.apk') - self.assertEqual(4, minSdkVersion) - minSdkVersion = fdroidserver.common.get_minSdkVersion('urzip-release-unsigned.apk') - self.assertEqual(4, minSdkVersion) - minSdkVersion = fdroidserver.common.get_minSdkVersion('repo/com.politedroid_3.apk') - self.assertEqual(3, minSdkVersion) - minSdkVersion = fdroidserver.common.get_minSdkVersion('repo/com.politedroid_4.apk') - self.assertEqual(3, minSdkVersion) - minSdkVersion = fdroidserver.common.get_minSdkVersion('repo/com.politedroid_5.apk') - self.assertEqual(3, minSdkVersion) - minSdkVersion = fdroidserver.common.get_minSdkVersion('repo/com.politedroid_6.apk') - self.assertEqual(14, minSdkVersion) - minSdkVersion = fdroidserver.common.get_minSdkVersion('repo/obb.main.oldversion_1444412523.apk') - self.assertEqual(4, minSdkVersion) - minSdkVersion = fdroidserver.common.get_minSdkVersion('repo/obb.mainpatch.current_1619_another-release-key.apk') - self.assertEqual(4, minSdkVersion) - minSdkVersion = fdroidserver.common.get_minSdkVersion('repo/obb.mainpatch.current_1619.apk') - self.assertEqual(4, minSdkVersion) - minSdkVersion = fdroidserver.common.get_minSdkVersion('repo/obb.main.twoversions_1101613.apk') - self.assertEqual(4, minSdkVersion) - minSdkVersion = fdroidserver.common.get_minSdkVersion('repo/obb.main.twoversions_1101615.apk') - self.assertEqual(4, minSdkVersion) - minSdkVersion = fdroidserver.common.get_minSdkVersion('repo/obb.main.twoversions_1101617.apk') - self.assertEqual(4, minSdkVersion) - minSdkVersion = fdroidserver.common.get_minSdkVersion('repo/urzip-; Рахма́, [rɐxˈmanʲɪnəf] سيرجي_رخمانينوف 谢·.apk') - self.assertEqual(4, minSdkVersion) + def test_get_sdkversions_androguard(self): + """This is a sanity test that androguard isn't broken""" + def get_minSdkVersion(apkfile): + apk = fdroidserver.common._get_androguard_APK(apkfile) + return int(apk.get_min_sdk_version()) - with self.assertRaises(FDroidException): - fdroidserver.common.get_minSdkVersion('nope') + def get_targetSdkVersion(apkfile): + apk = fdroidserver.common._get_androguard_APK(apkfile) + return int(apk.get_target_sdk_version()) + + self.assertEqual(4, get_minSdkVersion('bad-unicode-πÇÇ现代通用字-български-عربي1.apk')) + self.assertEqual(14, get_minSdkVersion('org.bitbucket.tickytacky.mirrormirror_1.apk')) + self.assertEqual(14, get_minSdkVersion('org.bitbucket.tickytacky.mirrormirror_2.apk')) + self.assertEqual(14, get_minSdkVersion('org.bitbucket.tickytacky.mirrormirror_3.apk')) + self.assertEqual(14, get_minSdkVersion('org.bitbucket.tickytacky.mirrormirror_4.apk')) + self.assertEqual(7, get_minSdkVersion('org.dyndns.fules.ck_20.apk')) + self.assertEqual(4, get_minSdkVersion('urzip.apk')) + self.assertEqual(4, get_minSdkVersion('urzip-badcert.apk')) + self.assertEqual(4, get_minSdkVersion('urzip-badsig.apk')) + self.assertEqual(4, get_minSdkVersion('urzip-release.apk')) + self.assertEqual(4, get_minSdkVersion('urzip-release-unsigned.apk')) + self.assertEqual(3, get_minSdkVersion('repo/com.politedroid_3.apk')) + self.assertEqual(3, get_minSdkVersion('repo/com.politedroid_4.apk')) + self.assertEqual(3, get_minSdkVersion('repo/com.politedroid_5.apk')) + self.assertEqual(14, get_minSdkVersion('repo/com.politedroid_6.apk')) + self.assertEqual(4, get_minSdkVersion('repo/obb.main.oldversion_1444412523.apk')) + self.assertEqual(4, get_minSdkVersion('repo/obb.mainpatch.current_1619_another-release-key.apk')) + self.assertEqual(4, get_minSdkVersion('repo/obb.mainpatch.current_1619.apk')) + self.assertEqual(4, get_minSdkVersion('repo/obb.main.twoversions_1101613.apk')) + self.assertEqual(4, get_minSdkVersion('repo/obb.main.twoversions_1101615.apk')) + self.assertEqual(4, get_minSdkVersion('repo/obb.main.twoversions_1101617.apk')) + self.assertEqual(4, get_minSdkVersion('repo/urzip-; Рахма́, [rɐxˈmanʲɪnəf] سيرجي_رخمانينوف 谢·.apk')) + + self.assertEqual(30, get_targetSdkVersion('minimal_targetsdk_30_unsigned.apk')) def test_apk_release_name(self): appid, vercode, sigfp = fdroidserver.common.apk_parse_release_filename('com.serwylo.lexica_905.apk') diff --git a/tests/minimal_targetsdk_30_unsigned.apk b/tests/minimal_targetsdk_30_unsigned.apk new file mode 100644 index 0000000000000000000000000000000000000000..c39aec6d18dc7410d8620b147a8a4f8e5006d86d GIT binary patch literal 2294 zcmWIWW@cdk0uB(tVE6Q8eHznsMyw4^(ep-`C9jLHZA4 zz2jZa*$+hiFg$-CzJuxf#k`C)`@WSP2#*S0G*RuB%lzcpHs7fS=N$0VoqNvkeFcBw zzq2(i#v6UyEeluazxkxPOIk`mb=IMtle+7ws`^(YEjk(d-{bbHm%M?!8ME^z-)UJj z<<`WXH=AaD>bc3^@p<<={n8_P>fghnZ6tpxmk6&iDtSJ&D)Er+W$S0NgJ$2X+n1cf zC0y1XHgWcM^=HBSd2z)l!Ty$p4qNl%y!>;L?B6b&>$7F``e$bgI<@~7r+s5j)-sy? z$@Ig#-(Sxjtx~LapPPMsPBq)E1g=YM#gY?VU$|(kAG(ElX|G`{bcnynm?{yI6So~V;TF~b6k60?OgGw_Tk3pPyTrf0l?T6 z0mdsNw#BEsgyWfsO#zHrtjH2e~ zCf7UHPEE^|TxwNpRc(7>_Qaq$Vs&3;@H-|31{+oe25z7ul5-M^i&KmBQc^2gr+DXI zb`WSt|Lbx4i=$N3q!l(zSB10YOludJ#g!qqpkqt#uScpOxo>k{UE8?WqPMBxlG4|v zE7IcsSnU}UTdE#5v{)3rn5g~8Az$I3#k~mvp@*(LpE-Bt-ML%S+|x_{|5&R1;o;|t zukY%wESHbpv;TvF#@@T~m%mk=tNm}O$~=W}!M++xjz25C8I&K~te)uHqHy=e$AvOZ zor)72H5Xk8ycTHbASuAmHqqFFXJ^+Pw`UVop9p^9s%)-t+NZ?wyiY;Ps4>PpZ35FL z0Zk>QC1**I&Z^@pH}Z33Vw9mW?bs-#mQ&xsiuydXn?6z|YsX3w|ra zS99#yEc8zOj^B@czP}avo69=8v?@Qo{dfPzouiHto*wtC=Kd>{=_z8)+*6@Gt z=cAXizCD}wfAepFLMK`F`%@AnuVxz<#A-Y#e);m~ojY&5^qBu$3DTP%8M!C?gpxqMn`c;?j&Yb&l@^0{reCh69azR4|{ zj~{uW(;d3w{~yDDl3`I#8F`$WD$DQh@LZczlULfDd9^;5`zqJF=`9oY#A+V9{-rDT zMd8_w?Wyxx3fhb7t~|WVW^L=KaQedbIdkI+{<~M{P0sC2Tb{nj;hnSoRg-5H=DtxS z3uaF{tu3Z&I6>-uT};4Nu@F<{U0P>St|UJAyp3h2+gV+<@@?CVJCApzO+VFk)1q=s z%4?sQAwO7BeIl3p`9!DRTDCc~Xv#^`EUO$JP06sHX}qaLNj@t#b~OhVUUMyt@l~0! zTtPsHvz)uBAgGI{{ZNyK(sIuo-es#5oo2m%*H@yjOH|3d<+=5xg)hQpbljS#(eBsk z^jt-VY0=`ZGsB(V#r|vyw0Bv5uYTbflkTbZt$l?Iv$o!zbatN7man24*KYT?@Jiok zOH~hlRBfA!?cKMgNh1I5aVx%B`~I7J=kXnu%-8Sk)^Oju_pY@jo6T?DE46pt-Sadr zJXHNa9+WqM=^qL>HgJ{c0J8-M^D(e76r~pDmlh=hGe%-jakA-6CI%J;CI%j`Jg~rK zU}TVC&;Zj=1Au}GP$r0CVoJ^gbD0zve*op`ft-AXB8GGZJ%%)f6d)^~ArnaJF(d=& zkpUDy0S5#DP#VMrg`)wO2Js+30mcB*Yz)N=CBV?iWXJ=C9Ynnjn1(6>nxz0`f+!A- z#DaqOyu{p8P#g$=%UO_hFe@3ELKrfje2`cK5IX=dr2taGgG+j5kXZrVjKGux0uUMW znjRttBeB%`AOj@8&W9L)UU|T@GBCUY`U6{4f^GtO$&WCh80b`_dH~^eh!WJifzZal a&;_hSP;*LvH!B-R1q%@V0MakmK|BC2q2Xcx literal 0 HcmV?d00001