diff --git a/app/build.gradle b/app/build.gradle index ad56600206..c6a9daf493 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -215,6 +215,7 @@ android { buildConfigField "String", "OPENAI_PRIVACY", "\"https://openai.com/policies/privacy-policy\"" buildConfigField "String", "GEMINI_ENDPOINT", "\"https://generativelanguage.googleapis.com/v1beta/\"" buildConfigField "String", "GEMINI_PRIVACY", "\"https://support.google.com/gemini/answer/13594961\"" + buildConfigField "String", "PAWNED_ENDPOINT", "\"https://api.pwnedpasswords.com/\"" buildConfigField "String", "FDROID", "\"https://f-droid.org/packages/%s/\"" } large { @@ -239,6 +240,7 @@ android { buildConfigField "String", "OPENAI_PRIVACY", "\"https://openai.com/policies/privacy-policy\"" buildConfigField "String", "GEMINI_ENDPOINT", "\"https://generativelanguage.googleapis.com/v1beta/\"" buildConfigField "String", "GEMINI_PRIVACY", "\"https://support.google.com/gemini/answer/13594961\"" + buildConfigField "String", "PAWNED_ENDPOINT", "\"https://api.pwnedpasswords.com/\"" buildConfigField "String", "FDROID", "\"https://f-droid.org/packages/%s/\"" } fdroid { @@ -272,6 +274,7 @@ android { buildConfigField "String", "OPENAI_PRIVACY", "\"https://openai.com/policies/privacy-policy\"" buildConfigField "String", "GEMINI_ENDPOINT", "\"https://generativelanguage.googleapis.com/v1beta/\"" buildConfigField "String", "GEMINI_PRIVACY", "\"https://support.google.com/gemini/answer/13594961\"" + buildConfigField "String", "PAWNED_ENDPOINT", "\"https://api.pwnedpasswords.com/\"" buildConfigField "String", "FDROID", "\"https://f-droid.org/packages/%s/\"" } play { @@ -297,6 +300,7 @@ android { buildConfigField "String", "OPENAI_PRIVACY", "\"\"" buildConfigField "String", "GEMINI_ENDPOINT", "\"\"" buildConfigField "String", "GEMINI_PRIVACY", "\"\"" + buildConfigField "String", "PAWNED_ENDPOINT", "\"\"" buildConfigField "String", "FDROID", "\"\"" getIsDefault().set(true) } @@ -323,6 +327,7 @@ android { buildConfigField "String", "OPENAI_PRIVACY", "\"\"" buildConfigField "String", "GEMINI_ENDPOINT", "\"\"" buildConfigField "String", "GEMINI_PRIVACY", "\"\"" + buildConfigField "String", "PAWNED_ENDPOINT", "\"\"" buildConfigField "String", "FDROID", "\"\"" } } diff --git a/app/src/main/java/eu/faircode/email/FragmentAccounts.java b/app/src/main/java/eu/faircode/email/FragmentAccounts.java index 4836ca92e2..d091943082 100644 --- a/app/src/main/java/eu/faircode/email/FragmentAccounts.java +++ b/app/src/main/java/eu/faircode/email/FragmentAccounts.java @@ -31,8 +31,13 @@ import android.content.SharedPreferences; import android.graphics.Canvas; import android.graphics.Color; import android.graphics.Rect; +import android.graphics.Typeface; import android.graphics.drawable.Drawable; import android.os.Bundle; +import android.text.SpannableStringBuilder; +import android.text.TextUtils; +import android.text.style.ForegroundColorSpan; +import android.text.style.StyleSpan; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuInflater; @@ -40,6 +45,7 @@ import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; import android.widget.Button; +import android.widget.ProgressBar; import android.widget.TextView; import android.widget.Toast; @@ -368,6 +374,7 @@ public class FragmentAccounts extends FragmentBase { menu.findItem(R.id.menu_show_folders).setVisible(!settings); menu.findItem(R.id.menu_theme).setVisible(!settings); menu.findItem(R.id.menu_force_sync).setVisible(!settings); + menu.findItem(R.id.menu_pwned).setVisible(settings && !TextUtils.isEmpty(BuildConfig.PAWNED_ENDPOINT)); super.onPrepareOptionsMenu(menu); } @@ -399,6 +406,9 @@ public class FragmentAccounts extends FragmentBase { } else if (itemId == R.id.menu_force_sync) { onMenuForceSync(); return true; + } else if (itemId == R.id.menu_pwned) { + onMenuPwned(); + return true; } return super.onOptionsItemSelected(item); } @@ -475,6 +485,74 @@ public class FragmentAccounts extends FragmentBase { ToastEx.makeText(getContext(), R.string.title_executing, Toast.LENGTH_LONG).show(); } + private void onMenuPwned() { + final Context context = getContext(); + final View dview = LayoutInflater.from(context).inflate(R.layout.dialog_pwned, null); + final TextView tvPwned = dview.findViewById(R.id.tvPwned); + final ProgressBar pbWait = dview.findViewById(R.id.pbWait); + final Group grpReady = dview.findViewById(R.id.grpReady); + + pbWait.setVisibility(View.VISIBLE); + grpReady.setVisibility(View.GONE); + + new AlertDialog.Builder(context) + .setView(dview) + .setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + // Do nothing + } + }) + .show(); + + new SimpleTask() { + @Override + protected void onPostExecute(Bundle args) { + pbWait.setVisibility(View.GONE); + } + + @Override + protected SpannableStringBuilder onExecute(Context context, Bundle args) throws Throwable { + SpannableStringBuilder ssb = new SpannableStringBuilder(); + + final int colorError = Helper.resolveColor(context, androidx.appcompat.R.attr.colorError); + final int colorVerified = Helper.resolveColor(context, R.attr.colorVerified); + + DB db = DB.getInstance(context); + List accounts = db.account().getAccounts(); + if (accounts != null) + for (EntityAccount account : accounts) + if (account.auth_type == AUTH_TYPE_PASSWORD && !TextUtils.isEmpty(account.password)) { + Integer count = HaveIBeenPwned.check(account.password, context); + boolean pwned = (count != null && count != 0); + ssb.append(account.name).append(": "); + int start = ssb.length(); + ssb.append(pwned ? "PWNED!" : "OK"); + if (pwned) { + ssb.setSpan(new ForegroundColorSpan(colorError), start, ssb.length(), 0); + ssb.setSpan(new StyleSpan(Typeface.BOLD), start, ssb.length(), 0); + } else + ssb.setSpan(new ForegroundColorSpan(colorVerified), start, ssb.length(), 0); + ssb.append('\n'); + } + + return ssb; + } + + @Override + protected void onExecuted(Bundle args, SpannableStringBuilder ssb) { + tvPwned.setText(ssb); + grpReady.setVisibility(View.VISIBLE); + } + + @Override + protected void onException(Bundle args, Throwable ex) { + tvPwned.setText(Log.formatThrowable(ex)); + grpReady.setVisibility(View.VISIBLE); + } + }.execute(this, new Bundle(), "pwned"); + } + @Override public void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { super.onActivityResult(requestCode, resultCode, data); diff --git a/app/src/main/java/eu/faircode/email/HaveIBeenPwned.java b/app/src/main/java/eu/faircode/email/HaveIBeenPwned.java new file mode 100644 index 0000000000..56c148ecec --- /dev/null +++ b/app/src/main/java/eu/faircode/email/HaveIBeenPwned.java @@ -0,0 +1,69 @@ +package eu.faircode.email; + +/* + This file is part of FairEmail. + + FairEmail is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + FairEmail is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with FairEmail. If not, see . + + Copyright 2018-2024 by Marcel Bokhorst (M66B) +*/ + +import android.content.Context; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.net.URL; +import java.security.NoSuchAlgorithmException; + +import javax.net.ssl.HttpsURLConnection; + +public class HaveIBeenPwned { + // https://haveibeenpwned.com/API/v3 + + private final static int FETCH_TIMEOUT = 15 * 1000; // milliseconds + + static Integer check(String password, Context context) throws NoSuchAlgorithmException, IOException { + String hashed = Helper.sha1(password.getBytes()); + String range = hashed.substring(0, 5); + String rest = hashed.substring(5); + + URL url = new URL(BuildConfig.PAWNED_ENDPOINT + "range/" + range); + Log.i("GET " + url); + HttpsURLConnection connection = (HttpsURLConnection) url.openConnection(); + connection.setRequestMethod("GET"); + connection.setReadTimeout(FETCH_TIMEOUT); + connection.setConnectTimeout(FETCH_TIMEOUT); + ConnectionHelper.setUserAgent(context, connection); + connection.connect(); + + try { + int status = connection.getResponseCode(); + if (status != HttpsURLConnection.HTTP_OK) + throw new IOException("Error " + status + ": " + connection.getResponseMessage()); + + String line; + BufferedReader br = new BufferedReader(new InputStreamReader(connection.getInputStream())); + while ((line = br.readLine()) != null) { + String[] parts = line.split(":"); + if (parts.length == 2 && rest.equalsIgnoreCase(parts[0])) + return Helper.parseInt(parts[1]); + } + } finally { + connection.disconnect(); + } + + return null; + } +} diff --git a/app/src/main/res/layout/dialog_pwned.xml b/app/src/main/res/layout/dialog_pwned.xml new file mode 100644 index 0000000000..9b1369be5c --- /dev/null +++ b/app/src/main/res/layout/dialog_pwned.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/menu_accounts.xml b/app/src/main/res/menu/menu_accounts.xml index 7315fb5ea6..cbe194d4b8 100644 --- a/app/src/main/res/menu/menu_accounts.xml +++ b/app/src/main/res/menu/menu_accounts.xml @@ -52,5 +52,10 @@ android:id="@+id/menu_force_sync" android:title="@string/title_force_sync" app:showAsAction="never" /> + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0f46fbed87..f7675e8ecc 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1961,6 +1961,7 @@ View conversation Force sync Force send + Have I been pwned? All