From 902c1b72ae6a236bd560e22320761a22d481bc77 Mon Sep 17 00:00:00 2001 From: Arne Date: Tue, 9 Apr 2024 19:59:57 +0200 Subject: [PATCH] update MemorizingTrustManager + bundle letsencrypt root certificates + code cleanup --- build.gradle | 2 +- .../services/MemorizingTrustManager.java | 275 ++++++++++-------- .../siacs/conversations/utils/Resolver.java | 7 - src/main/res/raw/letsencrypt.bks | Bin 0 -> 2043 bytes 4 files changed, 160 insertions(+), 124 deletions(-) create mode 100644 src/main/res/raw/letsencrypt.bks diff --git a/build.gradle b/build.gradle index 2268cd4a7..97e5bc524 100644 --- a/build.gradle +++ b/build.gradle @@ -70,7 +70,7 @@ dependencies { implementation 'org.bouncycastle:bcmail-jdk15on:1.64' implementation 'org.gnu.inet:libidn:1.15' implementation 'com.google.zxing:core:3.5.0' - implementation 'org.minidns:minidns-hla:1.0.4' + implementation 'org.minidns:minidns-hla:1.0.5' implementation 'me.leolin:ShortcutBadger:1.1.22@aar' implementation 'org.whispersystems:signal-protocol-android:2.6.2' implementation 'jetty:javax.servlet:5.1.12' diff --git a/src/main/java/eu/siacs/conversations/services/MemorizingTrustManager.java b/src/main/java/eu/siacs/conversations/services/MemorizingTrustManager.java index b58f02cdf..da02cebd8 100644 --- a/src/main/java/eu/siacs/conversations/services/MemorizingTrustManager.java +++ b/src/main/java/eu/siacs/conversations/services/MemorizingTrustManager.java @@ -33,6 +33,7 @@ import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; import android.net.Uri; +import android.os.Build; import android.os.Handler; import android.preference.PreferenceManager; import android.util.Base64; @@ -44,9 +45,21 @@ import androidx.core.util.Consumer; import com.google.common.base.Charsets; import com.google.common.base.Joiner; +import com.google.common.base.Preconditions; import com.google.common.io.ByteStreams; import com.google.common.io.CharStreams; +import eu.siacs.conversations.Config; +import eu.siacs.conversations.R; +import eu.siacs.conversations.crypto.BundledTrustManager; +import eu.siacs.conversations.crypto.CombiningTrustManager; +import eu.siacs.conversations.crypto.TrustManagers; +import eu.siacs.conversations.crypto.XmppDomainVerifier; +import eu.siacs.conversations.entities.MTMDecision; +import eu.siacs.conversations.http.HttpConnectionManager; +import eu.siacs.conversations.persistance.FileBackend; +import eu.siacs.conversations.ui.MemorizingActivity; + import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; @@ -81,39 +94,40 @@ import javax.net.ssl.TrustManager; import javax.net.ssl.TrustManagerFactory; import javax.net.ssl.X509TrustManager; -import eu.siacs.conversations.Config; -import eu.siacs.conversations.R; -import eu.siacs.conversations.crypto.XmppDomainVerifier; -import eu.siacs.conversations.entities.MTMDecision; -import eu.siacs.conversations.http.HttpConnectionManager; -import eu.siacs.conversations.persistance.FileBackend; -import eu.siacs.conversations.ui.MemorizingActivity; - /** - * A X509 trust manager implementation which asks the user about invalid - * certificates and memorizes their decision. - *

- * The certificate validity is checked using the system default X509 - * TrustManager, creating a query Dialog if the check fails. - *

- * WARNING: This only works if a dedicated thread is used for - * opening sockets! + * A X509 trust manager implementation which asks the user about invalid certificates and memorizes + * their decision. + * + *

The certificate validity is checked using the system default X509 TrustManager, creating a + * query Dialog if the check fails. + * + *

WARNING: This only works if a dedicated thread is used for opening sockets! */ public class MemorizingTrustManager { - private static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd", Locale.US); + private static final SimpleDateFormat DATE_FORMAT = + new SimpleDateFormat("yyyy-MM-dd", Locale.US); - final static String DECISION_INTENT = "de.duenndns.ssl.DECISION"; - public final static String DECISION_INTENT_ID = DECISION_INTENT + ".decisionId"; - public final static String DECISION_INTENT_CERT = DECISION_INTENT + ".cert"; - public final static String DECISION_TITLE_ID = DECISION_INTENT + ".titleId"; - final static String NO_TRUST_ANCHOR = "Trust anchor for certification path not found."; - private static final Pattern PATTERN_IPV4 = Pattern.compile("\\A(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)(\\.(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)){3}\\z"); - private static final Pattern PATTERN_IPV6_HEX4DECCOMPRESSED = Pattern.compile("\\A((?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)?) ::((?:[0-9A-Fa-f]{1,4}:)*)(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)(\\.(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)){3}\\z"); - private static final Pattern PATTERN_IPV6_6HEX4DEC = Pattern.compile("\\A((?:[0-9A-Fa-f]{1,4}:){6,6})(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)(\\.(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)){3}\\z"); - private static final Pattern PATTERN_IPV6_HEXCOMPRESSED = Pattern.compile("\\A((?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)?)::((?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)?)\\z"); - private static final Pattern PATTERN_IPV6 = Pattern.compile("\\A(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}\\z"); - private final static Logger LOGGER = Logger.getLogger(MemorizingTrustManager.class.getName()); + static final String DECISION_INTENT = "de.duenndns.ssl.DECISION"; + public static final String DECISION_INTENT_ID = DECISION_INTENT + ".decisionId"; + public static final String DECISION_INTENT_CERT = DECISION_INTENT + ".cert"; + public static final String DECISION_TITLE_ID = DECISION_INTENT + ".titleId"; + static final String NO_TRUST_ANCHOR = "Trust anchor for certification path not found."; + private static final Pattern PATTERN_IPV4 = + Pattern.compile( + "\\A(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)(\\.(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)){3}\\z"); + private static final Pattern PATTERN_IPV6_HEX4DECCOMPRESSED = + Pattern.compile( + "\\A((?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)?) ::((?:[0-9A-Fa-f]{1,4}:)*)(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)(\\.(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)){3}\\z"); + private static final Pattern PATTERN_IPV6_6HEX4DEC = + Pattern.compile( + "\\A((?:[0-9A-Fa-f]{1,4}:){6,6})(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)(\\.(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)){3}\\z"); + private static final Pattern PATTERN_IPV6_HEXCOMPRESSED = + Pattern.compile( + "\\A((?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)?)::((?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)?)\\z"); + private static final Pattern PATTERN_IPV6 = + Pattern.compile("\\A(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}\\z"); + private static final Logger LOGGER = Logger.getLogger(MemorizingTrustManager.class.getName()); static String KEYSTORE_DIR = "KeyStore"; static String KEYSTORE_FILE = "KeyStore.bks"; private static int decisionId = 0; @@ -129,21 +143,22 @@ public class MemorizingTrustManager { private final DaneVerifier daneVerifier; /** - * Creates an instance of the MemorizingTrustManager class that falls back to a custom TrustManager. - *

- * You need to supply the application context. This has to be one of: - * - Application - * - Activity - * - Service - *

- * The context is used for file management, to display the dialog / - * notification and for obtaining translated strings. + * Creates an instance of the MemorizingTrustManager class that falls back to a custom + * TrustManager. * - * @param m Context for the application. - * @param defaultTrustManager Delegate trust management to this TM. If null, the user must accept every certificate. + *

You need to supply the application context. This has to be one of: - Application - + * Activity - Service + * + *

The context is used for file management, to display the dialog / notification and for + * obtaining translated strings. + * + * @param context Context for the application. + * @param defaultTrustManager Delegate trust management to this TM. If null, the user must + * accept every certificate. */ - public MemorizingTrustManager(Context m, X509TrustManager defaultTrustManager) { - init(m); + public MemorizingTrustManager( + final Context context, final X509TrustManager defaultTrustManager) { + init(context); this.appTrustManager = getTrustManager(appKeyStore); this.defaultTrustManager = defaultTrustManager; this.daneVerifier = new DaneVerifier(); @@ -151,34 +166,55 @@ public class MemorizingTrustManager { /** * Creates an instance of the MemorizingTrustManager class using the system X509TrustManager. - *

- * You need to supply the application context. This has to be one of: - * - Application - * - Activity - * - Service - *

- * The context is used for file management, to display the dialog / - * notification and for obtaining translated strings. * - * @param m Context for the application. + *

You need to supply the application context. This has to be one of: - Application - + * Activity - Service + * + *

The context is used for file management, to display the dialog / notification and for + * obtaining translated strings. + * + * @param context Context for the application. */ - public MemorizingTrustManager(Context m) { - init(m); + public MemorizingTrustManager(final Context context) { + init(context); this.appTrustManager = getTrustManager(appKeyStore); - this.defaultTrustManager = getTrustManager(null); this.daneVerifier = new DaneVerifier(); + try { + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.N) { + this.defaultTrustManager = defaultWithBundledLetsEncrypt(context); + } else { + this.defaultTrustManager = TrustManagers.createDefaultTrustManager(); + } + } catch (final NoSuchAlgorithmException + | KeyStoreException + | CertificateException + | IOException e) { + throw new RuntimeException(e); + } + } + + private static X509TrustManager defaultWithBundledLetsEncrypt(final Context context) + throws NoSuchAlgorithmException, KeyStoreException, CertificateException, IOException { + final BundledTrustManager bundleTrustManager = + BundledTrustManager.builder() + .loadKeyStore( + context.getResources().openRawResource(R.raw.letsencrypt), + "letsencrypt") + .build(); + return CombiningTrustManager.combineWithDefault(bundleTrustManager); } private static boolean isIp(final String server) { - return server != null && ( - PATTERN_IPV4.matcher(server).matches() - || PATTERN_IPV6.matcher(server).matches() - || PATTERN_IPV6_6HEX4DEC.matcher(server).matches() - || PATTERN_IPV6_HEX4DECCOMPRESSED.matcher(server).matches() - || PATTERN_IPV6_HEXCOMPRESSED.matcher(server).matches()); + return server != null + && (PATTERN_IPV4.matcher(server).matches() + || PATTERN_IPV6.matcher(server).matches() + || PATTERN_IPV6_6HEX4DEC.matcher(server).matches() + || PATTERN_IPV6_HEX4DECCOMPRESSED.matcher(server).matches() + || PATTERN_IPV6_HEXCOMPRESSED.matcher(server).matches()); } - private static String getBase64Hash(X509Certificate certificate, String digest) throws CertificateEncodingException { + private static String getBase64Hash(X509Certificate certificate, String digest) + throws CertificateEncodingException { MessageDigest md; try { md = MessageDigest.getInstance(digest); @@ -193,8 +229,7 @@ public class MemorizingTrustManager { StringBuffer si = new StringBuffer(); for (int i = 0; i < data.length; i++) { si.append(String.format("%02x", data[i])); - if (i < data.length - 1) - si.append(":"); + if (i < data.length - 1) si.append(":"); } return si.toString(); } @@ -225,20 +260,22 @@ public class MemorizingTrustManager { } } - void init(final Context m) { - master = m; - masterHandler = new Handler(m.getMainLooper()); - notificationManager = (NotificationManager) master.getSystemService(Context.NOTIFICATION_SERVICE); + void init(final Context context) { + master = context; + masterHandler = new Handler(context.getMainLooper()); + notificationManager = + (NotificationManager) master.getSystemService(Context.NOTIFICATION_SERVICE); Application app; - if (m instanceof Application) { - app = (Application) m; - } else if (m instanceof Service) { - app = ((Service) m).getApplication(); - } else if (m instanceof AppCompatActivity) { - app = ((AppCompatActivity) m).getApplication(); + if (context instanceof Application) { + app = (Application) context; + } else if (context instanceof Service) { + app = ((Service) context).getApplication(); + } else if (context instanceof AppCompatActivity) { + app = ((AppCompatActivity) context).getApplication(); } else - throw new ClassCastException("MemorizingTrustManager context must be either Activity or Service!"); + throw new ClassCastException( + "MemorizingTrustManager context must be either Activity or Service!"); File dir = app.getDir(KEYSTORE_DIR, Context.MODE_PRIVATE); keyStoreFile = new File(dir + File.separator + KEYSTORE_FILE); @@ -263,12 +300,9 @@ public class MemorizingTrustManager { /** * Removes the given certificate from MTMs key store. * - *

- * WARNING: this does not immediately invalidate the certificate. It is - * well possible that (a) data is transmitted over still existing connections or - * (b) new connections are created using TLS renegotiation, without a new cert - * check. - *

+ *

WARNING: this does not immediately invalidate the certificate. It is well possible + * that (a) data is transmitted over still existing connections or (b) new connections are + * created using TLS renegotiation, without a new cert check. * * @param alias the certificate's alias as returned by {@link #getCertificates()}. * @throws KeyStoreException if the certificate could not be deleted. @@ -278,20 +312,21 @@ public class MemorizingTrustManager { keyStoreUpdated(); } - X509TrustManager getTrustManager(KeyStore ks) { + private X509TrustManager getTrustManager(final KeyStore keyStore) { + Preconditions.checkNotNull(keyStore); try { TrustManagerFactory tmf = TrustManagerFactory.getInstance("X509"); - tmf.init(ks); + tmf.init(keyStore); for (TrustManager t : tmf.getTrustManagers()) { if (t instanceof X509TrustManager) { return (X509TrustManager) t; } } - } catch (Exception e) { + } catch (final Exception e) { // Here, we are covering up errors. It might be more useful // however to throw them out of the constructor so the // embedding app knows something went wrong. - LOGGER.log(Level.SEVERE, "getTrustManager(" + ks + ")", e); + LOGGER.log(Level.SEVERE, "getTrustManager(" + keyStore + ")", e); } return null; } @@ -364,10 +399,18 @@ public class MemorizingTrustManager { } } - - private void checkCertTrusted(X509Certificate[] chain, String authType, String domain, boolean isServer, boolean interactive, String verifiedHostname, int port, Consumer daneCb) + private void checkCertTrusted( + X509Certificate[] chain, + String authType, + String domain, + boolean isServer, + boolean interactive, + String verifiedHostname, + int port, + Consumer daneCb) throws CertificateException { - LOGGER.log(Level.FINE, "checkCertTrusted(" + chain + ", " + authType + ", " + isServer + ")"); + LOGGER.log( + Level.FINE, "checkCertTrusted(" + chain + ", " + authType + ", " + isServer + ")"); try { LOGGER.log(Level.FINE, "checkCertTrusted: trying appTrustManager"); if (isServer) { @@ -390,17 +433,15 @@ public class MemorizingTrustManager { } catch (final CertificateException ae) { LOGGER.log(Level.FINER, "checkCertTrusted: appTrustManager failed", ae); if (isCertKnown(chain[0])) { - LOGGER.log(Level.INFO, "checkCertTrusted: accepting cert already stored in keystore"); + LOGGER.log( + Level.INFO, "checkCertTrusted: accepting cert already stored in keystore"); return; } try { - if (defaultTrustManager == null) - throw ae; + if (defaultTrustManager == null) throw ae; LOGGER.log(Level.FINE, "checkCertTrusted: trying defaultTrustManager"); - if (isServer) - defaultTrustManager.checkServerTrusted(chain, authType); - else - defaultTrustManager.checkClientTrusted(chain, authType); + if (isServer) defaultTrustManager.checkServerTrusted(chain, authType); + else defaultTrustManager.checkClientTrusted(chain, authType); } catch (final CertificateException e) { if (interactive) { interactCert(chain, authType, e); @@ -412,7 +453,9 @@ public class MemorizingTrustManager { } private X509Certificate[] getAcceptedIssuers() { - return defaultTrustManager == null ? new X509Certificate[0] : defaultTrustManager.getAcceptedIssuers(); + return defaultTrustManager == null + ? new X509Certificate[0] + : defaultTrustManager.getAcceptedIssuers(); } private int createDecisionId(MTMDecision d) { @@ -425,7 +468,8 @@ public class MemorizingTrustManager { return myId; } - private void certDetails(final StringBuffer si, final X509Certificate c, final boolean showValidFor) { + private void certDetails( + final StringBuffer si, final X509Certificate c, final boolean showValidFor) { si.append("\n"); if (showValidFor) { @@ -462,8 +506,7 @@ public class MemorizingTrustManager { // not found", so we use string comparison. if (NO_TRUST_ANCHOR.equals(e.getMessage())) { si.append(master.getString(R.string.mtm_trust_anchor)); - } else - si.append(e.getLocalizedMessage()); + } else si.append(e.getLocalizedMessage()); si.append("\n"); } si.append("\n"); @@ -471,7 +514,7 @@ public class MemorizingTrustManager { si.append("\n\n"); si.append(master.getString(R.string.mtm_cert_details)); si.append('\n'); - for(int i = 0; i < chain.length; ++i) { + for (int i = 0; i < chain.length; ++i) { certDetails(si, chain[i], i == 0); } return si.toString(); @@ -491,24 +534,25 @@ public class MemorizingTrustManager { MTMDecision choice = new MTMDecision(); final int myId = createDecisionId(choice); - masterHandler.post(new Runnable() { - public void run() { - Intent ni = new Intent(master, MemorizingActivity.class); - ni.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - ni.setData(Uri.parse(MemorizingTrustManager.class.getName() + "/" + myId)); - ni.putExtra(DECISION_INTENT_ID, myId); - ni.putExtra(DECISION_INTENT_CERT, message); - ni.putExtra(DECISION_TITLE_ID, titleId); + masterHandler.post( + new Runnable() { + public void run() { + Intent ni = new Intent(master, MemorizingActivity.class); + ni.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + ni.setData(Uri.parse(MemorizingTrustManager.class.getName() + "/" + myId)); + ni.putExtra(DECISION_INTENT_ID, myId); + ni.putExtra(DECISION_INTENT_CERT, message); + ni.putExtra(DECISION_TITLE_ID, titleId); - // we try to directly start the activity and fall back to - // making a notification - try { - getUI().startActivity(ni); - } catch (Exception e) { - LOGGER.log(Level.FINE, "startActivity(MemorizingActivity)", e); - } - } - }); + // we try to directly start the activity and fall back to + // making a notification + try { + getUI().startActivity(ni); + } catch (Exception e) { + LOGGER.log(Level.FINE, "startActivity(MemorizingActivity)", e); + } + } + }); LOGGER.log(Level.FINE, "openDecisions: " + openDecisions + ", waiting on " + myId); try { @@ -579,7 +623,6 @@ public class MemorizingTrustManager { public X509Certificate[] getAcceptedIssuers() { return MemorizingTrustManager.this.getAcceptedIssuers(); } - } private class InteractiveMemorizingTrustManager implements X509TrustManager { diff --git a/src/main/java/eu/siacs/conversations/utils/Resolver.java b/src/main/java/eu/siacs/conversations/utils/Resolver.java index 7f34c18fc..c4ba33bb0 100644 --- a/src/main/java/eu/siacs/conversations/utils/Resolver.java +++ b/src/main/java/eu/siacs/conversations/utils/Resolver.java @@ -19,11 +19,6 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Map; -import java.util.concurrent.Callable; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.TimeUnit; import org.minidns.AbstractDnsClient; import org.minidns.DnsCache; @@ -31,7 +26,6 @@ import org.minidns.DnsClient; import org.minidns.cache.LruCache; import org.minidns.dnsmessage.Question; import org.minidns.dnsname.DnsName; -import org.minidns.dnssec.DnssecResultNotAuthenticException; import org.minidns.dnssec.DnssecValidationFailedException; import org.minidns.dnsserverlookup.AndroidUsingExec; import org.minidns.hla.DnssecResolverApi; @@ -46,7 +40,6 @@ import org.minidns.record.InternetAddressRR; import org.minidns.record.Record; import org.minidns.record.SRV; import eu.siacs.conversations.Config; -import eu.siacs.conversations.R; import eu.siacs.conversations.persistance.FileBackend; import eu.siacs.conversations.services.XmppConnectionService; import eu.siacs.conversations.xmpp.Jid; diff --git a/src/main/res/raw/letsencrypt.bks b/src/main/res/raw/letsencrypt.bks new file mode 100644 index 0000000000000000000000000000000000000000..32fd83a6d275cbefdfdc8ee7c210baacb167d775 GIT binary patch literal 2043 zcmb`GdpOi-8^?c#IWW%SxJkq0Z5+aH8aYHlIV*=*p;3~wXm*A%Ll)CCyvlLxZY*Yz z)6%fObO9ohruWEQ5JLwA+=lu~JM$RPNy>~APh7ljK0wRxr15g-sD1b^fMo60i zr>^h8U`MbR5o~>GL^y{P#ZJ`skBEy1V}(cRyRn#@7_ug!zG;Qk6+Ni_Zu}5-~CR=UlBVWH@Jfe!h{@o7*V$i1NPyi7=0igaA4wvy# zaZB=^@3cG}Li!TwCcZ-}>0w*MTGAs~8e`M3$M3YcjM74BBs&*0OnAB5ef;S|X?EZDS(jPX`Cm=~js%bh2uVsj z-04x5L7?|UwfM5O?=3^5i7)! zOhzmzHZpuDU&^mvkPi{RYzjmJFdtF@WIKd|_z(!x&l|?Q*RXykEHw=@+IqfS+&XEK zm(8%B4ZL(_jni4Nz7ow?1$>OgCL+`62Yzm84F9ZwKYfcZxJVWna0e*%ZSE`MiIZ1E z8~C>d#a9J9sr%urYT>F??^~OZZyXUuOc@5KEg4QDLMYwFp?U&>t_wCeqn+6{o zGu3>+BQTrCZh30aZ^TdG$)v-f3GhWSm@oyT5$A0E48KFy>Ro}0=Du}q}LzqZRe zkvKb6Rcx{2Zolf3^28I>TrFwqTJ2ib41_HdSjpwjOpqm_$Jhytqq4A&Y zElPaVllTA3zH*;EmrDTU`jJPpSgU4iTL*%KxVQ4{3V%yEo>@_8%zxN+GZm#8(W%6B zow=9`^@nDkWbL^(PV!@H+&H;H+3y3vAOQZq%bwUAJe$-q^Bj!h$zAB)612J?WPC}P zY;|Omfj|Hu=<iCgTHtCdy)X^|049QJiY}RB%)qyJUP4gqbadbTUoH2X-Nwj{RH-Wdrh?CVde!&hPPRo zJ%935K_I$q=k>KatzC!g`0eCwVqDx$1D(7oTU%AicD}cLS$b1Ip{hT^0)BZ8@z3?y${{0{PRuQMAG^} z3N>X$@8I{t<&9m=%vnd$?vP5OR&35(4kd4HdzsYt*F?JOaI@x>0tQ#8Sg=RrZ?hQ&!`lI>8t+MGU4V@hHyc9|0HcJbUk-lV8x$~CEOFlrxQ9P ZfJc-#)6K97d1r4suDCHp%Lk8D{s%LBDIfp< literal 0 HcmV?d00001