From e68279eb9b34cd7367b06020b3128cc978bd1718 Mon Sep 17 00:00:00 2001
From: akwizgran <michael@briarproject.org>
Date: Mon, 3 Feb 2025 13:48:17 +0000
Subject: [PATCH 1/9] Remove Fastly and Azure fronts.

There are no longer any fronts that work on Android < 7.1.
---
 README.md                                     |  2 +-
 .../briarproject/moattest/MainActivity.java   |  7 +----
 .../briarproject/moattest/MainViewModel.java  | 16 ++++------
 app/src/main/res/layout/activity_main.xml     |  7 -----
 app/src/main/res/values/strings.xml           |  1 -
 .../org/briarproject/moat/MoatApiTest.java    | 31 +++----------------
 6 files changed, 12 insertions(+), 52 deletions(-)

diff --git a/README.md b/README.md
index b3482af..6ba78ec 100644
--- a/README.md
+++ b/README.md
@@ -16,7 +16,7 @@ If you need to support Android 4, you can use an obsolete version of OkHttp (3.1
 
     implementation 'com.squareup.okhttp3:okhttp:3.12.13'
 
-On Android versions earlier than 7.1 (API 25), you may need to use [Conscrypt](https://github.com/google/conscrypt/) for TLS connections to Fastly:
+On old versions of Android, you may need to use [Conscrypt](https://github.com/google/conscrypt/) to get a modern TLS stack:
 
     implementation 'org.conscrypt:conscrypt-android:2.5.2'
 
diff --git a/app/src/main/java/org/briarproject/moattest/MainActivity.java b/app/src/main/java/org/briarproject/moattest/MainActivity.java
index 605ffdf..eda17b6 100644
--- a/app/src/main/java/org/briarproject/moattest/MainActivity.java
+++ b/app/src/main/java/org/briarproject/moattest/MainActivity.java
@@ -26,14 +26,9 @@ public class MainActivity extends AppCompatActivity {
 		setContentView(R.layout.activity_main);
 
 		EditText countryCode = findViewById(R.id.countryCode);
-		SwitchCompat azure = findViewById(R.id.azure);
 		Button request = findViewById(R.id.request);
 		TextView response = findViewById(R.id.response);
 
-		// Before Android 7.1 we should use the Azure domain front, as the Fastly front
-		// uses an expired root certificate that older Android devices can't verify
-		azure.setChecked(SDK_INT < 25);
-
 		viewModel.getResponse().observe(this, text -> {
 			request.setEnabled(true);
 			response.setText(text);
@@ -42,7 +37,7 @@ public class MainActivity extends AppCompatActivity {
 		request.setOnClickListener(v -> {
 			request.setEnabled(false);
 			response.setText(null);
-			viewModel.sendRequest(countryCode.getText().toString(), azure.isChecked());
+			viewModel.sendRequest(countryCode.getText().toString());
 		});
 	}
 }
\ No newline at end of file
diff --git a/app/src/main/java/org/briarproject/moattest/MainViewModel.java b/app/src/main/java/org/briarproject/moattest/MainViewModel.java
index 34459a9..d8cbbb3 100644
--- a/app/src/main/java/org/briarproject/moattest/MainViewModel.java
+++ b/app/src/main/java/org/briarproject/moattest/MainViewModel.java
@@ -29,10 +29,8 @@ class MainViewModel extends AndroidViewModel {
 	private static final String LYREBIRD_LIB_NAME = "liblyrebird.so";
 	private static final String STATE_DIR_NAME = "state";
 
-	private static final String FASTLY_URL = "https://moat.torproject.org.global.prod.fastly.net/";
-	private static final String FASTLY_FRONT = "cdn.yelp.com";
-	private static final String AZURE_URL = "https://onion.azureedge.net/";
-	private static final String AZURE_FRONT = "ajax.aspnetcdn.com";
+	private static final String CDN77_URL = "https://1723079976.rsc.cdn77.org/";
+	private static final String CDN77_FRONT = "www.phpmyadmin.net";
 
 	private final ExecutorService backgroundExecutor;
 	private final MutableLiveData<String> response = new MutableLiveData<>();
@@ -49,20 +47,18 @@ class MainViewModel extends AndroidViewModel {
 	}
 
 	@UiThread
-	void sendRequest(String countryCode, boolean azure) {
-		backgroundExecutor.execute(() -> sendRequestInBackground(countryCode, azure));
+	void sendRequest(String countryCode) {
+		backgroundExecutor.execute(() -> sendRequestInBackground(countryCode));
 	}
 
 	@WorkerThread
-	private void sendRequestInBackground(String countryCode, boolean azure) {
+	private void sendRequestInBackground(String countryCode) {
 		countryCode = countryCode.toLowerCase(ROOT);
 		Application app = getApplication();
 		String nativeLibDir = app.getApplicationInfo().nativeLibraryDir;
 		File lyrebirdLib = new File(nativeLibDir, LYREBIRD_LIB_NAME);
 		File stateDir = app.getDir(STATE_DIR_NAME, MODE_PRIVATE);
-		String url = azure ? AZURE_URL : FASTLY_URL;
-		String front = azure ? AZURE_FRONT : FASTLY_FRONT;
-		MoatApi moat = new MoatApi(lyrebirdLib, stateDir, url, front);
+		MoatApi moat = new MoatApi(lyrebirdLib, stateDir, CDN77_URL, CDN77_FRONT);
 		try {
 			List<Bridges> bridges = moat.getWithCountry(countryCode);
 			StringBuilder sb = new StringBuilder();
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
index 1719b86..305e33a 100644
--- a/app/src/main/res/layout/activity_main.xml
+++ b/app/src/main/res/layout/activity_main.xml
@@ -16,13 +16,6 @@
 		android:importantForAutofill="no"
 		android:inputType="textNoSuggestions" />
 
-	<androidx.appcompat.widget.SwitchCompat
-		android:id="@+id/azure"
-		android:layout_width="match_parent"
-		android:layout_height="wrap_content"
-		android:layout_marginTop="8dp"
-		android:text="@string/azure" />
-
 	<Button
 		android:id="@+id/request"
 		android:layout_width="match_parent"
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index b9e9787..cb3bed0 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -6,5 +6,4 @@
 	<string name="bridge_source">Source: %1s</string>
 	<string name="bridge_type">Type: %1s</string>
 	<string name="bridge_line">Bridge: %1s</string>
-	<string name="azure">Use Azure domain front</string>
 </resources>
\ No newline at end of file
diff --git a/lib/src/test/java/org/briarproject/moat/MoatApiTest.java b/lib/src/test/java/org/briarproject/moat/MoatApiTest.java
index 4fa868e..a7297fb 100644
--- a/lib/src/test/java/org/briarproject/moat/MoatApiTest.java
+++ b/lib/src/test/java/org/briarproject/moat/MoatApiTest.java
@@ -5,10 +5,10 @@ import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.io.TempDir;
 
 import java.io.File;
-import java.io.FileOutputStream;
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.OutputStream;
+import java.nio.file.Files;
 import java.util.List;
 
 import static java.util.Collections.emptyList;
@@ -18,11 +18,6 @@ import static org.junit.jupiter.api.Assertions.fail;
 
 public class MoatApiTest {
 
-	private static final String FASTLY_URL = "https://moat.torproject.org.global.prod.fastly.net/";
-	private static final String[] FASTLY_FRONTS = new String[]{"cdn.yelp.com", "www.shazam.com",
-			"www.cosmopolitan.com", "www.esquire.com"};
-	private static final String AZURE_URL = "https://onion.azureedge.net/";
-	private static final String[] AZURE_FRONTS = new String[]{"ajax.aspnetcdn.com"};
 	private static final String CDN77_URL = "https://1723079976.rsc.cdn77.org/";
 	private static final String[] CDN77_FRONTS = new String[]{"www.phpmyadmin.net"};
 
@@ -36,21 +31,12 @@ public class MoatApiTest {
 		extractLyrebirdExecutable();
 	}
 
-	@Test
-	public void testCnFastly() throws Exception {
-		testCn(FASTLY_URL, FASTLY_FRONTS);
-	}
-
-	@Test
-	public void testCnAzure() throws Exception {
-		testCn(AZURE_URL, AZURE_FRONTS);
-	}
-
 	@Test
 	public void testCnCdn77() throws Exception {
 		testCn(CDN77_URL, CDN77_FRONTS);
 	}
 
+	@SuppressWarnings("SameParameterValue")
 	private void testCn(String url, String[] fronts) throws Exception {
 		for (String front : fronts) {
 			MoatApi moatApi = new MoatApi(lyrebirdExecutable, tempFolder, url, front);
@@ -65,21 +51,12 @@ public class MoatApiTest {
 		}
 	}
 
-	@Test
-	public void testUsFastly() throws Exception {
-		testUs(FASTLY_URL, FASTLY_FRONTS);
-	}
-
-	@Test
-	public void testUsAzure() throws Exception {
-		testUs(AZURE_URL, AZURE_FRONTS);
-	}
-
 	@Test
 	public void testUsCdn77() throws Exception {
 		testUs(CDN77_URL, CDN77_FRONTS);
 	}
 
+	@SuppressWarnings("SameParameterValue")
 	private void testUs(String url, String[] fronts) throws Exception {
 		for (String front : fronts) {
 			MoatApi moatApi = new MoatApi(lyrebirdExecutable, tempFolder, url, front);
@@ -88,7 +65,7 @@ public class MoatApiTest {
 	}
 
 	private void extractLyrebirdExecutable() throws IOException {
-		OutputStream out = new FileOutputStream(lyrebirdExecutable);
+		OutputStream out = Files.newOutputStream(lyrebirdExecutable.toPath());
 		InputStream in = getResourceInputStream("x86_64/lyrebird");
 		byte[] buf = new byte[4096];
 		while (true) {
-- 
GitLab


From e48c36343fddb7de1294d9b456f265cb1675f992 Mon Sep 17 00:00:00 2001
From: akwizgran <michael@briarproject.org>
Date: Mon, 3 Feb 2025 14:00:13 +0000
Subject: [PATCH 2/9] Upgrade dependencies.

---
 app/build.gradle | 8 ++++----
 1 file changed, 4 insertions(+), 4 deletions(-)

diff --git a/app/build.gradle b/app/build.gradle
index 5739374..daa5ac7 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -42,7 +42,7 @@ android {
                 minSdk 21
             }
             dependencies {
-                implementation 'com.squareup.okhttp3:okhttp:4.10.0'
+                implementation 'com.squareup.okhttp3:okhttp:4.12.0'
             }
         }
     }
@@ -59,12 +59,12 @@ configurations {
 dependencies {
     implementation project(':lib')
     implementation 'androidx.appcompat:appcompat:1.5.1'
-    implementation 'com.google.dagger:hilt-android:2.44'
-    implementation 'org.conscrypt:conscrypt-android:2.5.2'
+    implementation 'com.google.dagger:hilt-android:2.48'
+    implementation 'org.conscrypt:conscrypt-android:2.5.3'
 
     tor 'org.briarproject:lyrebird-android:0.5.0-2'
 
-    annotationProcessor 'com.google.dagger:hilt-compiler:2.44'
+    annotationProcessor 'com.google.dagger:hilt-compiler:2.48'
 }
 
 def torLibsDir = 'src/main/jniLibs'
-- 
GitLab


From 1e38884d5a7bda02571a6135e043e67358080975 Mon Sep 17 00:00:00 2001
From: akwizgran <michael@briarproject.org>
Date: Mon, 3 Feb 2025 15:59:17 +0000
Subject: [PATCH 3/9] Update unpacking of transport binaries.

---
 app/build.gradle | 40 +++++++---------------------------------
 1 file changed, 7 insertions(+), 33 deletions(-)

diff --git a/app/build.gradle b/app/build.gradle
index daa5ac7..f99082a 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -70,49 +70,23 @@ dependencies {
 def torLibsDir = 'src/main/jniLibs'
 
 task cleanTorBinaries {
+    outputs.dir torLibsDir
     doLast {
-        delete fileTree(torLibsDir) { include '**/*.so' }
+        delete fileTree(torLibsDir)
     }
 }
 
 clean.dependsOn cleanTorBinaries
 
 task unpackTorBinaries {
+    outputs.dir torLibsDir
     doLast {
-        configurations.tor.each { outer ->
-            zipTree(outer).each { inner ->
-                if (inner.name.endsWith('_arm_pie.zip')) {
-                    copy {
-                        from zipTree(inner)
-                        into torLibsDir
-                        rename '(.*)', 'armeabi-v7a/lib$1.so'
-                    }
-                } else if (inner.name.endsWith('_arm64_pie.zip')) {
-                    copy {
-                        from zipTree(inner)
-                        into torLibsDir
-                        rename '(.*)', 'arm64-v8a/lib$1.so'
-                    }
-                } else if (inner.name.endsWith('_x86_pie.zip')) {
-                    copy {
-                        from zipTree(inner)
-                        into torLibsDir
-                        rename '(.*)', 'x86/lib$1.so'
-                    }
-                } else if (inner.name.endsWith('_x86_64_pie.zip')) {
-                    copy {
-                        from zipTree(inner)
-                        into torLibsDir
-                        rename '(.*)', 'x86_64/lib$1.so'
-                    }
-                }
-            }
+        copy {
+            from configurations.tor.collect { zipTree(it) }
+            into torLibsDir
         }
     }
     dependsOn cleanTorBinaries
 }
 
-tasks.withType(MergeResources) {
-    inputs.dir torLibsDir
-    dependsOn unpackTorBinaries
-}
+preBuild.dependsOn unpackTorBinaries
-- 
GitLab


From 68b3af5dd4a06c912cc1fd861ade6af081143538 Mon Sep 17 00:00:00 2001
From: akwizgran <michael@briarproject.org>
Date: Mon, 3 Feb 2025 15:49:15 +0000
Subject: [PATCH 4/9] Validate certificate chain using ISRG root certificate.

---
 .../java/org/briarproject/moat/MoatApi.java   | 152 ++++++++++++++++++
 lib/src/main/resources/isrg-root-x1.der       | Bin 0 -> 1391 bytes
 2 files changed, 152 insertions(+)
 create mode 100644 lib/src/main/resources/isrg-root-x1.der

diff --git a/lib/src/main/java/org/briarproject/moat/MoatApi.java b/lib/src/main/java/org/briarproject/moat/MoatApi.java
index 522d157..ab0ad91 100644
--- a/lib/src/main/java/org/briarproject/moat/MoatApi.java
+++ b/lib/src/main/java/org/briarproject/moat/MoatApi.java
@@ -9,15 +9,34 @@ import org.briarproject.socks.SocksSocketFactory;
 
 import java.io.File;
 import java.io.IOException;
+import java.io.InputStream;
 import java.net.InetSocketAddress;
+import java.security.InvalidKeyException;
+import java.security.KeyManagementException;
+import java.security.KeyStore;
+import java.security.KeyStoreException;
+import java.security.NoSuchAlgorithmException;
+import java.security.NoSuchProviderException;
+import java.security.SecureRandom;
+import java.security.SignatureException;
+import java.security.cert.CertificateException;
+import java.security.cert.CertificateFactory;
+import java.security.cert.X509Certificate;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.List;
 import java.util.Map;
 import java.util.Scanner;
 import java.util.concurrent.ArrayBlockingQueue;
 import java.util.concurrent.BlockingQueue;
+import java.util.logging.Logger;
 
 import javax.net.SocketFactory;
+import javax.net.ssl.SSLContext;
+import javax.net.ssl.SSLSocketFactory;
+import javax.net.ssl.TrustManager;
+import javax.net.ssl.TrustManagerFactory;
+import javax.net.ssl.X509TrustManager;
 
 import okhttp3.MediaType;
 import okhttp3.OkHttpClient;
@@ -28,16 +47,23 @@ import okhttp3.ResponseBody;
 
 import static com.fasterxml.jackson.databind.MapperFeature.BLOCK_UNSAFE_POLYMORPHIC_BASE_TYPES;
 import static java.lang.Integer.parseInt;
+import static java.lang.System.arraycopy;
 import static java.util.Collections.emptyList;
 import static java.util.concurrent.TimeUnit.SECONDS;
+import static java.util.logging.Logger.getLogger;
+import static javax.net.ssl.TrustManagerFactory.getDefaultAlgorithm;
+import static org.briarproject.nullsafety.NullSafety.requireNonNull;
 
 @NotNullByDefault
 public class MoatApi {
 
+	private static final Logger LOG = getLogger(MoatApi.class.getName());
+
 	private static final String MOAT_URL = "https://bridges.torproject.org/moat";
 	private static final String MOAT_CIRCUMVENTION_SETTINGS = "circumvention/settings";
 	private static final MediaType JSON = MediaType.get("application/json; charset=utf-8");
 	private static final String PORT_PREFIX = "CMETHOD meek_lite socks5 127.0.0.1:";
+	private static final String ISRG_RESOURCE_NAME = "isrg-root-x1.der";
 
 	private static final int CONNECT_TO_PROXY_TIMEOUT = (int) SECONDS.toMillis(5);
 	private static final int EXTRA_CONNECT_TIMEOUT = (int) SECONDS.toMillis(120);
@@ -75,8 +101,11 @@ public class MoatApi {
 					socksUsername,
 					SOCKS_PASSWORD
 			);
+			X509TrustManager trustManager = createTrustManager();
+			SSLSocketFactory sslSocketFactory = createSslSocketFactory(trustManager);
 			OkHttpClient client = new OkHttpClient.Builder()
 					.socketFactory(socketFactory)
+					.sslSocketFactory(sslSocketFactory, trustManager)
 					.dns(new NoDns())
 					.build();
 
@@ -92,6 +121,9 @@ public class MoatApi {
 				throw new IOException("request error");
 			String responseJson = responseBody.string();
 			return parseResponse(responseJson);
+		} catch (CertificateException | NoSuchAlgorithmException | KeyStoreException |
+		         KeyManagementException e) {
+			throw new IOException(e);
 		} finally {
 			lyrebirdProcess.destroy();
 		}
@@ -179,4 +211,124 @@ public class MoatApi {
 			Thread.currentThread().interrupt();
 		}
 	}
+
+	private SSLSocketFactory createSslSocketFactory(X509TrustManager trustManager)
+			throws NoSuchAlgorithmException, KeyManagementException {
+		SSLContext sslContext = SSLContext.getInstance("SSL");
+		sslContext.init(null, new TrustManager[]{trustManager}, new SecureRandom());
+		return sslContext.getSocketFactory();
+	}
+
+	@SuppressWarnings("CustomX509TrustManager")
+	private X509TrustManager createTrustManager() throws IOException, CertificateException,
+			NoSuchAlgorithmException, KeyStoreException {
+		// Find the default X-509 trust manager
+		TrustManagerFactory tmf = TrustManagerFactory.getInstance(getDefaultAlgorithm());
+		// Using null here initialises the TrustManagerFactory with the default trust store.
+		tmf.init((KeyStore) null);
+		X509TrustManager x509 = null;
+		for (TrustManager tm : tmf.getTrustManagers()) {
+			if (tm instanceof X509TrustManager) {
+				x509 = (X509TrustManager) tm;
+				break;
+			}
+		}
+		if (x509 == null) throw new IOException("Could not find default X-509 trust manager");
+		final X509TrustManager delegate = x509;
+
+		// Return a trust manager that includes the root certificate used by Let's Encrypt
+		X509Certificate authority = createX509Certificate();
+		return new X509TrustManager() {
+			@Override
+			public void checkClientTrusted(X509Certificate[] chain, String authType)
+					throws CertificateException {
+				delegate.checkClientTrusted(chain, authType);
+			}
+
+			@Override
+			public void checkServerTrusted(X509Certificate[] chain, String authType)
+					throws CertificateException {
+				LOG.info("Auth type: " + authType);
+				try {
+					delegate.checkServerTrusted(chain, authType);
+					LOG.info("Certificate chain was verified by default trust manager");
+				} catch (CertificateException e) {
+					LOG.info("Certificate chain was not verified by default trust manager: " + e);
+					validateCertificateChain(chain, authority);
+				}
+			}
+
+			@Override
+			public X509Certificate[] getAcceptedIssuers() {
+				X509Certificate[] defaultIssuers = delegate.getAcceptedIssuers();
+				X509Certificate[] allIssuers = new X509Certificate[defaultIssuers.length + 1];
+				arraycopy(defaultIssuers, 0, allIssuers, 0, defaultIssuers.length);
+				allIssuers[defaultIssuers.length] = authority;
+				return allIssuers;
+			}
+		};
+	}
+
+	private X509Certificate createX509Certificate() throws CertificateException {
+		InputStream in = requireNonNull(
+				getClass().getClassLoader().getResourceAsStream(ISRG_RESOURCE_NAME));
+		CertificateFactory certFactory = CertificateFactory.getInstance("X509");
+		X509Certificate cert = (X509Certificate) certFactory.generateCertificate(in);
+		LOG.info("Adding certificate authority, issuer: " + cert.getIssuerX500Principal().getName()
+				+ ", subject: " + cert.getSubjectX500Principal().getName());
+		return cert;
+	}
+
+	static void validateCertificateChain(X509Certificate[] chain, X509Certificate authority)
+			throws CertificateException {
+		if (chain.length == 0) {
+			throw new CertificateException("Certificate chain is empty");
+		}
+		X509Certificate prev = authority;
+		for (int i = chain.length - 1; i >= 0; i--) {
+			X509Certificate curr = chain[i];
+			LOG.info("Checking subject: " + curr.getSubjectX500Principal().getName());
+			// Check that the certificate is within its validity period
+			curr.checkValidity();
+			// Check that the issuer matches the subject of the previous certificate
+			if (!Arrays.equals(curr.getIssuerUniqueID(), prev.getSubjectUniqueID())) {
+				throw new CertificateException("Certificate issuer does not match");
+			}
+			// Check that the certificate can be used for digital signatures
+			boolean[] keyUsage = curr.getKeyUsage();
+			if (keyUsage.length == 0 || !keyUsage[0]) {
+				throw new CertificateException(
+						"Certificate is not authorised for digital signatures");
+			}
+			// Check the basic constraints. The number of CA certificates in the chain
+			// following the current certificate is (i - 1).
+			int constraints = curr.getBasicConstraints();
+			int caPathLength = i - 1;
+			if (constraints == -1) {
+				LOG.info("Non-CA certificate");
+				if (i != 0) {
+					throw new CertificateException("Non-CA certificate found at invalid position");
+				}
+			} else if (i == 0) {
+				throw new CertificateException("CA certificate found at invalid position");
+			} else {
+				LOG.info("CA certificate with maximum path length: " + constraints);
+				if (constraints < caPathLength) {
+					throw new CertificateException("CA certificate has maximum CA path length: "
+							+ constraints + ", needed: " + caPathLength);
+				}
+			}
+			// Check that the certificate was signed by the public key of the previous
+			// certificate
+			try {
+				curr.verify(prev.getPublicKey());
+			} catch (NoSuchAlgorithmException | SignatureException | InvalidKeyException |
+			         NoSuchProviderException e1) {
+				throw new CertificateException(e1);
+			}
+			// All good, move on to the next certificate in the chain
+			prev = curr;
+		}
+		LOG.info("Certificate chain accepted");
+	}
 }
diff --git a/lib/src/main/resources/isrg-root-x1.der b/lib/src/main/resources/isrg-root-x1.der
new file mode 100644
index 0000000000000000000000000000000000000000..9d2132e7f1e352fabac7eafb231488b5da91ffb2
GIT binary patch
literal 1391
zcmXqLV$C*aVh&!w%*4pVB*@StaDKxjhsTjF$q#lXH+3@@@Un4gwRyCC=VfH%W@Rw&
zH{>?pWMd9xVH0Kw4K~y?PzQ0igcUsVN>YpRQcDzqQ<F=JGD|8If>Mi96N{2F6x@sQ
zOA8D|4TM2TnT2^ggM-`^g7WiA6e0`_<ivRmO%2QpObiVTOpGm}#CeU8xzx9?iAf3B
zQ;e((%uP)E3<gb1Tue<&j0|gEs1$z@G5<V!o_4r~O#8k&+wWUU=*hEr7QUe3d+DJ?
z|GsABePi&~xP339Eyrc@wvEYuMTD~V%U^nBI9svqqOr{`kFR$t?{D7mU+AOaEboI|
zZH1X$X=yqAbv6b2*J>)UeSUJ_S;M+V-u>HW)=goaf7yL{%}fvF;1?F_{JHX*^)7mb
z_cWAjyQP1@qPLp4KvBB%lYz~z{&jb6C9i%h=6|S9(7WzD_ly5q%k{o&s`h%|Bc#ex
z(95j3;9;=J8{wPpB=-w!_Uf_kT$~tqZ%sS<lrPDJZ}cAJN6%<{*coF|nN#-OdO}j=
zv)fB%>8l;RAn=gy-c5l%vESRjulRoaDHHpQelw1#&mWmj<25Ut_nWV1qwMTG%s)L@
zZ#3Rz-J*5P@#PxEvZ-ABH|}5ED<p5KuOXguX~w}7oGImb?&iDBt%;1wm|I_Tt@9|G
zqo!S?-Ceb>DklY(M=kbokat@+bL(=ez`Qo=d9_8$g;*;h-`WLMh;lRc_g>Iv-DFqo
zCF5PpD)i^rs|NwXHO`YuHlHea-Y3t<alzn9bfMW6_FV@J3}QUCH(AeER-4eZXt8F~
znO%FES)>;=GdnK4#`;nE(6$dNYTB&bR(NQ2+$oz?wqHJLsjX!HYm3h*_fBZ@a%uek
ze*2NA(-ox)>ah<i|4BSA=veFb>}I#svAgPldH?sMd^L9VXJTe#U|j5E;9$T9Os}&1
zjEw(TSb({M&43@o7Y6ZJ4VZzHfh<UXk420{q;gB-v+8Y6pD-3TaCDrIium?&b{=vn
z17;myDq~~_m5*4tXVZ#+3p^WdNM$OhYjUhfERE^P`_c3?Q~&C>Fz=l^iUlGsD^9O_
z?o;@C)1`#9mMgeli7SS+ehlD?e0}ag<jY+rMc=p0?Qd!L_T=Tn33tS2CrP`0NSk`8
zCjZbY>-X~KPhVT7{&D4o6YKug*3J5*#Pa(8&H7gpwUsuC^Ywq~GKr43@rUtb$j%*V
zXSzC!JAHIpY?|)Bn-<QxOK11@BioPrSvT!7JfT!vJn7=0h9#Dk0>;WsJ~s2)HigcR
z-KW{sqcnToqipMNtEK|qJDkTmPjj*R=DdjQJNf?H>f^h&YWulf^SYpR=4sI>j;y6q
zAB!&hzU1vmo%p4{|F6+t(%W~vdiUeP>Iq_(+2h=TYs}f5dM+QCHs|Whty&MJN;P<_
z^RZ+<cgB55&{XYRJASXdWE@=kRMt25>cWl3o${YKsGG(t*4WP8`@Gk9!gJ;MzXRq}
z=D1zmBD#56Ufpb-X;wRebnUN2Km5&csO6u^ip8C`)?_`D(Av1dIWhXO{2lAwvQN4%
zdQ0z%8|T;t|E@mm82|syq6>)@52x)|6WeWmz4WT_ftiBq<~klMDs9=v<meQiuHrG}
z;%xPO?Dji%_&1gWKCIgQcCPZHeGjf`un5~2GS9nPmD7KWUE)~%J@-C)jd?6==a+_<
hl<$4hIs2u!^Zn@C@&Eed!WW%&m|K^mbnBjkIsk}=Sg-&9

literal 0
HcmV?d00001

-- 
GitLab


From 615ec6573bbefbe527dfbe6c226b4b5883ef9584 Mon Sep 17 00:00:00 2001
From: akwizgran <michael@briarproject.org>
Date: Tue, 4 Feb 2025 11:59:21 +0000
Subject: [PATCH 5/9] Add some more certificate validation checks.

---
 .../main/java/org/briarproject/moat/MoatApi.java | 16 +++++++++++++---
 1 file changed, 13 insertions(+), 3 deletions(-)

diff --git a/lib/src/main/java/org/briarproject/moat/MoatApi.java b/lib/src/main/java/org/briarproject/moat/MoatApi.java
index ab0ad91..a4c6f16 100644
--- a/lib/src/main/java/org/briarproject/moat/MoatApi.java
+++ b/lib/src/main/java/org/briarproject/moat/MoatApi.java
@@ -290,9 +290,13 @@ public class MoatApi {
 			LOG.info("Checking subject: " + curr.getSubjectX500Principal().getName());
 			// Check that the certificate is within its validity period
 			curr.checkValidity();
-			// Check that the issuer matches the subject of the previous certificate
+			// Check that the issuer matches the subject ID and name of the previous certificate
 			if (!Arrays.equals(curr.getIssuerUniqueID(), prev.getSubjectUniqueID())) {
-				throw new CertificateException("Certificate issuer does not match");
+				throw new CertificateException("Certificate issuer unique ID does not match");
+			}
+			if (!curr.getIssuerX500Principal().getName().equals(
+					prev.getSubjectX500Principal().getName())) {
+				throw new CertificateException("Certificate issuer name does not match");
 			}
 			// Check that the certificate can be used for digital signatures
 			boolean[] keyUsage = curr.getKeyUsage();
@@ -300,6 +304,12 @@ public class MoatApi {
 				throw new CertificateException(
 						"Certificate is not authorised for digital signatures");
 			}
+			// If this is not the leaf certificate, check that is can be used for signing
+			// certificates
+			if (i > 0 && (keyUsage.length < 6 || !keyUsage[5])) {
+				throw new CertificateException(
+						"Certificate is not authorised for signing certificates");
+			}
 			// Check the basic constraints. The number of CA certificates in the chain
 			// following the current certificate is (i - 1).
 			int constraints = curr.getBasicConstraints();
@@ -312,7 +322,7 @@ public class MoatApi {
 			} else if (i == 0) {
 				throw new CertificateException("CA certificate found at invalid position");
 			} else {
-				LOG.info("CA certificate with maximum path length: " + constraints);
+				LOG.info("CA certificate with maximum CA path length: " + constraints);
 				if (constraints < caPathLength) {
 					throw new CertificateException("CA certificate has maximum CA path length: "
 							+ constraints + ", needed: " + caPathLength);
-- 
GitLab


From a9c479997d6ad0ef53974f5043c91a7e9ed623ff Mon Sep 17 00:00:00 2001
From: akwizgran <michael@briarproject.org>
Date: Thu, 6 Feb 2025 12:34:49 +0000
Subject: [PATCH 6/9] Use Lyrebird 0.5.0-3 so the test app works on Android <
 7.1.1.

---
 app/build.gradle | 2 +-
 lib/build.gradle | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/app/build.gradle b/app/build.gradle
index f99082a..38933b3 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -62,7 +62,7 @@ dependencies {
     implementation 'com.google.dagger:hilt-android:2.48'
     implementation 'org.conscrypt:conscrypt-android:2.5.3'
 
-    tor 'org.briarproject:lyrebird-android:0.5.0-2'
+    tor 'org.briarproject:lyrebird-android:0.5.0-3'
 
     annotationProcessor 'com.google.dagger:hilt-compiler:2.48'
 }
diff --git a/lib/build.gradle b/lib/build.gradle
index a4bfb83..e883370 100644
--- a/lib/build.gradle
+++ b/lib/build.gradle
@@ -26,7 +26,7 @@ dependencies {
     implementation 'org.briarproject:socks-socket:0.1'
 
     // Linux lyrebird binary is only used for testing
-    tor 'org.briarproject:lyrebird-linux:0.5.0-2'
+    tor 'org.briarproject:lyrebird-linux:0.5.0-3'
 
     // Test with obsolete version 3.12.x to ensure library users can use it
     // if they need Android 4 compatibility
-- 
GitLab


From cd14accfe043bb616fbd30e696ce99e12c997256 Mon Sep 17 00:00:00 2001
From: akwizgran <michael@briarproject.org>
Date: Fri, 7 Feb 2025 14:47:15 +0000
Subject: [PATCH 7/9] Add GitLab CI config.

---
 .gitlab-ci.yml | 34 ++++++++++++++++++++++++++++++++++
 1 file changed, 34 insertions(+)
 create mode 100644 .gitlab-ci.yml

diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
new file mode 100644
index 0000000..1335b8d
--- /dev/null
+++ b/.gitlab-ci.yml
@@ -0,0 +1,34 @@
+image: briar/ci-image-android:latest
+
+stages:
+  - test
+
+workflow:
+  # when to create a CI pipeline
+  rules:
+    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
+    - if: '$CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS'
+      when: never # avoids duplicate jobs for branch and MR
+    - if: '$CI_COMMIT_BRANCH'
+    - if: '$CI_COMMIT_TAG'
+
+test:
+  stage: test
+  before_script:
+    - set -e
+    - export GRADLE_USER_HOME=$PWD/.gradle
+  script:
+    - ./gradlew assemble check
+  after_script:
+    # these file change every time and should not be cached
+    - rm -f $GRADLE_USER_HOME/caches/modules-2/modules-2.lock
+    - rm -fr $GRADLE_USER_HOME/caches/*/plugin-resolution/
+  cache:
+    key: "$CI_COMMIT_REF_SLUG"
+    paths:
+      - .gradle/wrapper
+      - .gradle/caches
+  rules:
+    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
+      when: always
+    - when: always
-- 
GitLab


From eec5bf29cbb8b686ebb10454d23159342540de64 Mon Sep 17 00:00:00 2001
From: akwizgran <michael@briarproject.org>
Date: Fri, 7 Feb 2025 16:30:48 +0000
Subject: [PATCH 8/9] Add unit tests for certificate validation.

---
 app/build.gradle                              |   2 -
 lib/build.gradle                              |   3 +-
 .../moat/CertificateValidationTest.java       | 238 ++++++++++++++++++
 3 files changed, 240 insertions(+), 3 deletions(-)
 create mode 100644 lib/src/test/java/org/briarproject/moat/CertificateValidationTest.java

diff --git a/app/build.gradle b/app/build.gradle
index 38933b3..69b1fe2 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -1,5 +1,3 @@
-import com.android.build.gradle.tasks.MergeResources
-
 plugins {
     id 'com.android.application'
     id 'com.google.dagger.hilt.android'
diff --git a/lib/build.gradle b/lib/build.gradle
index e883370..15877d7 100644
--- a/lib/build.gradle
+++ b/lib/build.gradle
@@ -31,7 +31,8 @@ dependencies {
     // Test with obsolete version 3.12.x to ensure library users can use it
     // if they need Android 4 compatibility
     testImplementation 'com.squareup.okhttp3:okhttp:3.12.13'
-    testImplementation 'org.junit.jupiter:junit-jupiter:5.8.1'
+    testImplementation 'org.junit.jupiter:junit-jupiter:5.8.2'
+    testImplementation 'org.mockito:mockito-core:5.15.2'
 }
 
 test {
diff --git a/lib/src/test/java/org/briarproject/moat/CertificateValidationTest.java b/lib/src/test/java/org/briarproject/moat/CertificateValidationTest.java
new file mode 100644
index 0000000..629fbbd
--- /dev/null
+++ b/lib/src/test/java/org/briarproject/moat/CertificateValidationTest.java
@@ -0,0 +1,238 @@
+package org.briarproject.moat;
+
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+import java.security.PublicKey;
+import java.security.SignatureException;
+import java.security.cert.CertificateException;
+import java.security.cert.CertificateExpiredException;
+import java.security.cert.CertificateNotYetValidException;
+import java.security.cert.X509Certificate;
+
+import javax.security.auth.x500.X500Principal;
+
+import static org.briarproject.moat.MoatApi.validateCertificateChain;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.mockito.internal.verification.VerificationModeFactory.times;
+
+public class CertificateValidationTest {
+
+	private final X509Certificate authorityCert, intermediate1Cert, intermediate2Cert,
+			leaf1Cert, leaf2Cert;
+	private final X500Principal authorityPrincipal;
+	private final X500Principal intermediate1Principal;
+	private final X500Principal intermediate2Principal;
+	private final X500Principal leaf1Principal;
+	private final PublicKey authorityPublicKey, intermediate1PublicKey, intermediate2PublicKey;
+	private final boolean[] authorityUniqueId, intermediate1UniqueId, intermediate2UniqueId,
+			leaf1UniqueId;
+
+	public CertificateValidationTest() {
+		authorityCert = Mockito.mock(X509Certificate.class);
+		intermediate1Cert = Mockito.mock(X509Certificate.class);
+		intermediate2Cert = Mockito.mock(X509Certificate.class);
+		leaf1Cert = Mockito.mock(X509Certificate.class);
+		leaf2Cert = Mockito.mock(X509Certificate.class);
+
+		authorityPrincipal = Mockito.mock(X500Principal.class);
+		intermediate1Principal = Mockito.mock(X500Principal.class);
+		intermediate2Principal = Mockito.mock(X500Principal.class);
+		leaf1Principal = Mockito.mock(X500Principal.class);
+		X500Principal leaf2Principal = Mockito.mock(X500Principal.class);
+
+		authorityPublicKey = Mockito.mock(PublicKey.class);
+		intermediate1PublicKey = Mockito.mock(PublicKey.class);
+		intermediate2PublicKey = Mockito.mock(PublicKey.class);
+
+		authorityUniqueId = new boolean[]{false, false, false};
+		intermediate1UniqueId = new boolean[]{false, false, true};
+		intermediate2UniqueId = new boolean[]{false, true, false};
+		leaf1UniqueId = new boolean[]{false, true, true};
+		boolean[] leaf2UniqueId = new boolean[]{true, false, false};
+
+		boolean[] keyUsageDigitalSignatures = new boolean[]{true};
+		boolean[] keyUsageSigningCertificates =
+				new boolean[]{true, false, false, false, false, true};
+
+		when(authorityCert.getSubjectX500Principal()).thenReturn(authorityPrincipal);
+		when(intermediate1Cert.getSubjectX500Principal()).thenReturn(intermediate1Principal);
+		when(intermediate2Cert.getSubjectX500Principal()).thenReturn(intermediate2Principal);
+		when(leaf1Cert.getSubjectX500Principal()).thenReturn(leaf1Principal);
+		when(leaf2Cert.getSubjectX500Principal()).thenReturn(leaf2Principal);
+
+		when(authorityCert.getPublicKey()).thenReturn(authorityPublicKey);
+		when(intermediate1Cert.getPublicKey()).thenReturn(intermediate1PublicKey);
+		when(intermediate2Cert.getPublicKey()).thenReturn(intermediate2PublicKey);
+
+		when(authorityCert.getSubjectUniqueID()).thenReturn(authorityUniqueId);
+		when(intermediate1Cert.getSubjectUniqueID()).thenReturn(intermediate1UniqueId);
+		when(intermediate2Cert.getSubjectUniqueID()).thenReturn(intermediate2UniqueId);
+		when(leaf1Cert.getSubjectUniqueID()).thenReturn(leaf1UniqueId);
+		when(leaf2Cert.getSubjectUniqueID()).thenReturn(leaf2UniqueId);
+
+		when(authorityPrincipal.getName()).thenReturn("authority");
+		when(intermediate1Principal.getName()).thenReturn("intermediate1");
+		when(intermediate2Principal.getName()).thenReturn("intermediate2");
+		when(leaf1Principal.getName()).thenReturn("leaf1");
+		when(leaf2Principal.getName()).thenReturn("leaf2");
+
+		when(intermediate1Cert.getKeyUsage()).thenReturn(keyUsageSigningCertificates);
+		when(intermediate2Cert.getKeyUsage()).thenReturn(keyUsageSigningCertificates);
+		when(leaf1Cert.getKeyUsage()).thenReturn(keyUsageDigitalSignatures);
+		when(leaf2Cert.getKeyUsage()).thenReturn(keyUsageDigitalSignatures);
+
+		when(intermediate1Cert.getBasicConstraints()).thenReturn(1);
+		when(intermediate2Cert.getBasicConstraints()).thenReturn(0);
+		when(leaf1Cert.getBasicConstraints()).thenReturn(-1);
+		when(leaf2Cert.getBasicConstraints()).thenReturn(-1);
+	}
+
+	@Test
+	public void testRejectsEmptyCertificateChain() {
+		assertThrows(CertificateException.class, () ->
+				validateCertificateChain(new X509Certificate[0], authorityCert));
+	}
+
+	@Test
+	public void testRejectsExpiredCertificate() throws CertificateException {
+		when(leaf1Cert.getIssuerX500Principal()).thenReturn(authorityPrincipal);
+		when(leaf1Cert.getIssuerUniqueID()).thenReturn(authorityUniqueId);
+		doThrow(CertificateExpiredException.class).when(leaf1Cert).checkValidity(); // Too old
+
+		assertThrows(CertificateException.class, () ->
+				validateCertificateChain(new X509Certificate[]{leaf1Cert}, authorityCert));
+	}
+
+	@Test
+	public void testRejectsNotYetValidCertificate() throws CertificateException {
+		when(leaf1Cert.getIssuerX500Principal()).thenReturn(authorityPrincipal);
+		when(leaf1Cert.getIssuerUniqueID()).thenReturn(authorityUniqueId);
+		doThrow(CertificateNotYetValidException.class).when(leaf1Cert).checkValidity(); // Too new
+
+		assertThrows(CertificateException.class, () ->
+				validateCertificateChain(new X509Certificate[]{leaf1Cert}, authorityCert));
+	}
+
+	@Test
+	public void testRejectsWrongIssuerId() {
+		when(leaf1Cert.getIssuerX500Principal()).thenReturn(authorityPrincipal);
+		when(leaf1Cert.getIssuerUniqueID()).thenReturn(intermediate1UniqueId); // Mismatch
+
+		assertThrows(CertificateException.class, () ->
+				validateCertificateChain(new X509Certificate[]{leaf1Cert}, authorityCert));
+	}
+
+	@Test
+	public void testRejectsWrongIssuerPrincipal() {
+		when(leaf1Cert.getIssuerX500Principal()).thenReturn(intermediate1Principal); // Mismatch
+		when(leaf1Cert.getIssuerUniqueID()).thenReturn(authorityUniqueId);
+
+		assertThrows(CertificateException.class, () ->
+				validateCertificateChain(new X509Certificate[]{leaf1Cert}, authorityCert));
+	}
+
+	@Test
+	public void testRejectsInvalidSignature() throws Exception {
+		when(leaf1Cert.getIssuerX500Principal()).thenReturn(authorityPrincipal);
+		when(leaf1Cert.getIssuerUniqueID()).thenReturn(authorityUniqueId);
+		doThrow(SignatureException.class).when(leaf1Cert).verify(authorityPublicKey); // Invalid
+
+		assertThrows(CertificateException.class, () ->
+				validateCertificateChain(new X509Certificate[]{leaf1Cert}, authorityCert));
+	}
+
+	@Test
+	public void testAcceptsLeafWithoutIntermediate() throws Exception {
+		when(leaf1Cert.getIssuerX500Principal()).thenReturn(authorityPrincipal);
+		when(leaf1Cert.getIssuerUniqueID()).thenReturn(authorityUniqueId);
+
+		validateCertificateChain(new X509Certificate[]{leaf1Cert}, authorityCert);
+
+		verify(leaf1Cert, times(1)).verify(authorityPublicKey);
+	}
+
+	@Test
+	public void testRejectsIntermediateInLeafPosition() {
+		when(intermediate1Cert.getIssuerX500Principal()).thenReturn(authorityPrincipal);
+		when(intermediate1Cert.getIssuerUniqueID()).thenReturn(authorityUniqueId);
+
+		assertThrows(CertificateException.class, () ->
+				validateCertificateChain(new X509Certificate[]{intermediate1Cert}, authorityCert));
+	}
+
+	@Test
+	public void testRejectsLeafInIntermediatePosition() {
+		when(leaf1Cert.getIssuerX500Principal()).thenReturn(authorityPrincipal);
+		when(leaf1Cert.getIssuerUniqueID()).thenReturn(authorityUniqueId);
+		when(leaf2Cert.getIssuerX500Principal()).thenReturn(leaf1Principal);
+		when(leaf2Cert.getIssuerUniqueID()).thenReturn(leaf1UniqueId);
+
+		assertThrows(CertificateException.class, () ->
+				validateCertificateChain(new X509Certificate[]{leaf2Cert, leaf1Cert},
+						authorityCert));
+	}
+
+	@Test
+	public void testAcceptsLeafWithIntermediateWithBasicConstraintsZero() throws Exception {
+		when(intermediate1Cert.getIssuerX500Principal()).thenReturn(authorityPrincipal);
+		when(intermediate1Cert.getIssuerUniqueID()).thenReturn(authorityUniqueId);
+		when(leaf1Cert.getIssuerX500Principal()).thenReturn(intermediate1Principal);
+		when(leaf1Cert.getIssuerUniqueID()).thenReturn(intermediate1UniqueId);
+
+		validateCertificateChain(new X509Certificate[]{leaf1Cert, intermediate1Cert},
+				authorityCert);
+
+		verify(intermediate1Cert, times(1)).verify(authorityPublicKey);
+		verify(leaf1Cert, times(1)).verify(intermediate1PublicKey);
+	}
+
+	@Test
+	public void testAcceptsLeafWithIntermediateWithBasicConstraintsOne() throws Exception {
+		when(intermediate2Cert.getIssuerX500Principal()).thenReturn(authorityPrincipal);
+		when(intermediate2Cert.getIssuerUniqueID()).thenReturn(authorityUniqueId);
+		when(leaf1Cert.getIssuerX500Principal()).thenReturn(intermediate2Principal);
+		when(leaf1Cert.getIssuerUniqueID()).thenReturn(intermediate2UniqueId);
+
+		validateCertificateChain(new X509Certificate[]{leaf1Cert, intermediate2Cert},
+				authorityCert);
+
+		verify(intermediate2Cert, times(1)).verify(authorityPublicKey);
+		verify(leaf1Cert, times(1)).verify(intermediate2PublicKey);
+	}
+
+	@Test
+	public void testRejectsChainLongerThanBasicConstraints() throws Exception {
+		// Intermediate 2's basic constraints don't allow it to sign an intermediate certificate
+		when(intermediate2Cert.getIssuerX500Principal()).thenReturn(authorityPrincipal);
+		when(intermediate2Cert.getIssuerUniqueID()).thenReturn(authorityUniqueId);
+		when(intermediate1Cert.getIssuerX500Principal()).thenReturn(intermediate2Principal);
+		when(intermediate1Cert.getIssuerUniqueID()).thenReturn(intermediate2UniqueId);
+		when(leaf1Cert.getIssuerX500Principal()).thenReturn(intermediate1Principal);
+		when(leaf1Cert.getIssuerUniqueID()).thenReturn(intermediate1UniqueId);
+
+		assertThrows(CertificateException.class, () ->
+				validateCertificateChain(new X509Certificate[]{leaf1Cert, intermediate1Cert,
+						intermediate2Cert}, authorityCert));
+	}
+
+	@Test
+	public void testAcceptsLeafWithTwoIntermediates() throws Exception {
+		when(intermediate1Cert.getIssuerX500Principal()).thenReturn(authorityPrincipal);
+		when(intermediate1Cert.getIssuerUniqueID()).thenReturn(authorityUniqueId);
+		when(intermediate2Cert.getIssuerX500Principal()).thenReturn(intermediate1Principal);
+		when(intermediate2Cert.getIssuerUniqueID()).thenReturn(intermediate1UniqueId);
+		when(leaf1Cert.getIssuerX500Principal()).thenReturn(intermediate2Principal);
+		when(leaf1Cert.getIssuerUniqueID()).thenReturn(intermediate2UniqueId);
+
+		validateCertificateChain(new X509Certificate[]{leaf1Cert, intermediate2Cert,
+				intermediate1Cert}, authorityCert);
+
+		verify(intermediate1Cert, times(1)).verify(authorityPublicKey);
+		verify(intermediate2Cert, times(1)).verify(intermediate1PublicKey);
+		verify(leaf1Cert, times(1)).verify(intermediate2PublicKey);
+	}
+}
-- 
GitLab


From c083f2dacef88b00d6a85f864cd702692f9251d8 Mon Sep 17 00:00:00 2001
From: akwizgran <michael@briarproject.org>
Date: Fri, 7 Feb 2025 16:42:12 +0000
Subject: [PATCH 9/9] Add new constructor, default to old behaviour.

---
 .../briarproject/moattest/MainViewModel.java  |  4 +++-
 .../java/org/briarproject/moat/MoatApi.java   | 20 +++++++++++-----
 .../org/briarproject/moat/MoatApiTest.java    | 24 +++++++++++--------
 3 files changed, 31 insertions(+), 17 deletions(-)

diff --git a/app/src/main/java/org/briarproject/moattest/MainViewModel.java b/app/src/main/java/org/briarproject/moattest/MainViewModel.java
index d8cbbb3..44a8a3b 100644
--- a/app/src/main/java/org/briarproject/moattest/MainViewModel.java
+++ b/app/src/main/java/org/briarproject/moattest/MainViewModel.java
@@ -21,6 +21,7 @@ import androidx.lifecycle.MutableLiveData;
 import dagger.hilt.android.lifecycle.HiltViewModel;
 
 import static android.content.Context.MODE_PRIVATE;
+import static android.os.Build.VERSION.SDK_INT;
 import static java.util.Locale.ROOT;
 
 @HiltViewModel
@@ -58,7 +59,8 @@ class MainViewModel extends AndroidViewModel {
 		String nativeLibDir = app.getApplicationInfo().nativeLibraryDir;
 		File lyrebirdLib = new File(nativeLibDir, LYREBIRD_LIB_NAME);
 		File stateDir = app.getDir(STATE_DIR_NAME, MODE_PRIVATE);
-		MoatApi moat = new MoatApi(lyrebirdLib, stateDir, CDN77_URL, CDN77_FRONT);
+		// On API level < 25, add the ISRG root certificate which devices don't have by default
+		MoatApi moat = new MoatApi(lyrebirdLib, stateDir, CDN77_URL, CDN77_FRONT, SDK_INT < 25);
 		try {
 			List<Bridges> bridges = moat.getWithCountry(countryCode);
 			StringBuilder sb = new StringBuilder();
diff --git a/lib/src/main/java/org/briarproject/moat/MoatApi.java b/lib/src/main/java/org/briarproject/moat/MoatApi.java
index a4c6f16..3730f01 100644
--- a/lib/src/main/java/org/briarproject/moat/MoatApi.java
+++ b/lib/src/main/java/org/briarproject/moat/MoatApi.java
@@ -72,16 +72,23 @@ public class MoatApi {
 
 	private final File lyrebirdExecutable, lyrebirdDir;
 	private final String url, front;
+	private final boolean addIsrgRootCertificate;
 	private final JsonMapper mapper = JsonMapper.builder()
 			.enable(BLOCK_UNSAFE_POLYMORPHIC_BASE_TYPES)
 			.build();
 
 	public MoatApi(File lyrebirdExecutable, File lyrebirdDir, String url, String front) {
+		this(lyrebirdExecutable, lyrebirdDir, url, front, false);
+	}
+
+	public MoatApi(File lyrebirdExecutable, File lyrebirdDir, String url, String front,
+			boolean addIsrgRootCertificate) {
 		if (!lyrebirdDir.isDirectory()) throw new IllegalArgumentException();
 		this.lyrebirdExecutable = lyrebirdExecutable;
 		this.lyrebirdDir = lyrebirdDir;
 		this.url = url;
 		this.front = front;
+		this.addIsrgRootCertificate = addIsrgRootCertificate;
 	}
 
 	public List<Bridges> get() throws IOException {
@@ -101,13 +108,14 @@ public class MoatApi {
 					socksUsername,
 					SOCKS_PASSWORD
 			);
-			X509TrustManager trustManager = createTrustManager();
-			SSLSocketFactory sslSocketFactory = createSslSocketFactory(trustManager);
-			OkHttpClient client = new OkHttpClient.Builder()
+			OkHttpClient.Builder clientBuilder = new OkHttpClient.Builder()
 					.socketFactory(socketFactory)
-					.sslSocketFactory(sslSocketFactory, trustManager)
-					.dns(new NoDns())
-					.build();
+					.dns(new NoDns());
+			if (addIsrgRootCertificate) {
+				X509TrustManager trustManager = createTrustManager();
+				clientBuilder.sslSocketFactory(createSslSocketFactory(trustManager), trustManager);
+			}
+			OkHttpClient client = clientBuilder.build();
 
 			String requestJson = country.isEmpty() ? "" : "{\"country\": \"" + country + "\"}";
 			RequestBody requestBody = RequestBody.create(JSON, requestJson);
diff --git a/lib/src/test/java/org/briarproject/moat/MoatApiTest.java b/lib/src/test/java/org/briarproject/moat/MoatApiTest.java
index a7297fb..ae94787 100644
--- a/lib/src/test/java/org/briarproject/moat/MoatApiTest.java
+++ b/lib/src/test/java/org/briarproject/moat/MoatApiTest.java
@@ -39,15 +39,17 @@ public class MoatApiTest {
 	@SuppressWarnings("SameParameterValue")
 	private void testCn(String url, String[] fronts) throws Exception {
 		for (String front : fronts) {
-			MoatApi moatApi = new MoatApi(lyrebirdExecutable, tempFolder, url, front);
-			List<Bridges> bridges = moatApi.getWithCountry("cn");
-			boolean anyObfs4 = false, anySnowflake = false;
-			for (Bridges b : bridges) {
-				if (b.type.equals("obfs4")) anyObfs4 = true;
-				else if (b.type.equals("snowflake")) anySnowflake = true;
+			for (boolean isrg : new boolean[]{true, false}) {
+				MoatApi moatApi = new MoatApi(lyrebirdExecutable, tempFolder, url, front, isrg);
+				List<Bridges> bridges = moatApi.getWithCountry("cn");
+				boolean anyObfs4 = false, anySnowflake = false;
+				for (Bridges b : bridges) {
+					if (b.type.equals("obfs4")) anyObfs4 = true;
+					else if (b.type.equals("snowflake")) anySnowflake = true;
+				}
+				assertTrue(anyObfs4);
+				assertTrue(anySnowflake);
 			}
-			assertTrue(anyObfs4);
-			assertTrue(anySnowflake);
 		}
 	}
 
@@ -59,8 +61,10 @@ public class MoatApiTest {
 	@SuppressWarnings("SameParameterValue")
 	private void testUs(String url, String[] fronts) throws Exception {
 		for (String front : fronts) {
-			MoatApi moatApi = new MoatApi(lyrebirdExecutable, tempFolder, url, front);
-			assertEquals(emptyList(), moatApi.getWithCountry("us"));
+			for (boolean isrg : new boolean[]{true, false}) {
+				MoatApi moatApi = new MoatApi(lyrebirdExecutable, tempFolder, url, front, isrg);
+				assertEquals(emptyList(), moatApi.getWithCountry("us"));
+			}
 		}
 	}
 
-- 
GitLab