diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d556fcb --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,50 @@ +name: CI + +# End-to-end and unit tests run on GitHub Actions (a free CI provider for +# public repositories). The whole suite runs on the JVM via Robolectric, so no +# Android emulator/device is required. + +on: + push: + branches: [ master, update-to-current-toolchain ] + pull_request: + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + + - name: Set up Android SDK + uses: android-actions/setup-android@v3 + + - name: Install required SDK packages + run: sdkmanager "platform-tools" "platforms;android-35" "build-tools;35.0.0" + + - name: Cache Gradle + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle', 'gradle/wrapper/gradle-wrapper.properties') }} + restore-keys: gradle-${{ runner.os }}- + + - name: Run unit and end-to-end tests + run: ./gradlew testDebugUnitTest --console=plain --stacktrace + + - name: Upload test report + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-report + path: build/reports/tests/ + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index 9f0017c..404048f 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,10 @@ build/ **.orig **.rej -**.patch \ No newline at end of file +**.patch +release/ +debug/ +# Machine-specific / IDE files that should not be in VCS +local.properties +.idea/ +.envrc.local diff --git a/AndroidManifest.xml b/AndroidManifest.xml index 36d852d..8e8b200 100644 --- a/AndroidManifest.xml +++ b/AndroidManifest.xml @@ -1,40 +1,55 @@ - + xmlns:tools="http://schemas.android.com/tools" + android:versionCode="10" + android:versionName="1.3" + tools:ignore="GoogleAppIndexingWarning"> + - - - - + + + + - - - + android:windowSoftInputMode="stateVisible|adjustResize" android:exported="true"/> + + + - - - - - - - + + - + + diff --git a/assets/ssldroid_logo.svg b/assets/ssldroid_logo.svg index 15f98e6..ae11aad 100644 --- a/assets/ssldroid_logo.svg +++ b/assets/ssldroid_logo.svg @@ -1,6 +1,7 @@ + - // For instance, build-types/debug/java, build-types/debug/AndroidManifest.xml, ... - // This moves them out of them default location under src//... which would - // conflict with src/ being used by the main source set. - // Adding new build types or product flavors should be accompanied - // by a similar customization. debug.setRoot('build-types/debug') release.setRoot('build-types/release') } + defaultConfig { + minSdkVersion 21 + targetSdkVersion 33 + } + + compileSdk 35 + + buildFeatures { + renderScript true + aidl true + } + + testOptions { + unitTests { + includeAndroidResources = true + returnDefaultValues = true + all { + // Surface the proxy's android.util.Log output in test logs, + // which makes CI failures in the e2e tunnel tests diagnosable. + systemProperty 'robolectric.logging', 'stdout' + + // Robolectric instruments core classes, so on JDK 17 the test + // JVM needs reflective access to these java.base packages. + // Without java.net opened, real TLS handshakes in the e2e tests + // fail with an InaccessibleObjectException on InetAddress. + jvmArgs( + '--add-opens=java.base/java.lang=ALL-UNNAMED', + '--add-opens=java.base/java.util=ALL-UNNAMED', + '--add-opens=java.base/java.io=ALL-UNNAMED', + '--add-opens=java.base/java.net=ALL-UNNAMED', + '--add-opens=java.base/java.security=ALL-UNNAMED', + '--add-opens=java.base/javax.net.ssl=ALL-UNNAMED' + ) + } + } + } +} + +dependencies { + testImplementation 'junit:junit:4.13.2' + testImplementation 'org.robolectric:robolectric:4.14.1' + testImplementation 'androidx.test:core:1.6.1' } \ No newline at end of file diff --git a/docs/branch-review.md b/docs/branch-review.md new file mode 100644 index 0000000..639c2a0 --- /dev/null +++ b/docs/branch-review.md @@ -0,0 +1,75 @@ +# Review: `update-to-current-toolchain` + +Scope: the 18 commits on `update-to-current-toolchain` vs `master`. The branch +modernises the build (AGP 8.9.1, Gradle 8.11.1, `compileSdk 35`, `targetSdk 33`, +namespace, foreground service, notification channel, boot/network receivers) and +reorganises the `db` package back into the main package. + +Overall the toolchain migration is sound and the app compiles against the new +SDK. The findings below are the outstanding functional issues; the first is a +user-facing regression introduced by this branch. + +## Findings + +### 1. (Blocker, regression) `SSLDroidDbAdapter.createContentValues` drops `cacertfile` and `usesni` + +`createContentValues(...)` takes `cacertfile` and `usesni` parameters but never +writes them into the `ContentValues`: + +- `master` persisted `cacertfile` (`values.put(KEY_CACERTFILE, cacertfile)`). + This branch's refactor removed that line, so **CA-pinning configuration is + silently lost** on save. +- The branch added a new `usesni` column (`usesni integer not null`) and a + `usesni` parameter, but never persists it. So **the SNI checkbox is not saved**. +- Worse: on a **fresh** install the `tunnels.usesni` column is `NOT NULL` with no + default (only upgraded DBs get `default 1` via `onUpgrade`). Inserting a row + without `usesni` violates the constraint, `insert` returns `-1`, and + `saveState()` treats that as failure — **tunnels cannot be saved at all on a + clean install.** + +There is also a duplicated `values.put(KEY_REMOTEPORT, remoteport)` line. + +Fix: put `KEY_CACERTFILE` and `KEY_USE_SNI`, drop the duplicate. Covered by the +new regression test `SSLDroidDbAdapterTest.persistsCaCertFileAndSniFlag`. + +### 2. (Minor) Handshake failure leaks the accepted client socket + +In `TcpProxyServerThread.run()`, when the upstream TLS `createSocket` / +`startHandshake` throws `IOException`, the handler logs and `return`s without +closing the already-accepted client socket `sc`. The sibling `catch (Exception)` +does close `sc`. The client is then left with a half-open connection that only +resolves on its own timeout (this is what the negative e2e test observes). +Non-blocking for this task; noting for a follow-up. + +### 3. (Minor) A single bad connection tears down the whole tunnel + +Several error paths in the `accept()` loop `return` from `run()` instead of +`continue`-ing, so one failed upstream connection stops the listener for that +tunnel until the service restarts. Pre-existing behaviour, not introduced here. + +### 4. (Cosmetic) Dead byte-scrubbing loop in `Relay.run()` + +The `for` loop that rewrites byte `0x07` to `'#'` runs *after* the buffer has +already been written to `out`, so it has no effect — leftover from the original +`TcpTunnelGui`. Harmless; could be deleted. + +### 5. (Housekeeping) IDE and local files committed + +`.idea/` (incl. `workspace.xml`) and `local.properties` are tracked on this +branch. `local.properties` is machine-specific (`sdk.dir=/home/blint/...`) and +should not be in VCS. Recommend adding both to `.gitignore`. + +## Testing added by this change + +- `tests/java/hu/blint/ssldroid/TcpProxyE2ETest.java` — true end-to-end tunnel + tests (cleartext client → real `TcpProxy` → real TLS backend), covering the + no-pinning, correct-CA, and wrong-CA cases. +- `tests/java/hu/blint/ssldroid/SSLDroidDbAdapterTest.java` — CRUD + the + regression test for finding #1. +- `.github/workflows/ci.yml` — runs the suite on GitHub Actions (free provider), + entirely on the JVM via Robolectric (no emulator needed). + +Note: because Robolectric instruments core classes, the unit-test JVM is started +with `--add-opens` for several `java.base` packages (configured in +`build.gradle`); without `java.base/java.net` opened, JDK 17 fails the real TLS +handshake in the e2e tests with an `InaccessibleObjectException`. diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..bb7fab5 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,4 @@ +android.nonFinalResIds=false +android.nonTransitiveRClass=false +# Robolectric's test dependencies pull in AndroidX transitively. +android.useAndroidX=true \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index b223032..0ce99d2 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ -#Sat Jul 25 01:04:55 EDT 2015 +#Thu Jul 04 10:15:24 CEST 2019 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-2.5-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip diff --git a/res/layout/tunnel_details.xml b/res/layout/tunnel_details.xml index f65472b..46ea793 100644 --- a/res/layout/tunnel_details.xml +++ b/res/layout/tunnel_details.xml @@ -59,7 +59,7 @@ + android:inputType="textPassword" android:hint="Not mandatory" /> + + + + + - \ No newline at end of file + diff --git a/res/values/strings.xml b/res/values/strings.xml index efa961c..c1fea7a 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -9,6 +9,7 @@ Apply PKCS12 pass + Use SNI Tunnel name Add tunnel Stop service @@ -16,7 +17,8 @@ Start service No tunnels configured yet Delete tunnel - Pick a PKCS12 file from SD card + Pick a PKCS12 file from SD card + Pick a CA cert file from SD card No SD card present, please insert one to continue Read logs Reading log messages… diff --git a/res/xml/ssldroid_backup_rules.xml b/res/xml/ssldroid_backup_rules.xml new file mode 100644 index 0000000..0aae6b7 --- /dev/null +++ b/res/xml/ssldroid_backup_rules.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/hu/blint/ssldroid/BootStartupReceiver.java b/src/hu/blint/ssldroid/BootStartupReceiver.java index 8ce7a05..7b7346d 100644 --- a/src/hu/blint/ssldroid/BootStartupReceiver.java +++ b/src/hu/blint/ssldroid/BootStartupReceiver.java @@ -1,44 +1,50 @@ package hu.blint.ssldroid; -import hu.blint.ssldroid.db.SSLDroidDbAdapter; +import android.app.job.JobInfo; +import android.app.job.JobScheduler; import android.content.BroadcastReceiver; +import android.content.ComponentName; import android.content.Context; import android.content.Intent; -import android.database.Cursor; +import android.content.IntentFilter; +import android.os.Build; import android.util.Log; public class BootStartupReceiver extends BroadcastReceiver { - - private boolean isStopped(Context context){ - Boolean stopped = false; - SSLDroidDbAdapter dbHelper; - dbHelper = new SSLDroidDbAdapter(context); - dbHelper.open(); - Cursor cursor = dbHelper.getStopStatus(); - int tunnelcount = cursor.getCount(); - Log.d("SSLDroid", "Tunnelcount: "+tunnelcount); - - //don't start if the stop status field is available - if (tunnelcount != 0){ - stopped = true; + public void createNetworkChangeListener(Context context){ + if(Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { + context.registerReceiver(new NetworkChangeReceiver(null), new IntentFilter("android.net.conn.CONNECTIVITY_CHANGE")); + } + else { + Intent startServiceIntent = new Intent(context, NetworkChangeService.class); + context.startService(startServiceIntent); + Log.d("SSLDroid", "Scheduling network change monitor job"); + JobInfo myJob = new JobInfo.Builder(0, new ComponentName(context, NetworkChangeService.class)) + .setRequiresCharging(true) + .setMinimumLatency(1000) + .setOverrideDeadline(2000) + .setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY) + .setPersisted(true) + .build(); + + JobScheduler jobScheduler = (JobScheduler) context.getSystemService(Context.JOB_SCHEDULER_SERVICE); + jobScheduler.schedule(myJob); } - - cursor.close(); - dbHelper.close(); - - return stopped; } - + @Override public void onReceive(Context context, Intent intent) { - if (intent.getAction().equals(Intent.ACTION_BOOT_COMPLETED)) { - Intent i = new Intent(); - i.setAction("hu.blint.ssldroid.SSLDroid"); - if (!isStopped(context)) - context.startService(i); - else - Log.w("SSLDroid", "Not starting service as directed by explicit stop"); + if (intent.getAction() != Intent.ACTION_BOOT_COMPLETED) + return; + + createNetworkChangeListener(context); + + Intent i = new Intent(context, SSLDroid.class); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(i); + } else { + context.startService(i); } } } \ No newline at end of file diff --git a/src/hu/blint/ssldroid/NetworkChangeReceiver.java b/src/hu/blint/ssldroid/NetworkChangeReceiver.java index 63c683e..16d6aa7 100644 --- a/src/hu/blint/ssldroid/NetworkChangeReceiver.java +++ b/src/hu/blint/ssldroid/NetworkChangeReceiver.java @@ -1,57 +1,32 @@ package hu.blint.ssldroid; -import hu.blint.ssldroid.db.SSLDroidDbAdapter; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; -import android.database.Cursor; import android.net.ConnectivityManager; import android.net.NetworkInfo; -import android.util.Log; public class NetworkChangeReceiver extends BroadcastReceiver { - private boolean isStopped(Context context){ - Boolean stopped = false; - SSLDroidDbAdapter dbHelper; - dbHelper = new SSLDroidDbAdapter(context); - dbHelper.open(); - Cursor cursor = dbHelper.getStopStatus(); + private ConnectivityReceiverListener mConnectivityReceiverListener; - int tunnelcount = cursor.getCount(); - Log.d("SSLDroid", "Tunnelcount: "+tunnelcount); - - //don't start if the stop status field is available - if (tunnelcount != 0){ - stopped = true; - } - - cursor.close(); - dbHelper.close(); - - return stopped; + NetworkChangeReceiver(ConnectivityReceiverListener listener) { + mConnectivityReceiverListener = listener; } - + @Override public void onReceive(Context context, Intent intent) { - ConnectivityManager connectivityManager = (ConnectivityManager) context.getSystemService( Context.CONNECTIVITY_SERVICE ); - NetworkInfo activeNetInfo = connectivityManager.getActiveNetworkInfo(); - if ( activeNetInfo == null ) { - Intent i = new Intent(); - i.setAction("hu.blint.ssldroid.SSLDroid"); - context.stopService(i); - return; - } - Log.d("SSLDroid", activeNetInfo.toString()); - if (activeNetInfo.isAvailable()) { - Intent i = new Intent(); - i.setAction("hu.blint.ssldroid.SSLDroid"); - context.stopService(i); - if (!isStopped(context)) - context.startService(i); - else - Log.w("SSLDroid", "Not starting service as directed by explicit stop"); - } + mConnectivityReceiverListener.onNetworkConnectionChanged(isConnected(context)); + } + + public static boolean isConnected(Context context) { + ConnectivityManager cm = (ConnectivityManager) + context.getSystemService(Context.CONNECTIVITY_SERVICE); + NetworkInfo activeNetwork = cm.getActiveNetworkInfo(); + return activeNetwork != null && activeNetwork.isConnectedOrConnecting(); } -} + public interface ConnectivityReceiverListener { + void onNetworkConnectionChanged(boolean isConnected); + } +} \ No newline at end of file diff --git a/src/hu/blint/ssldroid/NetworkChangeService.java b/src/hu/blint/ssldroid/NetworkChangeService.java new file mode 100644 index 0000000..31715cc --- /dev/null +++ b/src/hu/blint/ssldroid/NetworkChangeService.java @@ -0,0 +1,63 @@ +package hu.blint.ssldroid; + +import android.app.job.JobParameters; +import android.app.job.JobService; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.os.Build; +import android.util.Log; + +public class NetworkChangeService extends JobService implements + NetworkChangeReceiver.ConnectivityReceiverListener { + + private static final String TAG = "SSLDroid"; + + private NetworkChangeReceiver mConnectivityReceiver; + + @Override + public void onCreate() { + super.onCreate(); + Log.i(TAG, "Service created"); + mConnectivityReceiver = new NetworkChangeReceiver(this); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + Log.i(TAG, "onStartCommand"); + return START_NOT_STICKY; + } + + + @Override + public boolean onStartJob(JobParameters params) { + Log.i(TAG, "onStartJob" + mConnectivityReceiver); + registerReceiver(mConnectivityReceiver, new IntentFilter("android.net.conn.CONNECTIVITY_CHANGE")); + return true; + } + + @Override + public boolean onStopJob(JobParameters params) { + Log.i(TAG, "onStopJob"); + unregisterReceiver(mConnectivityReceiver); + return true; + } + + @Override + public void onNetworkConnectionChanged(boolean isConnected) { + Context context = getBaseContext(); + if ( !isConnected ) { + Intent i = new Intent(context, SSLDroid.class); + context.stopService(i); + } + else { + Intent i = new Intent(context, SSLDroid.class); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(i); + } else { + context.startService(i); + } + } + } +} + diff --git a/src/hu/blint/ssldroid/PRNGFixes.java b/src/hu/blint/ssldroid/PRNGFixes.java new file mode 100644 index 0000000..9a35d87 --- /dev/null +++ b/src/hu/blint/ssldroid/PRNGFixes.java @@ -0,0 +1,344 @@ +package hu.blint.ssldroid; + +/* + * This software is provided 'as-is', without any express or implied + * warranty. In no event will Google be held liable for any damages + * arising from the use of this software. + * + * Permission is granted to anyone to use this software for any purpose, + * including commercial applications, and to alter it and redistribute it + * freely, as long as the origin is not misrepresented. + */ + +import android.os.Build; +import android.os.Process; +import android.util.Log; + +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.io.UnsupportedEncodingException; +import java.security.NoSuchAlgorithmException; +import java.security.Provider; +import java.security.SecureRandom; +import java.security.SecureRandomSpi; +import java.security.Security; + +/** + * Fixes for the output of the default PRNG having low entropy. + * + * The fixes need to be applied via {@link #apply()} before any use of Java + * Cryptography Architecture primitives. A good place to invoke them is in the + * application's {@code onCreate}. + */ +@SuppressWarnings("PrimitiveArrayArgumentToVarargsMethod") +final class PRNGFixes { + + private static final int VERSION_CODE_JELLY_BEAN = 16; + private static final int VERSION_CODE_JELLY_BEAN_MR2 = 18; + private static final byte[] BUILD_FINGERPRINT_AND_DEVICE_SERIAL = + getBuildFingerprintAndDeviceSerial(); + + /** Hidden constructor to prevent instantiation. */ + private PRNGFixes() {} + + /** + * Applies all fixes. + * + * @throws SecurityException if a fix is needed but could not be applied. + */ + public static void apply() { + applyOpenSSLFix(); + installLinuxPRNGSecureRandom(); + } + + /** + * Applies the fix for OpenSSL PRNG having low entropy. Does nothing if the + * fix is not needed. + * + * @throws SecurityException if the fix is needed but could not be applied. + */ + private static void applyOpenSSLFix() throws SecurityException { + if ((Build.VERSION.SDK_INT < VERSION_CODE_JELLY_BEAN) + || (Build.VERSION.SDK_INT > VERSION_CODE_JELLY_BEAN_MR2)) { + // No need to apply the fix + return; + } + + try { + // Mix in the device- and invocation-specific seed. + Class.forName("org.apache.harmony.xnet.provider.jsse.NativeCrypto") + .getMethod("RAND_seed", byte[].class) + .invoke(null, generateSeed()); + + // Mix output of Linux PRNG into OpenSSL's PRNG + @SuppressWarnings("ConstantConditions") + int bytesRead = (Integer) Class.forName( + "org.apache.harmony.xnet.provider.jsse.NativeCrypto") + .getMethod("RAND_load_file", String.class, long.class) + .invoke(null, "/dev/urandom", 1024); + if (bytesRead != 1024) { + throw new IOException( + "Unexpected number of bytes read from Linux PRNG: " + + bytesRead); + } + } catch (Exception e) { + throw new SecurityException("Failed to seed OpenSSL PRNG", e); + } + } + + /** + * Installs a Linux PRNG-backed {@code SecureRandom} implementation as the + * default. Does nothing if the implementation is already the default or if + * there is not need to install the implementation. + * + * @throws SecurityException if the fix is needed but could not be applied. + */ + private static void installLinuxPRNGSecureRandom() + throws SecurityException { + if (Build.VERSION.SDK_INT > VERSION_CODE_JELLY_BEAN_MR2) { + // No need to apply the fix + return; + } + + // Install a Linux PRNG-based SecureRandom implementation as the + // default, if not yet installed. + Provider[] secureRandomProviders = + Security.getProviders("SecureRandom.SHA1PRNG"); + if ((secureRandomProviders == null) + || (secureRandomProviders.length < 1) + || (!LinuxPRNGSecureRandomProvider.class.equals( + secureRandomProviders[0].getClass()))) { + Security.insertProviderAt(new LinuxPRNGSecureRandomProvider(), 1); + } + + // Assert that new SecureRandom() and + // SecureRandom.getInstance("SHA1PRNG") return a SecureRandom backed + // by the Linux PRNG-based SecureRandom implementation. + SecureRandom rng1 = new SecureRandom(); + if (!LinuxPRNGSecureRandomProvider.class.equals( + rng1.getProvider().getClass())) { + throw new SecurityException( + "new SecureRandom() backed by wrong Provider: " + + rng1.getProvider().getClass()); + } + + SecureRandom rng2; + try { + rng2 = SecureRandom.getInstance("SHA1PRNG"); + } catch (NoSuchAlgorithmException e) { + throw new SecurityException("SHA1PRNG not available", e); + } + if (!LinuxPRNGSecureRandomProvider.class.equals( + rng2.getProvider().getClass())) { + throw new SecurityException( + "SecureRandom.getInstance(\"SHA1PRNG\") backed by wrong" + + " Provider: " + rng2.getProvider().getClass()); + } + } + + /** + * {@code Provider} of {@code SecureRandom} engines which pass through + * all requests to the Linux PRNG. + */ + private static class LinuxPRNGSecureRandomProvider extends Provider { + + static final long serialVersionUID = 1L; + + LinuxPRNGSecureRandomProvider() { + super("LinuxPRNG", + 1.0, + "A Linux-specific random number provider that uses" + + " /dev/urandom"); + // Although /dev/urandom is not a SHA-1 PRNG, some apps + // explicitly request a SHA1PRNG SecureRandom and we thus need to + // prevent them from getting the default implementation whose output + // may have low entropy. + put("SecureRandom.SHA1PRNG", LinuxPRNGSecureRandom.class.getName()); + put("SecureRandom.SHA1PRNG ImplementedIn", "Software"); + } + } + + /** + * {@link SecureRandomSpi} which passes all requests to the Linux PRNG + * ({@code /dev/urandom}). + */ + static class LinuxPRNGSecureRandom extends SecureRandomSpi { + + /* + * IMPLEMENTATION NOTE: Requests to generate bytes and to mix in a seed + * are passed through to the Linux PRNG (/dev/urandom). Instances of + * this class seed themselves by mixing in the current time, PID, UID, + * build fingerprint, and hardware serial number (where available) into + * Linux PRNG. + * + * Concurrency: Read requests to the underlying Linux PRNG are + * serialized (on sLock) to ensure that multiple threads do not get + * duplicated PRNG output. + */ + + static final long serialVersionUID = 1L; + + private static final File URANDOM_FILE = new File("/dev/urandom"); + + private static final Object sLock = new Object(); + + /** + * Input stream for reading from Linux PRNG or {@code null} if not yet + * opened. + * + * @GuardedBy("sLock") + */ + private static DataInputStream sUrandomIn; + + /** + * Output stream for writing to Linux PRNG or {@code null} if not yet + * opened. + * + * @GuardedBy("sLock") + */ + private static OutputStream sUrandomOut; + + /** + * Whether this engine instance has been seeded. This is needed because + * each instance needs to seed itself if the client does not explicitly + * seed it. + */ + private boolean mSeeded; + + @Override + protected void engineSetSeed(byte[] bytes) { + try { + OutputStream out; + synchronized (sLock) { + out = getUrandomOutputStream(); + } + out.write(bytes); + out.flush(); + } catch (IOException e) { + // On a small fraction of devices /dev/urandom is not writable. + // Log and ignore. + Log.w(PRNGFixes.class.getSimpleName(), + "Failed to mix seed into " + URANDOM_FILE); + } finally { + mSeeded = true; + } + } + + @SuppressWarnings("SynchronizationOnLocalVariableOrMethodParameter") + @Override + protected void engineNextBytes(byte[] bytes) { + if (!mSeeded) { + // Mix in the device- and invocation-specific seed. + engineSetSeed(generateSeed()); + } + + try { + DataInputStream in; + synchronized (sLock) { + in = getUrandomInputStream(); + } + synchronized (in) { + in.readFully(bytes); + } + } catch (IOException e) { + throw new SecurityException( + "Failed to read from " + URANDOM_FILE, e); + } + } + + @Override + protected byte[] engineGenerateSeed(int size) { + byte[] seed = new byte[size]; + engineNextBytes(seed); + return seed; + } + + private DataInputStream getUrandomInputStream() { + synchronized (sLock) { + if (sUrandomIn == null) { + // NOTE: Consider inserting a BufferedInputStream between + // DataInputStream and FileInputStream if you need higher + // PRNG output performance and can live with future PRNG + // output being pulled into this process prematurely. + try { + sUrandomIn = new DataInputStream( + new FileInputStream(URANDOM_FILE)); + } catch (IOException e) { + throw new SecurityException("Failed to open " + + URANDOM_FILE + " for reading", e); + } + } + return sUrandomIn; + } + } + + private OutputStream getUrandomOutputStream() throws IOException { + synchronized (sLock) { + if (sUrandomOut == null) { + sUrandomOut = new FileOutputStream(URANDOM_FILE); + } + return sUrandomOut; + } + } + } + + /** + * Generates a device- and invocation-specific seed to be mixed into the + * Linux PRNG. + */ + private static byte[] generateSeed() { + try { + ByteArrayOutputStream seedBuffer = new ByteArrayOutputStream(); + DataOutputStream seedBufferOut = + new DataOutputStream(seedBuffer); + seedBufferOut.writeLong(System.currentTimeMillis()); + seedBufferOut.writeLong(System.nanoTime()); + seedBufferOut.writeInt(Process.myPid()); + seedBufferOut.writeInt(Process.myUid()); + seedBufferOut.write(BUILD_FINGERPRINT_AND_DEVICE_SERIAL); + seedBufferOut.close(); + return seedBuffer.toByteArray(); + } catch (IOException e) { + throw new SecurityException("Failed to generate seed", e); + } + } + + /** + * Gets the hardware serial number of this device. + * + * @return serial number or {@code null} if not available. + */ + private static String getDeviceSerialNumber() { + // We're using the Reflection API because Build.SERIAL is only available + // since API Level 9 (Gingerbread, Android 2.3). + try { + return (String) Build.class.getField("SERIAL").get(null); + } catch (Exception ignored) { + return null; + } + } + + @SuppressWarnings("CharsetObjectCanBeUsed") + private static byte[] getBuildFingerprintAndDeviceSerial() { + StringBuilder result = new StringBuilder(); + String fingerprint = Build.FINGERPRINT; + if (fingerprint != null) { + result.append(fingerprint); + } + String serial = getDeviceSerialNumber(); + if (serial != null) { + result.append(serial); + } + try { + return result.toString().getBytes("UTF-8"); + } catch (UnsupportedEncodingException e) { + throw new RuntimeException("UTF-8 encoding not supported"); + } + } +} \ No newline at end of file diff --git a/src/hu/blint/ssldroid/Relay.java b/src/hu/blint/ssldroid/Relay.java index 4fa59a4..c70f7ca 100644 --- a/src/hu/blint/ssldroid/Relay.java +++ b/src/hu/blint/ssldroid/Relay.java @@ -7,17 +7,17 @@ import android.util.Log; -public class Relay extends Thread { +class Relay extends Thread { /** - * + * */ private final TcpProxyServerThread tcpProxyServerThread; - private InputStream in; - private OutputStream out; - private String side; - private int sessionid; + private final InputStream in; + private final OutputStream out; + private final String side; + private final int sessionid; private final static int BUFSIZ = 4096; - private byte buf[] = new byte[BUFSIZ]; + private final byte[] buf = new byte[BUFSIZ]; public Relay(TcpProxyServerThread tcpProxyServerThread, InputStream in, OutputStream out, String side, int sessionid) { this.tcpProxyServerThread = tcpProxyServerThread; @@ -28,7 +28,7 @@ public Relay(TcpProxyServerThread tcpProxyServerThread, InputStream in, OutputSt } public void run() { - int n = 0; + int n; try { while ((n = in.read(buf)) > 0) { diff --git a/src/hu/blint/ssldroid/SSLDroid.java b/src/hu/blint/ssldroid/SSLDroid.java index 0870de3..5552f8c 100644 --- a/src/hu/blint/ssldroid/SSLDroid.java +++ b/src/hu/blint/ssldroid/SSLDroid.java @@ -1,26 +1,37 @@ package hu.blint.ssldroid; -import hu.blint.ssldroid.TcpProxy; -import android.app.*; +import android.app.AlertDialog; +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.app.Service; +import android.app.job.JobInfo; +import android.app.job.JobScheduler; +import android.content.ComponentName; +import android.content.Context; import android.content.Intent; +import android.content.IntentFilter; import android.content.pm.PackageInfo; import android.content.pm.PackageManager; import android.content.pm.PackageManager.NameNotFoundException; import android.database.Cursor; +import android.os.Build; import android.os.IBinder; import android.util.Log; -import hu.blint.ssldroid.db.SSLDroidDbAdapter; public class SSLDroid extends Service { - final String TAG = "SSLDroid"; - TcpProxy tp[]; - private SSLDroidDbAdapter dbHelper; + private final String TAG = "SSLDroid"; + private TcpProxy[] tp; + private SSLDroidDbAdapter dbHelper = new SSLDroidDbAdapter(this); - @Override - public void onCreate() { + private int NOTIFICATION_ID = 137; + + public int startServing() { + //initialize secure random Generation + PRNGFixes.apply(); - dbHelper = new SSLDroidDbAdapter(this); dbHelper.open(); Cursor cursor = dbHelper.fetchAllTunnels(); @@ -28,7 +39,7 @@ public void onCreate() { //skip start if the db is empty yet if (tunnelcount == 0) - return; + return 0; tp = new TcpProxy[tunnelcount]; @@ -36,49 +47,119 @@ public void onCreate() { for (i=0; i= Build.VERSION_CODES.N) + createNetworkChangeListener(); + Notification.Builder builder = createNotification(true, "SSLDroid is running", "Started and serving "+tunnelcount+" tunnel(s)"); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + startForeground(NOTIFICATION_ID, builder.build()); + } + else + displayNotification(builder); } @Override @@ -88,7 +169,7 @@ public int onStartCommand(Intent intent, int flags, int startId) { @Override public IBinder onBind(Intent intent) { - return null; + return null; } @Override @@ -102,33 +183,39 @@ public void onDestroy() { } catch (Exception e) { Log.d("SSLDroid", "Error stopping service: " + e.toString()); } - removeNotification(0); + removeNotification(); Log.d(TAG, "SSLDroid Service Stopped"); } - public void removeNotification(int id) { + private void removeNotification() { NotificationManager notificationManager = (NotificationManager) getSystemService(NOTIFICATION_SERVICE); - notificationManager.cancel(id); + assert notificationManager != null; + notificationManager.cancel(NOTIFICATION_ID); } - public void createNotification(int id, boolean persistent, String title, String text) { - NotificationManager notificationManager = (NotificationManager) getSystemService(NOTIFICATION_SERVICE); - Notification notification = new Notification(R.drawable.icon, - "SSLDroid startup", System.currentTimeMillis()); - // if requested, make the notification persistent, e.g. not clearable by the user at all, - // automatically hide on displaying the main activity otherwise - if (persistent == true) - notification.flags |= Notification.FLAG_NO_CLEAR; - else - notification.flags |= Notification.FLAG_AUTO_CANCEL; - - notification.flags |= Notification.FLAG_ONGOING_EVENT; - notification.priority = Notification.PRIORITY_MIN; - notification.tickerText = null; - - Intent intent = new Intent(this, SSLDroidGui.class); - PendingIntent activity = PendingIntent.getActivity(this, 0, intent, 0); - notification.setLatestEventInfo(this, title, text, activity); - notificationManager.notify(id, notification); + private Notification.Builder createNotification(boolean persistent, String title, String text) { + Context context = getApplicationContext(); + Intent mainIntent = new Intent(context, SSLDroidGui.class); + PendingIntent contentIntent = PendingIntent.getActivity(context, 0, mainIntent, PendingIntent.FLAG_IMMUTABLE); + + Notification.Builder builder = new Notification.Builder(context); + builder.setSmallIcon(R.drawable.icon) + .setContentTitle(title) + .setContentText(text) + .setWhen(System.currentTimeMillis()) + .setAutoCancel(true) + .setContentIntent(contentIntent) + .setPriority(Notification.PRIORITY_HIGH); + if (persistent) + builder.setOngoing(true); + NotificationManager notificationManager = + (NotificationManager) this.getSystemService(Context.NOTIFICATION_SERVICE); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + String channelId = "REMINDERS"; + NotificationChannel channel = new NotificationChannel(channelId,"Reminder", NotificationManager.IMPORTANCE_DEFAULT); + notificationManager.createNotificationChannel(channel); + builder.setChannelId(channelId); + } + return builder; } } diff --git a/src/hu/blint/ssldroid/db/SSLDroidDbAdapter.java b/src/hu/blint/ssldroid/SSLDroidDbAdapter.java similarity index 79% rename from src/hu/blint/ssldroid/db/SSLDroidDbAdapter.java rename to src/hu/blint/ssldroid/SSLDroidDbAdapter.java index f48576a..8219e9e 100644 --- a/src/hu/blint/ssldroid/db/SSLDroidDbAdapter.java +++ b/src/hu/blint/ssldroid/SSLDroidDbAdapter.java @@ -1,4 +1,4 @@ -package hu.blint.ssldroid.db; +package hu.blint.ssldroid; import android.content.ContentValues; import android.content.Context; @@ -17,11 +17,12 @@ public class SSLDroidDbAdapter { public static final String KEY_PKCSFILE = "pkcsfile"; public static final String KEY_PKCSPASS = "pkcspass"; public static final String KEY_CACERTFILE = "cacertfile"; - public static final String KEY_STATUS_NAME = "name"; - public static final String KEY_STATUS_VALUE = "value"; + public static final String KEY_USE_SNI = "usesni"; + private static final String KEY_STATUS_NAME = "name"; + private static final String KEY_STATUS_VALUE = "value"; private static final String DATABASE_TABLE = "tunnels"; private static final String STATUS_TABLE = "status"; - private Context context; + private final Context context; private SQLiteDatabase database; private SSLDroidDbHelper dbHelper; @@ -29,10 +30,9 @@ public SSLDroidDbAdapter(Context context) { this.context = context; } - public SSLDroidDbAdapter open() throws SQLException { + public void open() throws SQLException { dbHelper = new SSLDroidDbHelper(context); database = dbHelper.getWritableDatabase(); - return this; } public void close() { @@ -45,9 +45,9 @@ public void close() { * rowId for that note, otherwise return a -1 to indicate failure. */ public long createTunnel(String name, int localport, String remotehost, int remoteport, - String pkcsfile, String pkcspass, String cacertfile) { + String pkcsfile, String pkcspass, String cacertfile, int usesni) { ContentValues initialValues = createContentValues(name, localport, remotehost, - remoteport, pkcsfile, pkcspass, cacertfile); + remoteport, pkcsfile, pkcspass, cacertfile, usesni); return database.insert(DATABASE_TABLE, null, initialValues); } @@ -55,20 +55,20 @@ public long createTunnel(String name, int localport, String remotehost, int remo /** * Update the tunnel */ - public boolean updateTunnel(long rowId, String name, int localport, String remotehost, - int remoteport, String pkcsfile, String pkcspass, String cacertfile) { + public void updateTunnel(long rowId, String name, int localport, String remotehost, + int remoteport, String pkcsfile, String pkcspass, String cacertfile, int usesni) { ContentValues updateValues = createContentValues(name, localport, remotehost, - remoteport, pkcsfile, pkcspass, cacertfile); + remoteport, pkcsfile, pkcspass, cacertfile, usesni); - return database.update(DATABASE_TABLE, updateValues, KEY_ROWID + "=" - + rowId, null) > 0; + database.update(DATABASE_TABLE, updateValues, KEY_ROWID + "=" + + rowId, null); } /** * Deletes tunnel */ - public boolean deleteTunnel(long rowId) { - return database.delete(DATABASE_TABLE, KEY_ROWID + "=" + rowId, null) > 0; + public void deleteTunnel(long rowId) { + database.delete(DATABASE_TABLE, KEY_ROWID + "=" + rowId, null); } /** @@ -79,7 +79,7 @@ public boolean deleteTunnel(long rowId) { public Cursor fetchAllTunnels() { return database.query(DATABASE_TABLE, new String[] { KEY_ROWID, KEY_NAME, KEY_LOCALPORT, KEY_REMOTEHOST, KEY_REMOTEPORT, KEY_PKCSFILE, - KEY_PKCSPASS, KEY_CACERTFILE + KEY_PKCSPASS, KEY_CACERTFILE, KEY_USE_SNI }, null, null, null, null, null); } @@ -97,7 +97,7 @@ public Cursor fetchAllLocalPorts() { /** * Return a Cursor positioned at the defined tunnel */ - public Cursor fetchStatus(String valuename) throws SQLException { + private Cursor fetchStatus(String valuename) throws SQLException { return database.query(STATUS_TABLE, new String[] { KEY_STATUS_NAME, KEY_STATUS_VALUE }, @@ -108,23 +108,23 @@ public Cursor getStopStatus() { return fetchStatus("stopped"); } - public boolean setStopStatus() { + @SuppressWarnings("SameReturnValue") + public void setStopStatus() { ContentValues stopStatus = new ContentValues(); stopStatus.put(KEY_STATUS_NAME, "stopped"); stopStatus.put(KEY_STATUS_VALUE, "yes"); if (getStopStatus().getCount() == 0) database.insert(STATUS_TABLE, null, stopStatus); - return true; } - public boolean delStopStatus() { - return database.delete(STATUS_TABLE, KEY_STATUS_NAME+"= 'stopped'", null) > 0; + public void delStopStatus() { + database.delete(STATUS_TABLE, KEY_STATUS_NAME + "= 'stopped'", null); } public Cursor fetchTunnel(long rowId) throws SQLException { Cursor mCursor = database.query(true, DATABASE_TABLE, new String[] { KEY_ROWID, KEY_NAME, KEY_LOCALPORT, KEY_REMOTEHOST, KEY_REMOTEPORT, - KEY_PKCSFILE, KEY_PKCSPASS, KEY_CACERTFILE + KEY_PKCSFILE, KEY_PKCSPASS, KEY_CACERTFILE, KEY_USE_SNI }, KEY_ROWID + "=" + rowId, null, null, null, null, null); if (mCursor != null) { @@ -134,16 +134,16 @@ public Cursor fetchTunnel(long rowId) throws SQLException { } private ContentValues createContentValues(String name, int localport, String remotehost, int remoteport, - String pkcsfile, String pkcspass, String cacertfile) { + String pkcsfile, String pkcspass, String cacertfile, int usesni) { ContentValues values = new ContentValues(); values.put(KEY_NAME, name); values.put(KEY_LOCALPORT, localport); values.put(KEY_REMOTEHOST, remotehost); values.put(KEY_REMOTEPORT, remoteport); - values.put(KEY_REMOTEPORT, remoteport); values.put(KEY_PKCSFILE, pkcsfile); values.put(KEY_PKCSPASS, pkcspass); values.put(KEY_CACERTFILE, cacertfile); + values.put(KEY_USE_SNI, usesni); return values; } } diff --git a/src/hu/blint/ssldroid/db/SSLDroidDbHelper.java b/src/hu/blint/ssldroid/SSLDroidDbHelper.java similarity index 70% rename from src/hu/blint/ssldroid/db/SSLDroidDbHelper.java rename to src/hu/blint/ssldroid/SSLDroidDbHelper.java index 7a8083f..2fbbd07 100644 --- a/src/hu/blint/ssldroid/db/SSLDroidDbHelper.java +++ b/src/hu/blint/ssldroid/SSLDroidDbHelper.java @@ -1,21 +1,21 @@ -package hu.blint.ssldroid.db; +package hu.blint.ssldroid; import android.content.Context; import android.database.sqlite.SQLiteDatabase; import android.database.sqlite.SQLiteOpenHelper; import android.util.Log; -public class SSLDroidDbHelper extends SQLiteOpenHelper { +class SSLDroidDbHelper extends SQLiteOpenHelper { private static final String DATABASE_NAME = "applicationdata"; - private static final int DATABASE_VERSION = 3; + private static final int DATABASE_VERSION = 4; // Database creation sql statement private static final String DATABASE_CREATE = "CREATE TABLE IF NOT EXISTS tunnels (_id integer primary key autoincrement, " + "name text not null, localport integer not null, remotehost text not null, " - + "remoteport integer not null, pkcsfile text not null, pkcspass text, cacertfile text );"; + + "remoteport integer not null, pkcsfile text not null, pkcspass text, cacertfile text, usesni integer not null );"; private static final String STATUS_CREATE = "CREATE TABLE IF NOT EXISTS status (name text, value text);"; - public SSLDroidDbHelper(Context context) { + SSLDroidDbHelper(Context context) { super(context, DATABASE_NAME, null, DATABASE_VERSION); } @@ -29,14 +29,17 @@ public void onCreate(SQLiteDatabase database) { // Method is called during an update of the database, e.g. if you increase // the database version @Override - public void onUpgrade(SQLiteDatabase database, int oldVersion, - int newVersion) { + public void onUpgrade(SQLiteDatabase database, int oldVersion, int newVersion) { Log.w(SSLDroidDbHelper.class.getName(), - "Upgrading database from version " + oldVersion + " to " - + newVersion + ", which will add a status table"); + "Upgrading database from version " + oldVersion + " to " + + newVersion + ", which will add a status table"); database.execSQL("CREATE TABLE IF NOT EXISTS status (name text, value text);"); - if (oldVersion < 3) + if (oldVersion < 3) { + database.execSQL("ALTER TABLE tunnels ADD COLUMN usesni integer not null default 1;"); + } + if (oldVersion < 4) { database.execSQL("ALTER TABLE tunnels ADD cacertfile text;"); + } onCreate(database); } } diff --git a/src/hu/blint/ssldroid/SSLDroidGui.java b/src/hu/blint/ssldroid/SSLDroidGui.java index 95dc089..d7e4ec3 100644 --- a/src/hu/blint/ssldroid/SSLDroidGui.java +++ b/src/hu/blint/ssldroid/SSLDroidGui.java @@ -3,6 +3,7 @@ import android.app.ListActivity; import android.content.Intent; import android.database.Cursor; +import android.os.Build; import android.os.Bundle; import android.util.Log; import android.view.ContextMenu; @@ -14,7 +15,6 @@ import android.widget.AdapterView.AdapterContextMenuInfo; import android.widget.ListView; import android.widget.SimpleCursorAdapter; -import hu.blint.ssldroid.db.SSLDroidDbAdapter; public class SSLDroidGui extends ListActivity { private SSLDroidDbAdapter dbHelper; @@ -63,7 +63,12 @@ public boolean onMenuItemSelected(int featureId, MenuItem item) { case R.id.startservice: Log.d("SSLDroid", "Starting service"); dbHelper.delStopStatus(); - startService(new Intent(this, SSLDroid.class)); + Intent i = new Intent(this, SSLDroid.class); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + this.startForegroundService(i); + } else { + this.startService(i); + } return true; case R.id.readlogs: readLogs(); @@ -90,7 +95,12 @@ public boolean onOptionsItemSelected(MenuItem item) { case R.id.startservice: Log.d("SSLDroid", "Starting service"); dbHelper.delStopStatus(); - startService(new Intent(this, SSLDroid.class)); + Intent i = new Intent(this, SSLDroid.class); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + this.startForegroundService(i); + } else { + this.startService(i); + } return true; case R.id.readlogs: readLogs(); @@ -124,7 +134,7 @@ private void createTunnel() { startActivityForResult(i, ACTIVITY_CREATE); } - public void cloneTunnel(long id) { + private void cloneTunnel(long id) { Intent i = new Intent(this, SSLDroidTunnelDetails.class); i.putExtra(SSLDroidDbAdapter.KEY_ROWID, id); i.putExtra("doClone", true); @@ -136,12 +146,6 @@ private void readLogs() { startActivity(i); } - @SuppressWarnings("unused") - private void getProvisioning() { - //Intent i = new Intent(this, SSLDroidProvisioning.class); - //startActivity(i); - } - // ListView and view (row) on which was clicked, position and @Override protected void onListItemClick(ListView l, View v, int position, long id) { @@ -173,7 +177,7 @@ private void fillData() { // Now create an array adapter and set it to display using our row SimpleCursorAdapter tunnels = new SimpleCursorAdapter(this, - R.layout.tunnel_list_item, cursor, from, to); + R.layout.tunnel_list_item, cursor, from, to, 0); setListAdapter(tunnels); } @@ -191,5 +195,5 @@ public void onDestroy (){ dbHelper.close(); super.onDestroy(); } - + } diff --git a/src/hu/blint/ssldroid/SSLDroidProvisioning.java b/src/hu/blint/ssldroid/SSLDroidProvisioning.java new file mode 100644 index 0000000..285ef9c --- /dev/null +++ b/src/hu/blint/ssldroid/SSLDroidProvisioning.java @@ -0,0 +1,6 @@ +package hu.blint.ssldroid; + +import android.app.Activity; + +public class SSLDroidProvisioning extends Activity { +} diff --git a/src/hu/blint/ssldroid/SSLDroidReadLogs.java b/src/hu/blint/ssldroid/SSLDroidReadLogs.java index 0e31b0e..da34884 100644 --- a/src/hu/blint/ssldroid/SSLDroidReadLogs.java +++ b/src/hu/blint/ssldroid/SSLDroidReadLogs.java @@ -41,10 +41,10 @@ public void onCreate(Bundle savedInstanceState) { refreshLogs(); } - public void refreshLogs() { - TextView logcontainer = (TextView) findViewById(R.id.logTextView); + private void refreshLogs() { + TextView logcontainer = findViewById(R.id.logTextView); logcontainer.setText(""); - Process mLogcatProc = null; + Process mLogcatProc; BufferedReader reader = null; try { mLogcatProc = Runtime.getRuntime().exec(new String[] @@ -71,9 +71,9 @@ public void refreshLogs() { } } - public void shareLogs() { + private void shareLogs() { Intent sendIntent = new Intent(); - TextView logcontainer = (TextView) findViewById(R.id.logTextView); + TextView logcontainer = findViewById(R.id.logTextView); CharSequence logdata = logcontainer.getText(); sendIntent.setAction(Intent.ACTION_SEND); diff --git a/src/hu/blint/ssldroid/SSLDroidTunnelDetails.java b/src/hu/blint/ssldroid/SSLDroidTunnelDetails.java index be538b7..f52692b 100644 --- a/src/hu/blint/ssldroid/SSLDroidTunnelDetails.java +++ b/src/hu/blint/ssldroid/SSLDroidTunnelDetails.java @@ -29,41 +29,42 @@ import android.database.Cursor; import android.net.ConnectivityManager; import android.os.AsyncTask; +import android.os.Build; import android.os.Bundle; import android.os.Environment; import android.util.Log; import android.view.View; import android.widget.Button; +import android.widget.CheckBox; import android.widget.EditText; import android.widget.Toast; -import hu.blint.ssldroid.db.SSLDroidDbAdapter; -//TODO: cacert + crl should be configurable for the tunnel //TODO: test connection button public class SSLDroidTunnelDetails extends Activity { private final class SSLDroidTunnelHostnameChecker extends AsyncTask { + @Override + protected Boolean doInBackground(String... params) { + ConnectivityManager conMgr = (ConnectivityManager)getSystemService(Context.CONNECTIVITY_SERVICE); + String hostname = params[0]; - @Override - protected Boolean doInBackground(String... params) { - ConnectivityManager conMgr = (ConnectivityManager)getSystemService(Context.CONNECTIVITY_SERVICE); - String hostname = params[0]; - - if ( conMgr.getActiveNetworkInfo() != null || conMgr.getActiveNetworkInfo().isAvailable()) { - try { - InetAddress.getByName(hostname); - } catch (UnknownHostException e) { - return false; - } - } - return true; - } - protected void onPostExecute(Boolean result) { - if (result == false) { - Toast.makeText(getBaseContext(), "Remote host not found, please recheck...", Toast.LENGTH_LONG).show(); - } - } + if ( conMgr.getActiveNetworkInfo() != null || conMgr.getActiveNetworkInfo().isAvailable()) { + try { + InetAddress hostAddress = InetAddress.getByName(hostname); + if (hostAddress.getHostAddress() != "") + return true; + } catch (UnknownHostException e) { + return false; + } + } + return true; + } + protected void onPostExecute(Boolean result) { + if (!result) { + Toast.makeText(getBaseContext(), "Remote host not found, please recheck...", Toast.LENGTH_LONG).show(); + } + } } private final class SSLDroidTunnelValidator implements View.OnClickListener { @@ -79,7 +80,7 @@ public void onClick(View view) { } else { //local port should be between 1025-65535 - int cPort = 0; + int cPort; try { cPort = Integer.parseInt(localport.getText().toString()); } catch (NumberFormatException e) { @@ -110,9 +111,9 @@ public void onClick(View view) { return; } else { - //if we have interwebs access, the remote host should exist - String hostname = remotehost.getText().toString(); - new SSLDroidTunnelHostnameChecker().execute(hostname); + //if we have interwebs access, the remote host should exist + String hostname = remotehost.getText().toString(); + new SSLDroidTunnelHostnameChecker().execute(hostname); } //remote port validation @@ -122,7 +123,7 @@ public void onClick(View view) { } else { //remote port should be between 1025-65535 - int cPort = 0; + int cPort; try { cPort = Integer.parseInt(remoteport.getText().toString()); } catch (NumberFormatException e) { @@ -139,7 +140,7 @@ public void onClick(View view) { String cPkcsFile = pkcsfile.getText().toString(); String cPkcsPass = pkcspass.getText().toString(); try { - if (checkKeys(cPkcsFile, cPkcsPass) == false) { + if (!checkKeys(cPkcsFile, cPkcsPass)) { return; } } catch (Exception e) { @@ -160,6 +161,7 @@ public void onClick(View view) { private EditText pkcsfile; private EditText pkcspass; private EditText cacertfile; + private CheckBox usesni; private Long rowId; private Boolean doClone = false; private SSLDroidDbAdapter dbHelper; @@ -179,17 +181,18 @@ protected void onCreate(Bundle bundle) { pkcsfile = (EditText) findViewById(R.id.pkcsfile); pkcspass = (EditText) findViewById(R.id.pkcspass); cacertfile = (EditText) findViewById(R.id.cacertfile); + usesni = (CheckBox) findViewById(R.id.usesni); Button pickFile = (Button) findViewById(R.id.pickFile); Button pickCaFile = (Button) findViewById(R.id.pickCaFile); pickFile.setOnClickListener(new View.OnClickListener() { public void onClick(View view) { - pickFileSimple(pkcsfile, pkcspass); + pickFileSimple(getResources().getString(R.string.key_file_pick), pkcsfile, pkcspass); } }); pickCaFile.setOnClickListener(new View.OnClickListener() { public void onClick(View view) { - pickFileSimple(cacertfile, null); + pickFileSimple(getResources().getString(R.string.ca_file_pick), cacertfile, null); } }); @@ -205,9 +208,9 @@ public void onClick(View view) { confirmButton.setOnClickListener(new SSLDroidTunnelValidator()); } - final List getFileNames(File url, File baseurl) + private List getFileNames(File url) { - final List names = new LinkedList(); + final List names = new LinkedList<>(); File[] files = url.listFiles(); if (files != null && files.length > 0) { for (File file : url.listFiles()) { @@ -219,31 +222,31 @@ final List getFileNames(File url, File baseurl) return names; } - private void showFiles(final List names, final File baseurl, final EditText editBox, final View nextView) { + private void showFiles(final String title, final List names, final File baseurl, final EditText editBox, final View nextView) { final String[] namesList = new String[names.size()]; // = names.toArray(new String[] {}); ListIterator filelist = names.listIterator(); int i = 0; while (filelist.hasNext()) { File file = filelist.next(); + namesList[i] = file.getAbsolutePath().replaceFirst(baseurl+"/", ""); if (file.isDirectory()) - namesList[i] = file.getAbsolutePath().replaceFirst(baseurl+"/", "")+" (...)"; + namesList[i] = namesList[i]+" (...)"; else - namesList[i] = file.getAbsolutePath().replaceFirst(baseurl+"/", ""); - i++; + i++; } //Log.d("SSLDroid", "Gathered file names: "+namesList.toString()); // prompt user to select any file from the sdcard root new AlertDialog.Builder(SSLDroidTunnelDetails.this) - .setTitle(R.string.file_pick) + .setTitle(title) .setItems(namesList, new OnClickListener() { public void onClick(DialogInterface arg0, int arg1) { File name = names.get(arg1); if (name.isDirectory()) { - List names_ = getFileNames(name, baseurl); + List names_ = getFileNames(name); Collections.sort(names_); if (names_.size() > 0) { - showFiles(names_, baseurl, editBox, nextView); + showFiles(title, names_, baseurl, editBox, nextView); } else Toast.makeText(getBaseContext(), "Empty directory", Toast.LENGTH_LONG).show(); @@ -261,14 +264,16 @@ public void onClick(DialogInterface arg0, int arg1) { if (names.size() == 0) return; File name = names.get(0); - if (!name.getParentFile().equals(baseurl)) { - List names_ = getFileNames(name.getParentFile().getParentFile(), baseurl); - Collections.sort(names_); - if (names_.size() > 0) { - showFiles(names_, baseurl, editBox, nextView); + File parentfile = name.getParentFile(); + if (parentfile != null && !parentfile.equals(baseurl)) { + File grandparentfile = parentfile.getParentFile(); + if (grandparentfile != null) { + List names_ = getFileNames(grandparentfile); + Collections.sort(names_); + if (names_.size() > 0) { + showFiles(title, names_, baseurl, editBox, nextView); + } } - else - return; } } }) @@ -276,7 +281,7 @@ public void onClick(DialogInterface arg0, int arg1) { } //pick a file from /sdcard, courtesy of ConnectBot - private void pickFileSimple(final EditText editBox, final View nextView) { + private void pickFileSimple(final String title, final EditText editBox, final View nextView) { // build list of all files in sdcard root final File sdcard = Environment.getExternalStorageDirectory(); Log.d("SSLDroid", "SD Card location: "+sdcard.toString()); @@ -291,10 +296,10 @@ private void pickFileSimple(final EditText editBox, final View nextView) { return; } - List names = new LinkedList(); - names = getFileNames(sdcard, sdcard); + List names; + names = getFileNames(sdcard); Collections.sort(names); - showFiles(names, sdcard, editBox, nextView); + showFiles(title, names, sdcard, editBox, nextView); } private void populateFields() { @@ -318,17 +323,23 @@ private void populateFields() { .getColumnIndexOrThrow(SSLDroidDbAdapter.KEY_PKCSPASS))); cacertfile.setText(Tunnel.getString(Tunnel .getColumnIndexOrThrow(SSLDroidDbAdapter.KEY_CACERTFILE))); + if (Tunnel.getInt(Tunnel.getColumnIndexOrThrow(SSLDroidDbAdapter.KEY_USE_SNI)) != 0){ + usesni.setChecked(true); + } + else{ + usesni.setChecked(false); + } } } - public boolean checkKeys(String inCertPath, String passw) throws Exception { + private boolean checkKeys(String inCertPath, String passw) throws Exception { try { FileInputStream in_cert = new FileInputStream(inCertPath); KeyStore myStore = KeyStore.getInstance("PKCS12"); myStore.load(in_cert, passw.toCharArray()); Enumeration eAliases = myStore.aliases(); while (eAliases.hasMoreElements()) { - String strAlias = (String) eAliases.nextElement(); + String strAlias = eAliases.nextElement(); if (myStore.isKeyEntry(strAlias)) { // try to retrieve the private key part from PKCS12 certificate myStore.getKey(strAlias, passw.toCharArray()); @@ -369,12 +380,6 @@ protected void onSaveInstanceState(Bundle outState) { outState.putSerializable(SSLDroidDbAdapter.KEY_ROWID, rowId); } - @Override - protected void onPause() { - super.onPause(); - //saveState(); - } - @Override protected void onResume() { super.onResume(); @@ -387,16 +392,21 @@ private void saveState() { try { sLocalport = Integer.parseInt(localport.getText().toString()); } catch (NumberFormatException e) { + Log.e("SSLDroid", "Invalid local port number format; format='"+localport.getText().toString()+"'"); } String sRemotehost = remotehost.getText().toString(); int sRemoteport = 0; try { sRemoteport = Integer.parseInt(remoteport.getText().toString()); } catch (NumberFormatException e) { + Log.e("SSLDroid", "Invalid remote port number format; format='"+remoteport.getText().toString()+"'"); } String sPkcsfile = pkcsfile.getText().toString(); String sPkcspass = pkcspass.getText().toString(); String sCacertfile = cacertfile.getText().toString(); + Integer sUsesni = 1; + if (!usesni.isChecked()) + sUsesni = 0; //make sure that we have all of our values correctly set if (sName.length() == 0) { @@ -412,23 +422,27 @@ private void saveState() { return; } + Log.d("SSLDroid", "Saving settings..."); if (rowId == null || doClone) { long id = dbHelper.createTunnel(sName, sLocalport, sRemotehost, - sRemoteport, sPkcsfile, sPkcspass, sCacertfile); + sRemoteport, sPkcsfile, sPkcspass, sCacertfile, sUsesni); if (id > 0) { rowId = id; } } else { dbHelper.updateTunnel(rowId, sName, sLocalport, sRemotehost, sRemoteport, - sPkcsfile, sPkcspass, sCacertfile); + sPkcsfile, sPkcspass, sCacertfile, sUsesni); } - Log.d("SSLDroid", "Saving settings..."); //restart the service - stopService(new Intent(this, SSLDroid.class)); - startService(new Intent(this, SSLDroid.class)); Log.d("SSLDroid", "Restarting service after settings save..."); - + stopService(new Intent(this, SSLDroid.class)); + Intent i = new Intent(this, SSLDroid.class); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + this.startForegroundService(i); + } else { + this.startService(i); + } } } diff --git a/src/hu/blint/ssldroid/TcpProxy.java b/src/hu/blint/ssldroid/TcpProxy.java index 0c1cb56..c6ad875 100644 --- a/src/hu/blint/ssldroid/TcpProxy.java +++ b/src/hu/blint/ssldroid/TcpProxy.java @@ -1,21 +1,23 @@ package hu.blint.ssldroid; -import java.io.IOException; import android.util.Log; /** * This is a modified version of the TcpTunnelGui utility borrowed from the * xml.apache.org project. */ -public class TcpProxy { - String tunnelName; - int listenPort; - String tunnelHost; - int tunnelPort; - String keyFile, keyPass, caCertFile; - TcpProxyServerThread server = null; +class TcpProxy { + private final String tunnelName; + private final int listenPort; + private final String tunnelHost; + private final int tunnelPort; + private final String keyFile; + private final String keyPass; + private final String caCertFile; + private final boolean useSNI; + private TcpProxyServerThread server = null; - public TcpProxy(String tunnelName, int listenPort, String targetHost, int targetPort, String keyFile, String keyPass, String caCertFile) { + public TcpProxy(String tunnelName, int listenPort, String targetHost, int targetPort, String keyFile, String keyPass, String caCertFile, boolean useSNI) { this.tunnelName = tunnelName; this.listenPort = listenPort; this.tunnelHost = targetHost; @@ -23,11 +25,13 @@ public TcpProxy(String tunnelName, int listenPort, String targetHost, int target this.keyFile = keyFile; this.keyPass = keyPass; this.caCertFile = caCertFile; + this.useSNI = useSNI; } - public void serve() throws IOException { + public void serve() { server = new TcpProxyServerThread(this.tunnelName, this.listenPort, this.tunnelHost, - this.tunnelPort, this.keyFile, this.keyPass, this.caCertFile); + this.tunnelPort, this.keyFile, this.keyPass, + this.caCertFile, this.useSNI); server.start(); } diff --git a/src/hu/blint/ssldroid/TcpProxyServerThread.java b/src/hu/blint/ssldroid/TcpProxyServerThread.java index 7b3516e..f52c0d1 100644 --- a/src/hu/blint/ssldroid/TcpProxyServerThread.java +++ b/src/hu/blint/ssldroid/TcpProxyServerThread.java @@ -26,22 +26,29 @@ import javax.net.ssl.TrustManager; import javax.net.ssl.X509TrustManager; +import android.annotation.TargetApi; +import android.os.Build; import android.util.Log; -public class TcpProxyServerThread extends Thread { +class TcpProxyServerThread extends Thread { - String tunnelName; - int listenPort; - String tunnelHost; - int tunnelPort; - String keyFile, keyPass, caFile; + final String tunnelName; + private final int listenPort; + private final String tunnelHost; + private final int tunnelPort; + private final String keyFile; + private final String keyPass; + private final String caFile; + private final boolean useSNI; Relay inRelay, outRelay; ServerSocket ss = null; - int sessionid = 0; + private int sessionid = 0; private SSLSocketFactory sslSocketFactory; private X509Certificate caCert; - public TcpProxyServerThread(String tunnelName, int listenPort, String tunnelHost, int tunnelPort, String keyFile, String keyPass, String caFile) { + public TcpProxyServerThread(String tunnelName, int listenPort, String tunnelHost, + int tunnelPort, String keyFile, String keyPass, String caFile, + boolean useSNI) { this.tunnelName = tunnelName; this.listenPort = listenPort; this.tunnelHost = tunnelHost; @@ -66,25 +73,9 @@ public TcpProxyServerThread(String tunnelName, int listenPort, String tunnelHost } catch (IOException ex) { } } } + this.useSNI = useSNI; } - // Create a trust manager that does not validate certificate chains - // TODO: handle this somehow properly (popup if cert is untrusted?) - // TODO: cacert + crl should be configurable - /*TrustManager[] trustAllCerts = new TrustManager[] { - new X509TrustManager() { - public java.security.cert.X509Certificate[] getAcceptedIssuers() { - return null; - } - public void checkClientTrusted( - java.security.cert.X509Certificate[] certs, String authType) { - } - public void checkServerTrusted( - java.security.cert.X509Certificate[] certs, String authType) { - } - } - };*/ - // FIXME: https://stackoverflow.com/questions/6629473/validate-x-509-certificate-agains-concrete-ca-java TrustManager[] trustCaCert = new TrustManager[] { new X509TrustManager() { @@ -136,10 +127,8 @@ public void checkServerTrusted( } }; - - - public final SSLSocketFactory getSocketFactory(String pkcsFile, - String pwd, int sessionid) { + private SSLSocketFactory getSocketFactory(String pkcsFile, + String pwd, int sessionid) { if (sslSocketFactory == null) { try { KeyManagerFactory keyManagerFactory; @@ -187,8 +176,8 @@ public void run() { } while (true) { try { - Thread fromBrowserToServer = null; - Thread fromServerToBrowser = null; + Thread fromBrowserToServer; + Thread fromServerToBrowser; if (isInterrupted()) { Log.d("SSLDroid", tunnelName+"/"+sessionid+": Interrupted server thread, closing sockets..."); @@ -204,11 +193,12 @@ public void run() { Log.d("SSLDroid", "Accept failure: " + e.toString()); } - Socket st = null; + Socket st; try { final SSLSocketFactory sf = getSocketFactory(this.keyFile, this.keyPass, this.sessionid); - st = (SSLSocket) sf.createSocket(this.tunnelHost, this.tunnelPort); - setSNIHost(sf, (SSLSocket) st, this.tunnelHost); + st = sf.createSocket(this.tunnelHost, this.tunnelPort); + if (this.useSNI) + setSNIHost(sf, (SSLSocket) st, this.tunnelHost); ((SSLSocket) st).startHandshake(); } catch (IOException e) { Log.d("SSLDroid", tunnelName+"/"+sessionid+": SSL failure: " + e.toString()); @@ -223,7 +213,7 @@ public void run() { return; } - if (sc == null || st == null) { + if (sc == null) { Log.d("SSLDroid", tunnelName+"/"+sessionid+": Trying socket operation on a null socket, returning"); return; } @@ -247,8 +237,9 @@ public void run() { } } + @TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR1) private void setSNIHost(final SSLSocketFactory factory, final SSLSocket socket, final String hostname) { - if (factory instanceof android.net.SSLCertificateSocketFactory && android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.JELLY_BEAN_MR1) { + if (factory instanceof android.net.SSLCertificateSocketFactory) { ((android.net.SSLCertificateSocketFactory)factory).setHostname(socket, hostname); } else { try { @@ -258,5 +249,5 @@ private void setSNIHost(final SSLSocketFactory factory, final SSLSocket socket, } } } -}; +} diff --git a/tests/java/hu/blint/ssldroid/SSLDroidDbAdapterTest.java b/tests/java/hu/blint/ssldroid/SSLDroidDbAdapterTest.java new file mode 100644 index 0000000..089f394 --- /dev/null +++ b/tests/java/hu/blint/ssldroid/SSLDroidDbAdapterTest.java @@ -0,0 +1,130 @@ +package hu.blint.ssldroid; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import android.content.Context; +import android.database.Cursor; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.annotation.Config; + +/** + * Unit tests for {@link SSLDroidDbAdapter} backed by Robolectric's real SQLite. + * + *

