diff --git a/app/src/main/java/eu/faircode/email/CloudSync.java b/app/src/main/java/eu/faircode/email/CloudSync.java new file mode 100644 index 0000000000..79650522c1 --- /dev/null +++ b/app/src/main/java/eu/faircode/email/CloudSync.java @@ -0,0 +1,180 @@ +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-2023 by Marcel Bokhorst (M66B) +*/ + +import android.content.Context; +import android.util.Base64; +import android.util.Pair; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.IOException; +import java.net.URL; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.KeySpec; +import java.util.Arrays; + +import javax.crypto.BadPaddingException; +import javax.crypto.Cipher; +import javax.crypto.IllegalBlockSizeException; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.SecretKey; +import javax.crypto.SecretKeyFactory; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.PBEKeySpec; +import javax.crypto.spec.SecretKeySpec; +import javax.net.ssl.HttpsURLConnection; + +public class CloudSync { + private static final int CLOUD_TIMEOUT = 10 * 1000; // timeout + + public static JSONObject perform(Context context, String user, String password, JSONObject jrequest) + throws InvalidAlgorithmParameterException, NoSuchAlgorithmException, + InvalidKeySpecException, InvalidKeyException, + IllegalBlockSizeException, NoSuchPaddingException, BadPaddingException, + JSONException, IOException { + byte[] salt = MessageDigest.getInstance("SHA256").digest(user.getBytes()); + byte[] huser = MessageDigest.getInstance("SHA256").digest(salt); + byte[] userid = Arrays.copyOfRange(huser, 0, 8); + String cloudUser = Base64.encodeToString(userid, Base64.NO_PADDING | Base64.NO_WRAP); + + Pair key = getKeyPair(salt, password); + String cloudPassword = Base64.encodeToString(key.first, Base64.NO_PADDING | Base64.NO_WRAP); + + jrequest.put("version", 1); + jrequest.put("username", cloudUser); + jrequest.put("password", cloudPassword); + jrequest.put("debug", BuildConfig.DEBUG); + + if (jrequest.has("keys")) { + JSONArray jkeys = jrequest.getJSONArray("keys"); + for (int i = 0; i < jkeys.length(); i++) { + jkeys.put(i, transform(jkeys.getString(i), key.second, true)); + } + } + + if (jrequest.has("items")) { + JSONArray jitems = jrequest.getJSONArray("items"); + for (int i = 0; i < jitems.length(); i++) { + JSONObject jitem = jitems.getJSONObject(i); + + String k = jitem.getString("key"); + jitem.put("key", transform(k, key.second, true)); + + if (jitem.has("value") && !jitem.isNull("value")) { + String v = jitem.getString("value"); + jitem.put("value", transform(v, key.second, true)); + } + } + } + + String request = jrequest.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()); + Log.w("Cloud error=" + error + " detail=" + detail); + 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); + + if (jresponse.has("items")) { + JSONArray jitems = jresponse.getJSONArray("items"); + for (int i = 0; i < jitems.length(); i++) { + JSONObject jitem = jitems.getJSONObject(i); + + String ekey = jitem.getString("key"); + jitem.put("key", transform(ekey, key.second, false)); + + if (jitem.has("value") && !jitem.isNull("value")) { + String evalue = jitem.getString("value"); + jitem.put("value", transform(evalue, key.second, false)); + } + } + } + + return jresponse; + } finally { + connection.disconnect(); + } + } + + private static Pair getKeyPair(byte[] salt, String password) + throws NoSuchAlgorithmException, InvalidKeySpecException { + // 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); + SecretKey secret = keyFactory.generateSecret(keySpec); + + byte[] encoded = secret.getEncoded(); + int half = encoded.length / 2; + return new Pair<>( + Arrays.copyOfRange(encoded, 0, half), + Arrays.copyOfRange(encoded, half, half + half)); + } + + private static String transform(String value, byte[] key, boolean encrypt) + throws NoSuchPaddingException, NoSuchAlgorithmException, InvalidAlgorithmParameterException, InvalidKeyException, IllegalBlockSizeException, BadPaddingException { + SecretKeySpec secret = new SecretKeySpec(key, "AES"); + Cipher cipher = Cipher.getInstance("AES/GCM-SIV/NoPadding"); + IvParameterSpec ivSpec = new IvParameterSpec(new byte[12]); + cipher.init(encrypt ? Cipher.ENCRYPT_MODE : Cipher.DECRYPT_MODE, secret, ivSpec); + //cipher.updateAAD(ByteBuffer.allocate(4).putInt(0).array()); + if (encrypt) { + byte[] encrypted = cipher.doFinal(value.getBytes()); + return Base64.encodeToString(encrypted, Base64.NO_PADDING | Base64.NO_WRAP); + } else { + byte[] encrypted = Base64.decode(value, Base64.NO_PADDING | Base64.NO_WRAP); + byte[] decrypted = cipher.doFinal(encrypted); + return new String(decrypted); + } + } +} diff --git a/app/src/main/java/eu/faircode/email/FragmentOptionsBackup.java b/app/src/main/java/eu/faircode/email/FragmentOptionsBackup.java index 9afb834896..40d90b8e75 100644 --- a/app/src/main/java/eu/faircode/email/FragmentOptionsBackup.java +++ b/app/src/main/java/eu/faircode/email/FragmentOptionsBackup.java @@ -43,7 +43,6 @@ import android.text.TextUtils; import android.text.TextWatcher; import android.text.style.ForegroundColorSpan; import android.text.style.StyleSpan; -import android.util.Base64; import android.util.Pair; import android.view.KeyEvent; import android.view.LayoutInflater; @@ -83,19 +82,12 @@ 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.InvalidAlgorithmParameterException; -import java.security.InvalidKeyException; -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; import java.util.Collections; import java.util.Date; import java.util.HashMap; @@ -108,13 +100,10 @@ import javax.crypto.Cipher; import javax.crypto.CipherInputStream; import javax.crypto.CipherOutputStream; import javax.crypto.IllegalBlockSizeException; -import javax.crypto.NoSuchPaddingException; import javax.crypto.SecretKey; import javax.crypto.SecretKeyFactory; import javax.crypto.spec.IvParameterSpec; import javax.crypto.spec.PBEKeySpec; -import javax.crypto.spec.SecretKeySpec; -import javax.net.ssl.HttpsURLConnection; public class FragmentOptionsBackup extends FragmentBase implements SharedPreferences.OnSharedPreferenceChangeListener { private View view; @@ -145,8 +134,6 @@ public class FragmentOptionsBackup extends FragmentBase implements SharedPrefere 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); @@ -1542,92 +1529,50 @@ public class FragmentOptionsBackup extends FragmentBase implements SharedPrefere String password = args.getString("password"); boolean wipe = args.getBoolean("wipe"); - byte[] salt = MessageDigest.getInstance("SHA256").digest(user.getBytes()); - byte[] huser = MessageDigest.getInstance("SHA256").digest(salt); - byte[] userid = Arrays.copyOfRange(huser, 0, 8); - String cloudUser = Base64.encodeToString(userid, Base64.NO_PADDING | Base64.NO_WRAP); - - Pair key = getKeyPair(salt, password); - String cloudPassword = Base64.encodeToString(key.first, Base64.NO_PADDING | Base64.NO_WRAP); - - JSONObject jroot = new JSONObject(); - jroot.put("version", 1); - jroot.put("command", wipe ? "wipe" : "login"); - jroot.put("username", cloudUser); - jroot.put("password", cloudPassword); - jroot.put("debug", BuildConfig.DEBUG); + JSONObject jrequest = new JSONObject(); + jrequest.put("command", wipe ? "wipe" : "login"); if (false) { JSONArray jwrite = new JSONArray(); + JSONObject jkv0 = new JSONObject(); + jkv0.put("key", "key0"); + jkv0.put("timestamp", 1000); + jkv0.put("value", "value0"); + jwrite.put(jkv0); + JSONObject jkv1 = new JSONObject(); - jkv1.put("key", transform("key1", key.second, true)); - jkv1.put("value", transform("value1", key.second, true)); + jkv1.put("key", "key1"); + jkv1.put("timestamp", 1001); + jkv1.put("value", "value1"); jwrite.put(jkv1); - JSONObject jkv2 = new JSONObject(); - jkv2.put("key", transform("key2", key.second, true)); - jkv2.put("value", null); - jwrite.put(jkv2); - - jroot.put("command", "write"); - jroot.put("items", jwrite); + jrequest.put("command", "write"); + jrequest.put("stage", "ack"); + jrequest.put("items", jwrite); } - if (false) { + if (true) { JSONArray jread = new JSONArray(); - jread.put(transform("key1", key.second, true)); - jroot.put("command", "read"); - jroot.put("items", jread); + jread.put("key1"); + jrequest.put("command", "read"); + jrequest.put("stage", "sync"); + jrequest.put("keys", jread); } - 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()); - Log.w("Cloud error=" + error + " detail=" + detail); - JSONObject jerror = new JSONObject(detail); - if (status == HttpsURLConnection.HTTP_FORBIDDEN) - throw new SecurityException(jerror.optString("error")); - else - throw new IOException(error + " " + jerror); - } + JSONObject jresponse = CloudSync.perform(context, user, password, jrequest); - String response = Helper.readStream(connection.getInputStream()); - Log.i("Cloud response=" + response); - JSONObject jresponse = new JSONObject(response); - if (jresponse.has("items")) { - JSONArray jitems = jresponse.getJSONArray("items"); - for (int i = 0; i < jitems.length(); i++) { - JSONObject jitem = jitems.getJSONObject(i); - String k = transform(jitem.optString("key"), key.second, false); - String v = transform(jitem.optString("value"), key.second, false); - Log.i("Cloud item " + k + "=" + v); - } + if (jresponse.has("items")) { + JSONArray jitems = jresponse.getJSONArray("items"); + for (int i = 0; i < jitems.length(); i++) { + JSONObject jitem = jitems.getJSONObject(i); + String key = jitem.getString("key"); + long timestamp = jitem.getLong("timestamp"); + String value = (jitem.has("value") ? jitem.getString("value") : null); + Log.i("Cloud item " + key + "=" + value + " @" + timestamp); } - return jresponse.optString("status"); - } finally { - connection.disconnect(); } + return jresponse.optString("status"); } @Override @@ -1668,37 +1613,6 @@ public class FragmentOptionsBackup extends FragmentBase implements SharedPrefere }.execute(FragmentOptionsBackup.this, args, "cloud"); } - private static Pair getKeyPair(byte[] salt, String password) - throws NoSuchAlgorithmException, InvalidKeySpecException { - // 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); - SecretKey secret = keyFactory.generateSecret(keySpec); - - byte[] encoded = secret.getEncoded(); - int half = encoded.length / 2; - return new Pair<>( - Arrays.copyOfRange(encoded, 0, half), - Arrays.copyOfRange(encoded, half, half + half)); - } - - private String transform(String value, byte[] key, boolean encrypt) - throws NoSuchPaddingException, NoSuchAlgorithmException, InvalidAlgorithmParameterException, InvalidKeyException, IllegalBlockSizeException, BadPaddingException { - SecretKeySpec secret = new SecretKeySpec(key, "AES"); - Cipher cipher = Cipher.getInstance("AES/GCM-SIV/NoPadding"); - IvParameterSpec ivSpec = new IvParameterSpec(new byte[12]); - cipher.init(encrypt ? Cipher.ENCRYPT_MODE : Cipher.DECRYPT_MODE, secret, ivSpec); - //cipher.updateAAD(ByteBuffer.allocate(4).putInt(0).array()); - if (encrypt) { - byte[] encrypted = cipher.doFinal(value.getBytes()); - return Base64.encodeToString(encrypted, Base64.NO_PADDING | Base64.NO_WRAP); - } else { - byte[] encrypted = Base64.decode(value, Base64.NO_PADDING | Base64.NO_WRAP); - byte[] decrypted = cipher.doFinal(encrypted); - return new String(decrypted); - } - } - public static class FragmentDialogExport extends FragmentDialogBase { private TextInputLayout tilPassword1; private TextInputLayout tilPassword2;