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.content.SharedPreferences; import android.text.TextUtils; import android.util.Base64; import android.util.Pair; import androidx.preference.PreferenceManager; import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; import java.net.URL; import java.nio.ByteBuffer; import java.security.GeneralSecurityException; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.security.spec.InvalidKeySpecException; import java.security.spec.KeySpec; import java.util.ArrayList; import java.util.Arrays; import java.util.Date; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.zip.GZIPInputStream; import java.util.zip.GZIPOutputStream; import javax.crypto.Cipher; 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 private static final int BATCH_SIZE = 25; private static final Map> keyCache = new HashMap<>(); // Upper level static void execute(Context context, String command, boolean manual) throws JSONException, GeneralSecurityException, IOException { SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); String user = prefs.getString("cloud_user", null); String password = prefs.getString("cloud_password", null); if (TextUtils.isEmpty(user) || TextUtils.isEmpty(password)) return; if (!ActivityBilling.isPro(context)) return; JSONObject jrequest = new JSONObject(); if ("sync".equals(command)) { long lrevision = prefs.getLong("cloud_revision", new Date().getTime()); Log.i("Cloud local revision=" + lrevision + " (" + new Date(lrevision) + ")"); Long lastUpdate = updateSyncdata(context); Log.i("Cloud last update=" + (lastUpdate == null ? null : new Date(lastUpdate))); if (lastUpdate != null && lrevision > lastUpdate) { Log.w("Cloud invalid local revision" + " lrevision=" + lrevision + " last=" + lastUpdate); prefs.edit().putLong("cloud_revision", lastUpdate).apply(); } JSONObject jsyncstatus = new JSONObject(); jsyncstatus.put("key", "sync.status"); jsyncstatus.put("rev", lrevision); JSONArray jitems = new JSONArray(); jitems.put(jsyncstatus); jrequest.put("items", jitems); JSONObject jresponse = call(context, user, password, "read", jrequest); jitems = jresponse.getJSONArray("items"); if (jitems.length() == 0) { Log.i("Cloud server is empty"); sendLocalData(context, user, password, lrevision); } else if (jitems.length() == 1) { Log.i("Cloud sync check"); jsyncstatus = jitems.getJSONObject(0); long rrevision = jsyncstatus.getLong("rev"); JSONObject jstatus = new JSONObject(jsyncstatus.getString("val")); int sync_version = jstatus.optInt("sync.version", 0); int app_version = jstatus.optInt("app.version", 0); Log.i("Cloud version sync=" + sync_version + " app=" + app_version + " local=" + lrevision + " last=" + lastUpdate + " remote=" + rrevision); // last > local (local mods) && remote > local (remote mods) = CONFLICT // local > last = ignorable ERROR // remote > local = fetch remote // last > remote = send local if (lastUpdate != null && lastUpdate > rrevision) // local newer than remote sendLocalData(context, user, password, lastUpdate); else if (rrevision > lrevision) // remote changes if (lastUpdate != null && lastUpdate > lrevision) { // local changes Log.w("Cloud conflict" + " lrevision=" + lrevision + " last=" + lastUpdate + " rrevision=" + rrevision); if (manual) if (lastUpdate >= rrevision) sendLocalData(context, user, password, lastUpdate); else receiveRemoteData(context, user, password, lrevision, jstatus); } else receiveRemoteData(context, user, password, lrevision, jstatus); else if (BuildConfig.DEBUG) receiveRemoteData(context, user, password, lrevision - 1, jstatus); } else throw new IllegalArgumentException("Expected one status item"); } else { JSONArray jitems = new JSONArray(); jrequest.put("items", jitems); call(context, user, password, command, jrequest); } prefs.edit().putLong("cloud_last_sync", new Date().getTime()).apply(); } private static Long updateSyncdata(Context context) throws IOException, JSONException { DB db = DB.getInstance(context); File dir = Helper.ensureExists(new File(context.getFilesDir(), "syncdata")); Long last = null; List accounts = db.account().getSynchronizingAccounts(null); if (accounts != null) for (EntityAccount account : accounts) if (!TextUtils.isEmpty(account.uuid)) { EntityAccount aexisting = null; File afile = new File(dir, "account." + account.uuid + ".json"); if (afile.exists()) try (InputStream is = new FileInputStream(afile)) { aexisting = EntityAccount.fromJSON(new JSONObject(Helper.readStream(is))); } boolean apassword = (account.auth_type == ServiceAuthenticator.AUTH_TYPE_PASSWORD); if (aexisting == null || !EntityAccount.areEqual(account, aexisting, apassword, false)) Helper.writeText(afile, account.toJSON().toString()); long atime = afile.lastModified(); if (last == null || atime > last) last = atime; List identities = db.identity().getIdentities(account.id); if (identities != null) for (EntityIdentity identity : identities) if (!TextUtils.isEmpty(identity.uuid)) { EntityIdentity iexisting = null; File ifile = new File(dir, "identity." + identity.uuid + ".json"); if (ifile.exists()) try (InputStream is = new FileInputStream(ifile)) { iexisting = EntityIdentity.fromJSON(new JSONObject(Helper.readStream(is))); } boolean ipassword = (account.auth_type == ServiceAuthenticator.AUTH_TYPE_PASSWORD); if (iexisting == null || EntityIdentity.areEqual(identity, iexisting, ipassword, false)) Helper.writeText(ifile, identity.toJSON().toString()); long itime = ifile.lastModified(); if (last == null || itime > last) last = itime; } } return last; } private static void sendLocalData(Context context, String user, String password, long lrevision) throws JSONException, GeneralSecurityException, IOException { DB db = DB.getInstance(context); SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); List accounts = db.account().getSynchronizingAccounts(null); Log.i("Cloud accounts=" + (accounts == null ? null : accounts.size())); if (accounts == null || accounts.size() == 0) { Log.i("Cloud no accounts"); return; } JSONArray jupload = new JSONArray(); JSONArray jaccountuuidlist = new JSONArray(); for (EntityAccount account : accounts) if (!TextUtils.isEmpty(account.uuid)) { jaccountuuidlist.put(account.uuid); JSONArray jidentitieuuids = new JSONArray(); List identities = db.identity().getIdentities(account.id); if (identities != null) for (EntityIdentity identity : identities) if (!TextUtils.isEmpty(identity.uuid)) { jidentitieuuids.put(identity.uuid); JSONObject jidentitykv = new JSONObject(); jidentitykv.put("key", "identity." + identity.uuid); jidentitykv.put("val", identity.toJSON().toString()); jidentitykv.put("rev", lrevision); jupload.put(jidentitykv); } JSONObject jaccount = account.toJSON(); if (account.swipe_left != null && account.swipe_left > 0) { EntityFolder f = db.folder().getFolder(account.swipe_left); if (f != null) jaccount.put("swipe_left_folder", f.name); } if (account.swipe_right != null && account.swipe_right > 0) { EntityFolder f = db.folder().getFolder(account.swipe_right); if (f != null) jaccount.put("swipe_right_folder", f.name); } JSONObject jaccountdata = new JSONObject(); jaccountdata.put("account", jaccount); jaccountdata.put("identities", jidentitieuuids); JSONObject jaccountkv = new JSONObject(); jaccountkv.put("key", "account." + account.uuid); jaccountkv.put("val", jaccountdata.toString()); jaccountkv.put("rev", lrevision); jupload.put(jaccountkv); } JSONObject jaccountuuids = new JSONObject(); jaccountuuids.put("uuids", jaccountuuidlist); JSONObject jstatus = new JSONObject(); jstatus.put("sync.version", 1); jstatus.put("app.version", BuildConfig.VERSION_CODE); jstatus.put("accounts", jaccountuuids); JSONObject jstatuskv = new JSONObject(); jstatuskv.put("key", "sync.status"); jstatuskv.put("val", jstatus.toString()); jstatuskv.put("rev", lrevision); jupload.put(jstatuskv); JSONObject jrequest = new JSONObject(); jrequest.put("items", jupload); call(context, user, password, "write", jrequest); prefs.edit().putLong("cloud_revision", lrevision).apply(); } private static void receiveRemoteData(Context context, String user, String password, long lrevision, JSONObject jstatus) throws JSONException, GeneralSecurityException, IOException { DB db = DB.getInstance(context); SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); // New revision boolean updates = false; JSONArray jdownload = new JSONArray(); // Get accounts JSONObject jaccountstatus = jstatus.getJSONObject("accounts"); JSONArray jaccountuuidlist = jaccountstatus.getJSONArray("uuids"); for (int i = 0; i < jaccountuuidlist.length(); i++) { String uuid = jaccountuuidlist.getString(i); JSONObject jaccountkv = new JSONObject(); jaccountkv.put("key", "account." + uuid); jaccountkv.put("rev", lrevision); jdownload.put(jaccountkv); Log.i("Cloud account uuid=" + uuid); } if (jdownload.length() > 0) { Log.i("Cloud getting accounts"); JSONObject jrequest = new JSONObject(); jrequest.put("items", jdownload); JSONObject jresponse = call(context, user, password, "sync", jrequest); // Process accounts Log.i("Cloud processing accounts"); JSONArray jitems = jresponse.getJSONArray("items"); jdownload = new JSONArray(); for (int i = 0; i < jitems.length(); i++) { JSONObject jaccountkv = jitems.getJSONObject(i); String value = jaccountkv.getString("val"); long revision = jaccountkv.getLong("rev"); JSONObject jaccountdata = new JSONObject(value); JSONObject jaccount = jaccountdata.getJSONObject("account"); EntityAccount raccount = EntityAccount.fromJSON(jaccount); EntityAccount laccount = db.account().getAccountByUUID(raccount.uuid); JSONArray jidentities = jaccountdata.getJSONArray("identities"); Log.i("Cloud account " + raccount.uuid + "=" + (laccount == null ? "insert" : (EntityAccount.areEqual(raccount, laccount, laccount.auth_type == ServiceAuthenticator.AUTH_TYPE_PASSWORD, true) ? "equal" : "update")) + " rev=" + revision + " identities=" + jidentities + " size=" + value.length()); for (int j = 0; j < jidentities.length(); j++) { JSONObject jidentitykv = new JSONObject(); jidentitykv.put("key", "identity." + jidentities.getString(j)); jidentitykv.put("rev", lrevision); jdownload.put(jidentitykv); } } if (jdownload.length() > 0) { // Get identities Log.i("Cloud getting identities"); jrequest.put("items", jdownload); jresponse = call(context, user, password, "sync", jrequest); // Process identities Log.i("Cloud processing identities"); jitems = jresponse.getJSONArray("items"); for (int i = 0; i < jitems.length(); i++) { JSONObject jidentitykv = jitems.getJSONObject(i); long revision = jidentitykv.getLong("rev"); String value = jidentitykv.getString("val"); JSONObject jidentity = new JSONObject(value); EntityIdentity ridentity = EntityIdentity.fromJSON(jidentity); EntityIdentity lidentity = db.identity().getIdentityByUUID(ridentity.uuid); Log.i("Cloud identity " + ridentity.uuid + "=" + (lidentity == null ? "insert" : (EntityIdentity.areEqual(ridentity, lidentity, lidentity.auth_type == ServiceAuthenticator.AUTH_TYPE_PASSWORD, true) ? "equal" : "update")) + " rev=" + revision + " size=" + value.length()); } } } prefs.edit().putLong("cloud_revision", lrevision).apply(); if (updates) ServiceSynchronize.reload(context, null, true, "sync"); } // Lower level public static JSONObject call(Context context, String user, String password, String command, JSONObject jrequest) throws GeneralSecurityException, JSONException, IOException { Log.i("Cloud command=" + command); jrequest.put("command", command); List responses = new ArrayList<>(); for (JSONArray batch : partition(jrequest.getJSONArray("items"))) { jrequest.put("items", batch); responses.add(_call(context, user, password, jrequest)); } if (responses.size() == 1) return responses.get(0); else { JSONArray jall = new JSONArray(); for (JSONObject response : responses) { JSONArray jitems = response.getJSONArray("items"); for (int i = 0; i < jitems.length(); i++) jall.put(jitems.getJSONObject(i)); } JSONObject jresponse = responses.get(0); jresponse.put("items", jall); return jresponse; } } private static JSONObject _call(Context context, String user, String password, JSONObject jrequest) throws GeneralSecurityException, 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; String lookup = Helper.hex(salt) + ":" + password; synchronized (keyCache) { key = keyCache.get(lookup); } if (key == null) { Log.i("Cloud generating key"); key = getKeyPair(salt, password); synchronized (keyCache) { keyCache.put(lookup, key); } } else { Log.i("Cloud using cached key"); } 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("items")) { JSONArray jitems = jrequest.getJSONArray("items"); for (int i = 0; i < jitems.length(); i++) { JSONObject jitem = jitems.getJSONObject(i); long revision = jitem.getLong("rev"); String k = jitem.getString("key"); jitem.put("key", transform(k, key.second, null, true)); String v = null; if (jitem.has("val") && !jitem.isNull("val")) { v = jitem.getString("val"); jitem.put("val", transform(v, key.second, getAd(k, revision), true)); } v = (v == null ? null : "#" + v.length()); Log.i("Cloud > " + k + "=" + v + " @" + revision); } } String request = jrequest.toString(); Log.i("Cloud request length=" + request.length()); 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 length=" + response.length()); JSONObject jresponse = new JSONObject(response); if (jresponse.has("account")) { JSONObject jaccount = jresponse.getJSONObject("account"); if (jaccount.has("consumed")) Log.i("Cloud $$$ account consumed=" + jaccount.get("consumed")); if (jaccount.has("metrics")) Log.i("Cloud $$$ account metrics=" + jaccount.get("metrics")); } if (jresponse.has("consumed")) Log.i("Cloud $$$ consumed=" + jresponse.get("consumed")); if (jresponse.has("metrics")) Log.i("Cloud $$$ metrics=" + jresponse.get("metrics")); if (jresponse.has("items")) { JSONArray jitems = jresponse.getJSONArray("items"); for (int i = 0; i < jitems.length(); i++) { JSONObject jitem = jitems.getJSONObject(i); long revision = jitem.getLong("rev"); String ekey = jitem.getString("key"); String k = transform(ekey, key.second, null, false); jitem.put("key", k); String v = null; if (jitem.has("val") && !jitem.isNull("val")) { String evalue = jitem.getString("val"); v = transform(evalue, key.second, getAd(k, revision), false); jitem.put("val", v); } v = (v == null ? null : "#" + v.length()); Log.i("Cloud < " + k + "=" + v + " @" + revision); } } 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 byte[] getAd(String key, long revision) throws NoSuchAlgorithmException { byte[] k = MessageDigest.getInstance("SHA256").digest(key.getBytes()); byte[] ad = ByteBuffer.allocate(8 + 8) .putLong(revision) .put(Arrays.copyOfRange(k, 0, 8)) .array(); return ad; } private static String transform(String value, byte[] key, byte[] ad, boolean encrypt) throws GeneralSecurityException, IOException { 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); if (ad != null) cipher.updateAAD(ad); if (encrypt) { byte[] encrypted = cipher.doFinal(compress(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(decompress(decrypted)); } } public static byte[] compress(byte[] data) throws IOException { try (ByteArrayOutputStream bos = new ByteArrayOutputStream(data.length)) { try (GZIPOutputStream gzip = new GZIPOutputStream(bos)) { gzip.write(data); } return bos.toByteArray(); } } public static byte[] decompress(byte[] compressed) throws IOException { try (ByteArrayInputStream is = new ByteArrayInputStream(compressed)) { try (GZIPInputStream gis = new GZIPInputStream(is)) { return Helper.readBytes(gis); } } } private static List partition(JSONArray jarray) throws JSONException { if (jarray.length() <= BATCH_SIZE) return Arrays.asList(jarray); int count = 0; List jpartitions = new ArrayList<>(); for (int i = 0; i < jarray.length(); i += BATCH_SIZE) { JSONArray jpartition = new JSONArray(); for (int j = 0; j < BATCH_SIZE && i + j < jarray.length(); j++) { count++; jpartition.put(jarray.get(i + j)); } jpartitions.add(jpartition); } if (count != jarray.length()) throw new IllegalArgumentException("Partition error size=" + count + "/" + jarray.length()); return jpartitions; } }