The {@link #persistsCaCertFileAndSniFlag()} case is a regression test for + * the bug where {@code createContentValues} silently dropped the + * {@code cacertfile} and {@code usesni} columns, which (a) lost the CA-pinning + * and SNI settings and (b) failed the {@code usesni NOT NULL} constraint on a + * fresh database, so tunnels could not be saved at all.

+ */ +@RunWith(RobolectricTestRunner.class) +@Config(manifest = Config.NONE) +public class SSLDroidDbAdapterTest { + + private SSLDroidDbAdapter db; + + @Before + public void setUp() { + Context context = RuntimeEnvironment.getApplication(); + db = new SSLDroidDbAdapter(context); + db.open(); + } + + @After + public void tearDown() { + db.close(); + } + + @Test + public void createTunnelReturnsPositiveRowId() { + long id = db.createTunnel("t1", 1234, "example.com", 443, + "", "", "", 1); + assertTrue("createTunnel should return a valid row id, got " + id, id > 0); + } + + @Test + public void persistsCaCertFileAndSniFlag() { + long id = db.createTunnel("pinned", 1234, "example.com", 443, + "/sdcard/client.p12", "secret", "/sdcard/ca.pem", 0); + assertTrue("createTunnel should succeed even with usesni=0", id > 0); + + Cursor c = db.fetchTunnel(id); + try { + assertEquals("pinned", + c.getString(c.getColumnIndexOrThrow(SSLDroidDbAdapter.KEY_NAME))); + assertEquals("example.com", + c.getString(c.getColumnIndexOrThrow(SSLDroidDbAdapter.KEY_REMOTEHOST))); + assertEquals(443, + c.getInt(c.getColumnIndexOrThrow(SSLDroidDbAdapter.KEY_REMOTEPORT))); + assertEquals("CA cert file must be persisted", "/sdcard/ca.pem", + c.getString(c.getColumnIndexOrThrow(SSLDroidDbAdapter.KEY_CACERTFILE))); + assertEquals("SNI flag must be persisted", 0, + c.getInt(c.getColumnIndexOrThrow(SSLDroidDbAdapter.KEY_USE_SNI))); + } finally { + c.close(); + } + } + + @Test + public void updateTunnelChangesStoredValues() { + long id = db.createTunnel("orig", 1000, "a.example", 443, + "", "", "", 1); + db.updateTunnel(id, "renamed", 2000, "b.example", 8443, + "", "", "/sdcard/new-ca.pem", 0); + + Cursor c = db.fetchTunnel(id); + try { + assertEquals("renamed", + c.getString(c.getColumnIndexOrThrow(SSLDroidDbAdapter.KEY_NAME))); + assertEquals(2000, + c.getInt(c.getColumnIndexOrThrow(SSLDroidDbAdapter.KEY_LOCALPORT))); + assertEquals("b.example", + c.getString(c.getColumnIndexOrThrow(SSLDroidDbAdapter.KEY_REMOTEHOST))); + assertEquals("/sdcard/new-ca.pem", + c.getString(c.getColumnIndexOrThrow(SSLDroidDbAdapter.KEY_CACERTFILE))); + assertEquals(0, + c.getInt(c.getColumnIndexOrThrow(SSLDroidDbAdapter.KEY_USE_SNI))); + } finally { + c.close(); + } + } + + @Test + public void deleteTunnelRemovesRow() { + long id = db.createTunnel("todelete", 1234, "example.com", 443, + "", "", "", 1); + db.deleteTunnel(id); + Cursor all = db.fetchAllTunnels(); + try { + assertEquals(0, all.getCount()); + } finally { + all.close(); + } + } + + @Test + public void stopStatusLifecycle() { + assertEquals(0, countAndClose(db.getStopStatus())); + db.setStopStatus(); + assertEquals(1, countAndClose(db.getStopStatus())); + // setting it again must remain idempotent + db.setStopStatus(); + assertEquals(1, countAndClose(db.getStopStatus())); + db.delStopStatus(); + assertEquals(0, countAndClose(db.getStopStatus())); + } + + private static int countAndClose(Cursor c) { + try { + return c.getCount(); + } finally { + c.close(); + } + } +} diff --git a/tests/java/hu/blint/ssldroid/TcpProxyE2ETest.java b/tests/java/hu/blint/ssldroid/TcpProxyE2ETest.java new file mode 100644 index 0000000..5d0c231 --- /dev/null +++ b/tests/java/hu/blint/ssldroid/TcpProxyE2ETest.java @@ -0,0 +1,190 @@ +package hu.blint.ssldroid; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.fail; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.net.SocketTimeoutException; +import java.nio.charset.StandardCharsets; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +/** + * End-to-end tests for the SSLDroid tunnel. Each test wires up the real + * production {@link TcpProxy} in front of a real {@link TlsTestBackend} and + * drives it with a plain-text client socket, asserting on the bytes that make + * the full round trip: + * + *
+ *   plain client --(cleartext)--> SSLDroid TcpProxy --(TLS)--> TlsTestBackend
+ * 
+ * + * Robolectric is used only so the production classes' {@code android.util.Log} + * calls resolve at runtime; the networking and TLS are real. + */ +@RunWith(RobolectricTestRunner.class) +@Config(manifest = Config.NONE) +public class TcpProxyE2ETest { + + private TlsTestBackend backend; + private TcpProxy proxy; + + @Before + public void setUp() throws Exception { + backend = new TlsTestBackend(); + } + + @After + public void tearDown() { + if (proxy != null) { + proxy.stop(); + } + if (backend != null) { + backend.close(); + } + } + + @Test + public void tunnelsCleartextClientToTlsBackendWithoutCaPinning() throws Exception { + int listenPort = reserveFreePort(); + // Empty CA file => the proxy trusts any server certificate. + proxy = new TcpProxy("no-pin", listenPort, "127.0.0.1", backend.getPort(), + "", "", "", false); + proxy.serve(); + + String response = roundTrip(listenPort, "HELLO"); + assertEquals("PONG:HELLO", response); + } + + @Test + public void tunnelsWhenServerCertificateMatchesConfiguredCa() throws Exception { + File caFile = resourceToTempFile("/hu/blint/ssldroid/server-cert.pem", "server-cert", ".pem"); + int listenPort = reserveFreePort(); + proxy = new TcpProxy("good-pin", listenPort, "127.0.0.1", backend.getPort(), + "", "", caFile.getAbsolutePath(), false); + proxy.serve(); + + String response = roundTrip(listenPort, "PING"); + assertEquals("PONG:PING", response); + } + + @Test + public void refusesToTunnelWhenServerCertificateDoesNotMatchConfiguredCa() throws Exception { + File caFile = resourceToTempFile("/hu/blint/ssldroid/other-cert.pem", "other-cert", ".pem"); + int listenPort = reserveFreePort(); + proxy = new TcpProxy("bad-pin", listenPort, "127.0.0.1", backend.getPort(), + "", "", caFile.getAbsolutePath(), false); + proxy.serve(); + + // The backend cert is not signed by the configured CA, so no plaintext + // must ever reach the client. We should see no "PONG" — either an EOF + // or a read timeout, never a valid tunnelled response. + String response = readWithTimeout(listenPort, "SECRET", 3000); + assertFalse("wrong-CA tunnel must not deliver backend data, got: " + response, + response.contains("PONG")); + } + + // --- helpers ----------------------------------------------------------- + + /** Performs a single request/response round trip through the proxy. */ + private static String roundTrip(int proxyPort, String request) throws IOException { + try (Socket client = connectWithRetry(proxyPort, 5000)) { + client.setSoTimeout(5000); + OutputStream out = client.getOutputStream(); + out.write(request.getBytes(StandardCharsets.UTF_8)); + out.flush(); + return readAll(client.getInputStream()); + } + } + + /** + * Sends a request and reads whatever comes back within {@code timeoutMs}, + * returning an empty string if the read times out or hits EOF immediately. + */ + private static String readWithTimeout(int proxyPort, String request, int timeoutMs) throws IOException { + try (Socket client = connectWithRetry(proxyPort, 5000)) { + client.setSoTimeout(timeoutMs); + OutputStream out = client.getOutputStream(); + out.write(request.getBytes(StandardCharsets.UTF_8)); + out.flush(); + try { + return readAll(client.getInputStream()); + } catch (SocketTimeoutException e) { + return ""; + } + } + } + + private static String readAll(InputStream in) throws IOException { + byte[] buf = new byte[4096]; + int n = in.read(buf); + if (n <= 0) { + return ""; + } + return new String(buf, 0, n, StandardCharsets.UTF_8); + } + + /** + * The proxy binds its listening socket asynchronously on its own thread, so + * the client may briefly beat it to the port. Retry connecting until the + * proxy is up or the deadline passes. + */ + private static Socket connectWithRetry(int port, long deadlineMs) throws IOException { + long deadline = System.currentTimeMillis() + deadlineMs; + IOException last = null; + while (System.currentTimeMillis() < deadline) { + try { + return new Socket(InetAddress.getByName("127.0.0.1"), port); + } catch (IOException e) { + last = e; + try { + Thread.sleep(50); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + break; + } + } + } + if (last != null) { + throw last; + } + throw new IOException("Could not connect to proxy on port " + port); + } + + /** Reserves (and releases) a free loopback TCP port for the proxy to bind. */ + private static int reserveFreePort() throws IOException { + try (ServerSocket ss = new ServerSocket(0, 1, InetAddress.getByName("127.0.0.1"))) { + return ss.getLocalPort(); + } + } + + private static File resourceToTempFile(String resource, String prefix, String suffix) throws IOException { + File tmp = File.createTempFile(prefix, suffix); + tmp.deleteOnExit(); + try (InputStream in = TcpProxyE2ETest.class.getResourceAsStream(resource); + OutputStream out = new FileOutputStream(tmp)) { + if (in == null) { + fail("Missing test resource: " + resource); + } + byte[] buf = new byte[4096]; + int n; + while ((n = in.read(buf)) > 0) { + out.write(buf, 0, n); + } + } + return tmp; + } +} diff --git a/tests/java/hu/blint/ssldroid/TlsTestBackend.java b/tests/java/hu/blint/ssldroid/TlsTestBackend.java new file mode 100644 index 0000000..217c40a --- /dev/null +++ b/tests/java/hu/blint/ssldroid/TlsTestBackend.java @@ -0,0 +1,94 @@ +package hu.blint.ssldroid; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.security.KeyStore; + +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLServerSocket; +import javax.net.ssl.SSLServerSocketFactory; +import javax.net.ssl.SSLSocket; + +/** + * A tiny TLS "echo with prefix" server used as the far end of an SSLDroid + * tunnel in the end-to-end tests. It listens on 127.0.0.1 using the bundled + * self-signed {@code testserver.p12} keystore, reads a single request line and + * writes back {@code "PONG:" + request}. + * + *

This is deliberately implemented with plain JSSE (no Android APIs) so it + * can act as a real, independent peer for the production proxy code.

+ */ +class TlsTestBackend implements Runnable { + + static final String KEYSTORE_RESOURCE = "/hu/blint/ssldroid/testserver.p12"; + static final char[] KEYSTORE_PASSWORD = "testpass".toCharArray(); + + private final SSLServerSocket serverSocket; + private volatile boolean running = true; + private final Thread thread; + + TlsTestBackend() throws Exception { + SSLContext context = SSLContext.getInstance("TLS"); + KeyStore keyStore = KeyStore.getInstance("PKCS12"); + try (InputStream in = TlsTestBackend.class.getResourceAsStream(KEYSTORE_RESOURCE)) { + if (in == null) { + throw new IllegalStateException("Missing test keystore resource: " + KEYSTORE_RESOURCE); + } + keyStore.load(in, KEYSTORE_PASSWORD); + } + KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + kmf.init(keyStore, KEYSTORE_PASSWORD); + context.init(kmf.getKeyManagers(), null, null); + + SSLServerSocketFactory factory = context.getServerSocketFactory(); + InetAddress loopback = InetAddress.getByName("127.0.0.1"); + serverSocket = (SSLServerSocket) factory.createServerSocket(0, 10, loopback); + + thread = new Thread(this, "tls-test-backend"); + thread.setDaemon(true); + thread.start(); + } + + int getPort() { + return serverSocket.getLocalPort(); + } + + @Override + public void run() { + while (running) { + try (SSLSocket client = (SSLSocket) serverSocket.accept()) { + InputStream in = client.getInputStream(); + OutputStream out = client.getOutputStream(); + byte[] buf = new byte[4096]; + int n = in.read(buf); + if (n <= 0) { + continue; + } + String request = new String(buf, 0, n, "UTF-8").trim(); + out.write(("PONG:" + request).getBytes("UTF-8")); + out.flush(); + } catch (IOException e) { + // Socket closed on shutdown, or a handshake failure from a + // negative test case. Either way keep serving until stopped. + if (!running) { + return; + } + } + } + } + + void close() { + running = false; + try { + serverSocket.close(); + } catch (IOException ignored) { + // best effort + } + thread.interrupt(); + } +} diff --git a/tests/resources/hu/blint/ssldroid/other-cert.pem b/tests/resources/hu/blint/ssldroid/other-cert.pem new file mode 100644 index 0000000..698fb91 --- /dev/null +++ b/tests/resources/hu/blint/ssldroid/other-cert.pem @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDQTCCAimgAwIBAgIIcx+y4DgmyAgwDQYJKoZIhvcNAQELBQAwTjELMAkGA1UE +BhMCSFUxDTALBgNVBAcTBFRlc3QxETAPBgNVBAoTCFNTTERyb2lkMQ0wCwYDVQQL +EwRUZXN0MQ4wDAYDVQQDEwVvdGhlcjAgFw0yNjA4MDgxMjM1MDdaGA8yMTI2MDcx +NTEyMzUwN1owTjELMAkGA1UEBhMCSFUxDTALBgNVBAcTBFRlc3QxETAPBgNVBAoT +CFNTTERyb2lkMQ0wCwYDVQQLEwRUZXN0MQ4wDAYDVQQDEwVvdGhlcjCCASIwDQYJ +KoZIhvcNAQEBBQADggEPADCCAQoCggEBAKweGayLdhNWmFPpx1u130V7f0cQLuOk +bEJcJkXpkaiIR+BW22tw47JLnzERwaCsUx5sinvxIG0Cbu/vNKpWS87bx9QOWL1P +aAlIWwNvTc5BmESnxT7W80TYw7OUkX6JXliGIBbq2QGapi0ora6n7l9R/GEtA1XR +YqWGrRCkNMQ+Ac4ZxtKOsaIXDMwtVmiPehDAtqu3CW9BZe7RZjrwbT0I+cszEhRW +RO487ONy97KdpB97Jb8XMQNMI/fB9Q2Duv+9xKAOr8WNv/B0IwD7FazGPV0TioUx +8EHL+N7nEohkg6jak9waqprItqaWqXoG2xl6c0720x5e1pdAGe0O6isCAwEAAaMh +MB8wHQYDVR0OBBYEFD4pXm0+AG2csLcAgOi+31lr7Yd6MA0GCSqGSIb3DQEBCwUA +A4IBAQCTH+X/3PZjlawfXjUyLu9GNv6REov1McvUHJl6ZV9kOpI+4P3OPvvGYA8K +4qFHrhNZd0/mDS5Eb7TkJOnkTOMmOFNxuJAkNeurgj7afzEEQIYTlbYmETCLSPG4 +ZEQD8eMAXwGtk7KxScgxLnmWe43m0XgOG8nPLFp2qgVPJKfwblj8wij1+cqaCsHq +RnRCjcU2BRuamABUuNb4iNGtHW4LT0INxvq9bGCaUJ1Hex6SzkbwFsIcj/wjAp9o +r6G6K84RCgjEXSv3QITBQRM+XbUaTd/uvqMsWfQJIbcdfqVLp+9W/2nGK8F1JRGH +yyM5MgS3NcVrIM61rUU0PhmU5/6Z +-----END CERTIFICATE----- diff --git a/tests/resources/hu/blint/ssldroid/server-cert.pem b/tests/resources/hu/blint/ssldroid/server-cert.pem new file mode 100644 index 0000000..fb866df --- /dev/null +++ b/tests/resources/hu/blint/ssldroid/server-cert.pem @@ -0,0 +1,21 @@ +-----BEGIN CERTIFICATE----- +MIIDZTCCAk2gAwIBAgIIQbBRtFcOY5AwDQYJKoZIhvcNAQELBQAwUjELMAkGA1UE +BhMCSFUxDTALBgNVBAcTBFRlc3QxETAPBgNVBAoTCFNTTERyb2lkMQ0wCwYDVQQL +EwRUZXN0MRIwEAYDVQQDEwlsb2NhbGhvc3QwIBcNMjYwODA4MTIzNTA2WhgPMjEy +NjA3MTUxMjM1MDZaMFIxCzAJBgNVBAYTAkhVMQ0wCwYDVQQHEwRUZXN0MREwDwYD +VQQKEwhTU0xEcm9pZDENMAsGA1UECxMEVGVzdDESMBAGA1UEAxMJbG9jYWxob3N0 +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAkt4Ru0EGuO4BZoa3WZ9/ +V8mZM6uDWrEaZoMxxnGH0x2HT7RbiC44+hKcJ7xqJGWSO89gKlO/TsmaDslO9tVt +1DmjjxhCoDFoVEeLsHCf8YHwFZzASMuVxHBImF1PDRgJ0SqloO1CaBJ4tr7+16i/ +FcTY5qQ+SYi4qA+g1tz74yyK4Dc/pJ3+F9HhZxxuIURE8TC+BPjnxD9MoiXOANkt +Xc+a0Jh/fu23ZNn6kGAqqT2vMPWF1E7oh4o6VqfmbqbkfTg1j0k7ctwy52NgL7hu +bxx8bXzb4V+7gTmCDw2COfj/uj6McUufDn2lW+sUrj0jMT+Zj1hFrNlobU/WtMUn +bQIDAQABoz0wOzAdBgNVHQ4EFgQU1DSG7gfFLPWFR23my+IcrtW4v+IwGgYDVR0R +BBMwEYIJbG9jYWxob3N0hwR/AAABMA0GCSqGSIb3DQEBCwUAA4IBAQB7ZaUQmLRT +u/U/lCstgImX6BheL+VItssgE4jvxVEFYUiY+nOmOd2+5G/c0ZGkBoDHi5hKF1S/ +HJfcqHjfv5VA9/aHJBoamIBng9fkyguWoEa1gInnBDQjv3DTem8k6qQbKF1o9Guv +mdTUi9xkzUIZ0J3xdfoEBoXBtjjk73ZTxOR90PklUCSKbcRPMwIPaIZ5JclkWOSF +xr6Oc1DbTpafLgqEq6MbiBDJTFyztkJ894qNBaR8vxPKkAabm8TQc5jN1GX6Ds3f +sH/p9y5e8eZR6QiYEMj2rJ02T3DhQFW4qYbDovInoGZnErD9zjl2ZCVyGxoYPJ6Y +MrDCOdumteGv +-----END CERTIFICATE----- diff --git a/tests/resources/hu/blint/ssldroid/testserver.p12 b/tests/resources/hu/blint/ssldroid/testserver.p12 new file mode 100644 index 0000000..696f679 Binary files /dev/null and b/tests/resources/hu/blint/ssldroid/testserver.p12 differ