From da6fe6f5554a331f4ef3465237abc646ee01a7c6 Mon Sep 17 00:00:00 2001 From: M66B Date: Thu, 12 Jan 2023 17:33:16 +0100 Subject: [PATCH] Cloud sync foundation --- app/build.gradle | 4 + .../faircode/email/FragmentOptionsBackup.java | 245 ++++++++++++++++-- app/src/main/java/eu/faircode/email/Log.java | 6 +- .../res/layout/fragment_options_backup.xml | 142 +++++++++- app/src/main/res/values/strings.xml | 12 +- 5 files changed, 388 insertions(+), 21 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 77000f9a2d..5d9c564ee0 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -168,6 +168,7 @@ android { buildConfigField "String", "BITBUCKET_DOWNLOADS_API", "\"https://api.bitbucket.org/2.0/repositories/M66B/fairemail-test/downloads\"" buildConfigField "String", "BITBUCKET_DOWNLOADS_URI", "\"https://bitbucket.org/M66B/fairemail-test/downloads/\"" buildConfigField "String", "ANNOUNCEMENT_URI", "\"https://gist.githubusercontent.com/M66B/d544192ca56224839d6ba0f2f6314c1f/raw/\"" + buildConfigField "String", "CLOUD_URI", "\"https://api.fairemail.net/sync\"" buildConfigField "String", "TX_URI", localProperties.getProperty("paypal.uri", "\"\"") buildConfigField "String", "GPA_URI", localProperties.getProperty("gpa.uri", "\"\"") buildConfigField "String", "INFO_URI", localProperties.getProperty("info.uri", "\"\"") @@ -187,6 +188,7 @@ android { buildConfigField "String", "BITBUCKET_DOWNLOADS_API", "\"https://api.bitbucket.org/2.0/repositories/M66B/fairemail-test/downloads\"" buildConfigField "String", "BITBUCKET_DOWNLOADS_URI", "\"https://bitbucket.org/M66B/fairemail-test/downloads/\"" buildConfigField "String", "ANNOUNCEMENT_URI", "\"https://gist.githubusercontent.com/M66B/d544192ca56224839d6ba0f2f6314c1f/raw/\"" + buildConfigField "String", "CLOUD_URI", "\"https://api.fairemail.net/sync\"" buildConfigField "String", "TX_URI", "\"\"" buildConfigField "String", "GPA_URI", "\"\"" buildConfigField "String", "INFO_URI", "\"\"" @@ -207,6 +209,7 @@ android { buildConfigField "String", "BITBUCKET_DOWNLOADS_API", "\"\"" buildConfigField "String", "BITBUCKET_DOWNLOADS_URI", "\"\"" buildConfigField "String", "ANNOUNCEMENT_URI", "\"\"" + buildConfigField "String", "CLOUD_URI", "\"\"" buildConfigField "String", "TX_URI", "\"\"" buildConfigField "String", "GPA_URI", "\"\"" buildConfigField "String", "INFO_URI", "\"\"" @@ -227,6 +230,7 @@ android { buildConfigField "String", "BITBUCKET_DOWNLOADS_API", "\"\"" buildConfigField "String", "BITBUCKET_DOWNLOADS_URI", "\"\"" buildConfigField "String", "ANNOUNCEMENT_URI", "\"\"" + buildConfigField "String", "CLOUD_URI", "\"\"" buildConfigField "String", "TX_URI", "\"\"" buildConfigField "String", "GPA_URI", "\"\"" buildConfigField "String", "INFO_URI", "\"\"" diff --git a/app/src/main/java/eu/faircode/email/FragmentOptionsBackup.java b/app/src/main/java/eu/faircode/email/FragmentOptionsBackup.java index 9c7751000e..635ea3ebc7 100644 --- a/app/src/main/java/eu/faircode/email/FragmentOptionsBackup.java +++ b/app/src/main/java/eu/faircode/email/FragmentOptionsBackup.java @@ -62,6 +62,7 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.AlertDialog; import androidx.cardview.widget.CardView; +import androidx.constraintlayout.widget.Group; import androidx.documentfile.provider.DocumentFile; import androidx.lifecycle.ViewModelProvider; import androidx.preference.PreferenceManager; @@ -81,12 +82,14 @@ import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.io.OutputStream; +import java.net.URL; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; import java.security.spec.InvalidKeySpecException; import java.security.spec.KeySpec; +import java.text.DateFormat; import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Arrays; @@ -106,22 +109,45 @@ import javax.crypto.SecretKey; import javax.crypto.SecretKeyFactory; import javax.crypto.spec.IvParameterSpec; import javax.crypto.spec.PBEKeySpec; +import javax.net.ssl.HttpsURLConnection; -public class FragmentOptionsBackup extends FragmentBase { +public class FragmentOptionsBackup extends FragmentBase implements SharedPreferences.OnSharedPreferenceChangeListener { private View view; private ImageButton ibHelp; private Button btnExport; private Button btnImport; private CardView cardCloud; + private TextView tvCloudInfo; + private TextView tvCloudPro; private EditText etUser; private TextInputLayout tilPassword; private Button btnLogin; + private TextView tvLogin; + private CheckBox cbAccounts; + private CheckBox cbBlockedSenders; + private CheckBox cbFilterRules; + private ImageButton ibSync; + private TextView tvLastSync; + private Button btnLogout; + private CheckBox cbDelete; + private Group grpLogin; + private Group grpLogout; + + private DateFormat DTF; private static final int REQUEST_EXPORT_SELECT = 1; private static final int REQUEST_IMPORT_SELECT = 2; private static final int REQUEST_EXPORT_HANDLE = 3; private static final int REQUEST_IMPORT_HANDLE = 4; + private static final int CLOUD_TIMEOUT = 10 * 1000; // timeout + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + DTF = Helper.getDateTimeInstance(getContext()); + } + @Override @Nullable public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { @@ -135,9 +161,21 @@ public class FragmentOptionsBackup extends FragmentBase { btnExport = view.findViewById(R.id.btnExport); btnImport = view.findViewById(R.id.btnImport); cardCloud = view.findViewById(R.id.cardCloud); + tvCloudInfo = view.findViewById(R.id.tvCloudInfo); + tvCloudPro = view.findViewById(R.id.tvCloudPro); etUser = view.findViewById(R.id.etUser); tilPassword = view.findViewById(R.id.tilPassword); btnLogin = view.findViewById(R.id.btnLogin); + tvLogin = view.findViewById(R.id.tvLogin); + cbAccounts = view.findViewById(R.id.cbAccounts); + cbBlockedSenders = view.findViewById(R.id.cbBlockedSenders); + cbFilterRules = view.findViewById(R.id.cbFilterRules); + ibSync = view.findViewById(R.id.ibSync); + tvLastSync = view.findViewById(R.id.tvLastSync); + btnLogout = view.findViewById(R.id.btnLogout); + cbDelete = view.findViewById(R.id.cbDelete); + grpLogin = view.findViewById(R.id.grpLogin); + grpLogout = view.findViewById(R.id.grpLogout); // Wire controls @@ -150,6 +188,13 @@ public class FragmentOptionsBackup extends FragmentBase { } }); + tvCloudInfo.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + Helper.viewFAQ(v.getContext(), 999); + } + }); + btnExport.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { @@ -171,14 +216,86 @@ public class FragmentOptionsBackup extends FragmentBase { } }); + cbAccounts.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { + @Override + public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { + prefs.edit().putBoolean("cloud_sync_accounts", isChecked).apply(); + } + }); + + cbBlockedSenders.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { + @Override + public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { + prefs.edit().putBoolean("cloud_sync_blocked_senders", isChecked).apply(); + } + }); + + cbFilterRules.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { + @Override + public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { + prefs.edit().putBoolean("cloud_sync_filter_rules", isChecked).apply(); + } + }); + + ibSync.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + // TODO + } + }); + + btnLogout.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + onLogout(); + } + }); + // Initialize FragmentDialogTheme.setBackground(getContext(), view, false); - cardCloud.setVisibility(BuildConfig.DEBUG && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O - ? View.VISIBLE : View.GONE); + cardCloud.setVisibility( + BuildConfig.DEBUG && + Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && + !TextUtils.isEmpty(BuildConfig.CLOUD_URI) + ? View.VISIBLE : View.GONE); + Helper.linkPro(tvCloudPro); + + prefs.registerOnSharedPreferenceChangeListener(this); + cbAccounts.setChecked(prefs.getBoolean("cloud_sync_accounts", true)); + cbBlockedSenders.setChecked(prefs.getBoolean("cloud_sync_blocked_senders", true)); + cbFilterRules.setChecked(prefs.getBoolean("cloud_sync_filter_rules", true)); + onSharedPreferenceChanged(prefs, null); return view; } + @Override + public void onDestroy() { + PreferenceManager.getDefaultSharedPreferences(getContext()).unregisterOnSharedPreferenceChangeListener(this); + super.onDestroy(); + } + + @Override + public void onSharedPreferenceChanged(SharedPreferences prefs, String key) { + if (key == null || + "cloud_user".equals(key) || + "cloud_password".equals(key)) { + String user = prefs.getString("cloud_user", null); + String password = prefs.getString("cloud_password", null); + boolean auth = !(TextUtils.isEmpty(user) || TextUtils.isEmpty(password)); + long last_sync = prefs.getLong("cloud_last_sync", 0); + + etUser.setText(user); + tilPassword.getEditText().setText(password); + tvLogin.setText(user); + tvLastSync.setText(getString(R.string.title_advanced_cloud_last_sync, + last_sync == 0 ? "-" : DTF.format(last_sync))); + cbDelete.setChecked(false); + grpLogin.setVisibility(auth ? View.GONE : View.VISIBLE); + grpLogout.setVisibility(auth ? View.VISIBLE : View.GONE); + } + } + @Override public void onActivityResult(int requestCode, int resultCode, Intent data) { super.onActivityResult(requestCode, resultCode, data); @@ -1362,46 +1479,142 @@ public class FragmentOptionsBackup extends FragmentBase { } private void onLogin() { + String username = etUser.getText().toString(); + String password = tilPassword.getEditText().getText().toString(); + + if (TextUtils.isEmpty(username.trim())) { + etUser.requestFocus(); + return; + } + + if (TextUtils.isEmpty(password)) { + tilPassword.getEditText().requestFocus(); + return; + } + Bundle args = new Bundle(); - args.putString("user", etUser.getText().toString()); + cloud(args); + } + + private void onLogout() { + Bundle args = new Bundle(); + args.putBoolean("logout", true); + args.putBoolean("wipe", cbDelete.isChecked()); + cloud(args); + } + + private void cloud(Bundle args) { + args.putString("user", etUser.getText().toString().trim()); args.putString("password", tilPassword.getEditText().getText().toString()); - new SimpleTask() { + new SimpleTask() { @Override protected void onPreExecute(Bundle args) { - btnLogin.setEnabled(false); + Helper.setViewsEnabled(cardCloud, false); } @Override protected void onPostExecute(Bundle args) { - btnLogin.setEnabled(true); + Helper.setViewsEnabled(cardCloud, true); } @Override - protected Void onExecute(Context context, Bundle args) throws Throwable { + protected String onExecute(Context context, Bundle args) throws Throwable { String user = args.getString("user"); String password = args.getString("password"); + boolean wipe = args.getBoolean("wipe"); - Pair key = getKeyPair(user, password); + byte[] salt = MessageDigest.getInstance("SHA256").digest(user.getBytes()); + String cloudUser = Helper.hex(MessageDigest.getInstance("SHA256").digest(salt)); - return null; + Pair key = getKeyPair(salt, password); + String cloudPassword = Helper.hex(key.first); + + JSONObject jroot = new JSONObject(); + jroot.put("username", cloudUser); + jroot.put("password", cloudPassword); + jroot.put("wipe", wipe); + jroot.put("debug", BuildConfig.DEBUG); + String request = jroot.toString(); + Log.i("Cloud request=" + request); + + URL url = new URL(BuildConfig.CLOUD_URI); + HttpsURLConnection connection = (HttpsURLConnection) url.openConnection(); + connection.setRequestMethod("POST"); + connection.setDoInput(true); + connection.setDoOutput(true); + connection.setReadTimeout(CLOUD_TIMEOUT); + connection.setConnectTimeout(CLOUD_TIMEOUT); + ConnectionHelper.setUserAgent(context, connection); + connection.setRequestProperty("Accept", "application/json"); + connection.setRequestProperty("Content-Length", Integer.toString(request.length())); + connection.setRequestProperty("Content-Type", "application/json"); + connection.connect(); + + try { + connection.getOutputStream().write(request.getBytes()); + + int status = connection.getResponseCode(); + if (status != HttpsURLConnection.HTTP_OK) { + String error = "Error " + status + ": " + connection.getResponseMessage(); + String detail = Helper.readStream(connection.getErrorStream()); + JSONObject jerror = new JSONObject(detail); + if (status == HttpsURLConnection.HTTP_FORBIDDEN) + throw new SecurityException(jerror.optString("error")); + else + throw new IOException(error + " " + jerror); + } + + String response = Helper.readStream(connection.getInputStream()); + Log.i("Cloud response=" + response); + JSONObject jresponse = new JSONObject(response); + return jresponse.optString("status"); + } finally { + connection.disconnect(); + } } @Override - protected void onExecuted(Bundle args, Void data) { + protected void onExecuted(Bundle args, String status) { + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(getContext()); + if ("ok".equals(status) && !args.getBoolean("logout")) + prefs.edit() + .putString("cloud_user", args.getString("user")) + .putString("cloud_password", args.getString("password")) + .apply(); + else + prefs.edit() + .remove("cloud_user") + .remove("cloud_password") + .apply(); + + view.post(new Runnable() { + @Override + public void run() { + view.scrollTo(0, cardCloud.getTop()); + } + }); } @Override protected void onException(Bundle args, Throwable ex) { - Log.unexpectedError(getParentFragmentManager(), ex); + if (ex instanceof SecurityException) { + AlertDialog.Builder builder = new AlertDialog.Builder(getContext()) + .setIcon(R.drawable.twotone_warning_24) + .setTitle(getString(R.string.title_advanced_cloud_invalid)) + .setNegativeButton(android.R.string.cancel, null); + String message = ex.getMessage(); + if (!TextUtils.isEmpty(message)) + builder.setMessage(message); + builder.show(); + } else + Log.unexpectedError(getParentFragmentManager(), ex); } - }.execute(FragmentOptionsBackup.this, args, "cloud:login"); + }.execute(FragmentOptionsBackup.this, args, "cloud"); } - private static Pair getKeyPair(String user, String password) + private static Pair getKeyPair(byte[] salt, String password) throws NoSuchAlgorithmException, InvalidKeySpecException { - byte[] salt = MessageDigest.getInstance("SHA256").digest(user.getBytes()); - // https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html SecretKeyFactory keyFactory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256"); KeySpec keySpec = new PBEKeySpec(password.toCharArray(), salt, 310000, 2 * 256); diff --git a/app/src/main/java/eu/faircode/email/Log.java b/app/src/main/java/eu/faircode/email/Log.java index fbc5efc1d2..966ccbdb71 100644 --- a/app/src/main/java/eu/faircode/email/Log.java +++ b/app/src/main/java/eu/faircode/email/Log.java @@ -2242,7 +2242,11 @@ public class Log { Object value = settings.get(key); if ("wipe_mnemonic".equals(key) && value != null) value = "[redacted]"; - if (key != null && key.startsWith("oauth.")) + else if ("cloud_user".equals(key) && value != null) + value = "[redacted]"; + else if ("cloud_password".equals(key) && value != null) + value = "[redacted]"; + else if (key != null && key.startsWith("oauth.")) value = "[redacted]"; size += write(os, key + "=" + value + "\r\n"); } diff --git a/app/src/main/res/layout/fragment_options_backup.xml b/app/src/main/res/layout/fragment_options_backup.xml index d676cfe04e..8779b3aba3 100644 --- a/app/src/main/res/layout/fragment_options_backup.xml +++ b/app/src/main/res/layout/fragment_options_backup.xml @@ -168,6 +168,32 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> + + + + + app:layout_constraintTop_toBottomOf="@id/tvCloudPro" /> + + + + + + + + + + + + + + + +