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) {