diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
new file mode 100644
index 0000000000000000000000000000000000000000..1335b8db0486d23affe1f3f637a20862ad0593e1
--- /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
diff --git a/README.md b/README.md
index b3482af4d4afc2227c143ebb3b9aada5e5ae95e9..6ba78ec4806e0f5284866cc246e68207b4a69594 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/build.gradle b/app/build.gradle
index 573937447879f7988ef6895298043599998e9997..69b1fe22a3c5fd5942bf54403ffeded0f9606c07 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'
@@ -42,7 +40,7 @@ android {
                 minSdk 21
             }
             dependencies {
-                implementation 'com.squareup.okhttp3:okhttp:4.10.0'
+                implementation 'com.squareup.okhttp3:okhttp:4.12.0'
             }
         }
     }
@@ -59,60 +57,34 @@ 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'
+    tor 'org.briarproject:lyrebird-android:0.5.0-3'
 
-    annotationProcessor 'com.google.dagger:hilt-compiler:2.44'
+    annotationProcessor 'com.google.dagger:hilt-compiler:2.48'
 }
 
 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
diff --git a/app/src/main/java/org/briarproject/moattest/MainActivity.java b/app/src/main/java/org/briarproject/moattest/MainActivity.java
index 605ffdf9208c963ab60c5523ec84f3652b96398f..eda17b6c51713432c4616467a7a53a0f09a1dfc6 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 34459a942ebbce19cb7d061746f10a6f61cd0b3d..44a8a3bf251689546a10c5d34c9554b25cae3476 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
@@ -29,10 +30,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 +48,19 @@ 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);
+		// 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/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
index 1719b86022dfee47ce1739a02868efbe2a16cfb5..305e33a56350cb5cd229d73dfd06a48699095cf1 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 b9e9787b027b591fe68ab87f9690f4fb257765af..cb3bed0b8e9de3742a36909f0207e26f988507d3 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/build.gradle b/lib/build.gradle
index a4bfb83eb647e503924a5e2935aeb81a56ff8343..15877d73e9b49b205cd4cd8753b40157508e8905 100644
--- a/lib/build.gradle
+++ b/lib/build.gradle
@@ -26,12 +26,13 @@ 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
     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/main/java/org/briarproject/moat/MoatApi.java b/lib/src/main/java/org/briarproject/moat/MoatApi.java
index 522d157d375b6b3d1a92a78d0903e7dde4f6fc09..3730f01232134909bee18b862921d460f1269bcd 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);
@@ -46,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 {
@@ -75,10 +108,14 @@ public class MoatApi {
 					socksUsername,
 					SOCKS_PASSWORD
 			);
-			OkHttpClient client = new OkHttpClient.Builder()
+			OkHttpClient.Builder clientBuilder = new OkHttpClient.Builder()
 					.socketFactory(socketFactory)
-					.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);
@@ -92,6 +129,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 +219,134 @@ 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 ID and name of the previous certificate
+			if (!Arrays.equals(curr.getIssuerUniqueID(), prev.getSubjectUniqueID())) {
+				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();
+			if (keyUsage.length == 0 || !keyUsage[0]) {
+				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();
+			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 CA 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
Binary files /dev/null and b/lib/src/main/resources/isrg-root-x1.der differ
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 0000000000000000000000000000000000000000..629fbbd7a03933fe93721707045d85bed3a769d1
--- /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);
+	}
+}
diff --git a/lib/src/test/java/org/briarproject/moat/MoatApiTest.java b/lib/src/test/java/org/briarproject/moat/MoatApiTest.java
index 4fa868e68de25c2e834e0953511d36d1e4fefbf8..ae9478780b2879bb9029cf0471ae15850732a056 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,59 +31,45 @@ 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);
-			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);
 		}
 	}
 
-	@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);
-			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"));
+			}
 		}
 	}
 
 	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) {