mirror of
https://github.com/M66B/FairEmail.git
synced 2026-01-06 12:54:11 +01:00
Store passwords encrypted
This commit is contained in:
9
FAQ.md
9
FAQ.md
@@ -702,10 +702,11 @@ Long version:
|
||||
<a name="faq37"></a>
|
||||
**(37) How are passwords stored?**
|
||||
|
||||
Providers require passwords in plain text, so the background service that takes care of synchronizing messages needs to send passwords in plain text.
|
||||
Since encrypting passwords would require a secret and the background service needs to know this secret, this could only be done by storing that secret.
|
||||
Storing a secret together with encrypted passwords would not add anything, so passwords are stored in plain text in a safe, inaccessible place.
|
||||
Recent Android versions encrypt all user data anyway.
|
||||
On Android 6 Marshmallow and later passwords are stored encrypted in an app private database.
|
||||
Passwords are encrypted with the cipher AES/GCM/NoPadding
|
||||
and a generated secret key stored by the [Android keystore system](https://developer.android.com/training/articles/keystore).
|
||||
|
||||
On earlier Android versions passwords are stored in plain text.
|
||||
|
||||
<br />
|
||||
|
||||
|
||||
1540
app/schemas/eu.faircode.email.DB/53.json
Normal file
1540
app/schemas/eu.faircode.email.DB/53.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@ package eu.faircode.email;
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import android.database.Cursor;
|
||||
import android.os.Build;
|
||||
import android.preference.PreferenceManager;
|
||||
import android.text.TextUtils;
|
||||
|
||||
@@ -49,7 +50,7 @@ import io.requery.android.database.sqlite.RequerySQLiteOpenHelperFactory;
|
||||
// https://developer.android.com/topic/libraries/architecture/room.html
|
||||
|
||||
@Database(
|
||||
version = 52,
|
||||
version = 53,
|
||||
entities = {
|
||||
EntityIdentity.class,
|
||||
EntityAccount.class,
|
||||
@@ -560,6 +561,33 @@ public abstract class DB extends RoomDatabase {
|
||||
db.execSQL("ALTER TABLE `folder` ADD COLUMN `total` INTEGER");
|
||||
}
|
||||
})
|
||||
.addMigrations(new Migration(52, 53) {
|
||||
@Override
|
||||
public void migrate(SupportSQLiteDatabase db) {
|
||||
Log.i("DB migration from version " + startVersion + " to " + endVersion);
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
Cursor cursor = db.query("SELECT id, password FROM account");
|
||||
while (cursor.moveToNext()) {
|
||||
long id = cursor.getLong(0);
|
||||
String plain = cursor.getString(1);
|
||||
db.execSQL("UPDATE account SET password = ? WHERE id = ?",
|
||||
new Object[]{id, Helper.encryptPassword(plain)});
|
||||
}
|
||||
cursor.close();
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
Cursor cursor = db.query("SELECT id, password FROM identity");
|
||||
while (cursor.moveToNext()) {
|
||||
long id = cursor.getLong(0);
|
||||
String plain = cursor.getString(1);
|
||||
db.execSQL("UPDATE identity SET password = ? WHERE id = ?",
|
||||
new Object[]{id, Helper.encryptPassword(plain)});
|
||||
}
|
||||
cursor.close();
|
||||
}
|
||||
}
|
||||
})
|
||||
.build();
|
||||
}
|
||||
|
||||
|
||||
@@ -95,6 +95,20 @@ public class EntityAccount implements Serializable {
|
||||
return "imap" + (starttls ? "" : "s");
|
||||
}
|
||||
|
||||
String getPassword() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M)
|
||||
return this.password;
|
||||
else
|
||||
return Helper.decryptPassword(this.password);
|
||||
}
|
||||
|
||||
void setPassword(String plain) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M)
|
||||
this.password = plain;
|
||||
else
|
||||
this.password = Helper.encryptPassword(plain);
|
||||
}
|
||||
|
||||
static String getNotificationChannelName(long account) {
|
||||
return "notification" + (account == 0 ? "" : "." + account);
|
||||
}
|
||||
@@ -124,7 +138,7 @@ public class EntityAccount implements Serializable {
|
||||
json.put("insecure", insecure);
|
||||
json.put("port", port);
|
||||
json.put("user", user);
|
||||
json.put("password", password);
|
||||
json.put("password", getPassword());
|
||||
json.put("realm", realm);
|
||||
|
||||
json.put("name", name);
|
||||
@@ -156,7 +170,7 @@ public class EntityAccount implements Serializable {
|
||||
account.insecure = (json.has("insecure") && json.getBoolean("insecure"));
|
||||
account.port = json.getInt("port");
|
||||
account.user = json.getString("user");
|
||||
account.password = json.getString("password");
|
||||
account.setPassword(json.getString("password"));
|
||||
if (json.has("realm"))
|
||||
account.realm = json.getString("realm");
|
||||
|
||||
@@ -194,7 +208,7 @@ public class EntityAccount implements Serializable {
|
||||
this.insecure == other.insecure &&
|
||||
this.port.equals(other.port) &&
|
||||
this.user.equals(other.user) &&
|
||||
this.password.equals(other.password) &&
|
||||
this.getPassword().equals(other.getPassword()) &&
|
||||
Objects.equals(this.realm, other.realm) &&
|
||||
Objects.equals(this.name, other.name) &&
|
||||
Objects.equals(this.color, other.color) &&
|
||||
|
||||
@@ -19,6 +19,8 @@ package eu.faircode.email;
|
||||
Copyright 2018-2019 by Marcel Bokhorst (M66B)
|
||||
*/
|
||||
|
||||
import android.os.Build;
|
||||
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
@@ -99,6 +101,20 @@ public class EntityIdentity {
|
||||
return (starttls ? "smtp" : "smtps");
|
||||
}
|
||||
|
||||
String getPassword() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M)
|
||||
return this.password;
|
||||
else
|
||||
return Helper.decryptPassword(this.password);
|
||||
}
|
||||
|
||||
void setPassword(String plain) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M)
|
||||
this.password = plain;
|
||||
else
|
||||
this.password = Helper.encryptPassword(plain);
|
||||
}
|
||||
|
||||
public JSONObject toJSON() throws JSONException {
|
||||
JSONObject json = new JSONObject();
|
||||
json.put("id", id);
|
||||
@@ -116,7 +132,7 @@ public class EntityIdentity {
|
||||
json.put("insecure", insecure);
|
||||
json.put("port", port);
|
||||
json.put("user", user);
|
||||
json.put("password", password);
|
||||
json.put("password", getPassword());
|
||||
json.put("realm", realm);
|
||||
json.put("use_ip", use_ip);
|
||||
|
||||
@@ -154,7 +170,7 @@ public class EntityIdentity {
|
||||
identity.insecure = (json.has("insecure") && json.getBoolean("insecure"));
|
||||
identity.port = json.getInt("port");
|
||||
identity.user = json.getString("user");
|
||||
identity.password = json.getString("password");
|
||||
identity.setPassword(json.getString("password"));
|
||||
if (json.has("realm"))
|
||||
identity.realm = json.getString("realm");
|
||||
if (json.has("use_ip"))
|
||||
@@ -199,7 +215,7 @@ public class EntityIdentity {
|
||||
this.insecure.equals(other.insecure) &&
|
||||
this.port.equals(other.port) &&
|
||||
this.user.equals(other.user) &&
|
||||
this.password.equals(other.password) &&
|
||||
this.getPassword().equals(other.getPassword()) &&
|
||||
Objects.equals(this.realm, other.realm) &&
|
||||
this.use_ip == other.use_ip &&
|
||||
this.synchronize.equals(other.synchronize) &&
|
||||
|
||||
@@ -845,7 +845,7 @@ public class FragmentAccount extends FragmentBase {
|
||||
boolean check = (synchronize && (account == null ||
|
||||
auth_type != account.auth_type ||
|
||||
!host.equals(account.host) || Integer.parseInt(port) != account.port ||
|
||||
!user.equals(account.user) || !password.equals(account.password) ||
|
||||
!user.equals(account.user) || !password.equals(account.getPassword()) ||
|
||||
!Objects.equals(realm, accountRealm)));
|
||||
boolean reload = (check || account == null ||
|
||||
!Objects.equals(account.prefix, prefix) ||
|
||||
@@ -914,7 +914,7 @@ public class FragmentAccount extends FragmentBase {
|
||||
account.insecure = insecure;
|
||||
account.port = Integer.parseInt(port);
|
||||
account.user = user;
|
||||
account.password = password;
|
||||
account.setPassword(password);
|
||||
account.realm = realm;
|
||||
|
||||
account.name = name;
|
||||
@@ -1135,7 +1135,7 @@ public class FragmentAccount extends FragmentBase {
|
||||
|
||||
etUser.setTag(account == null || auth_type == Helper.AUTH_TYPE_PASSWORD ? null : account.user);
|
||||
etUser.setText(account == null ? null : account.user);
|
||||
tilPassword.getEditText().setText(account == null ? null : account.password);
|
||||
tilPassword.getEditText().setText(account == null ? null : account.getPassword());
|
||||
etRealm.setText(account == null ? null : account.realm);
|
||||
|
||||
etName.setText(account == null ? null : account.name);
|
||||
|
||||
@@ -237,7 +237,7 @@ public class FragmentIdentity extends FragmentBase {
|
||||
etEmail.setText(account.user);
|
||||
etUser.setTag(auth_type == Helper.AUTH_TYPE_PASSWORD ? null : account.user);
|
||||
etUser.setText(account.user);
|
||||
tilPassword.getEditText().setText(account.password);
|
||||
tilPassword.getEditText().setText(account.getPassword());
|
||||
etRealm.setText(account.realm);
|
||||
tilPassword.setEnabled(auth_type == Helper.AUTH_TYPE_PASSWORD);
|
||||
etRealm.setEnabled(auth_type == Helper.AUTH_TYPE_PASSWORD);
|
||||
@@ -589,7 +589,7 @@ public class FragmentIdentity extends FragmentBase {
|
||||
boolean check = (synchronize && (identity == null ||
|
||||
auth_type != identity.auth_type ||
|
||||
!host.equals(identity.host) || Integer.parseInt(port) != identity.port ||
|
||||
!user.equals(identity.user) || !password.equals(identity.password) ||
|
||||
!user.equals(identity.user) || !password.equals(identity.getPassword()) ||
|
||||
!Objects.equals(realm, identityRealm) ||
|
||||
use_ip != identity.use_ip));
|
||||
boolean reload = (identity == null || identity.synchronize != synchronize || check);
|
||||
@@ -655,7 +655,7 @@ public class FragmentIdentity extends FragmentBase {
|
||||
identity.insecure = insecure;
|
||||
identity.port = Integer.parseInt(port);
|
||||
identity.user = user;
|
||||
identity.password = password;
|
||||
identity.setPassword(password);
|
||||
identity.realm = realm;
|
||||
identity.use_ip = use_ip;
|
||||
identity.synchronize = synchronize;
|
||||
@@ -759,7 +759,7 @@ public class FragmentIdentity extends FragmentBase {
|
||||
etPort.setText(identity == null ? null : Long.toString(identity.port));
|
||||
etUser.setTag(identity == null || auth_type == Helper.AUTH_TYPE_PASSWORD ? null : identity.user);
|
||||
etUser.setText(identity == null ? null : identity.user);
|
||||
tilPassword.getEditText().setText(identity == null ? null : identity.password);
|
||||
tilPassword.getEditText().setText(identity == null ? null : identity.getPassword());
|
||||
etRealm.setText(identity == null ? null : identity.realm);
|
||||
cbUseIp.setChecked(identity == null ? true : identity.use_ip);
|
||||
cbSynchronize.setChecked(identity == null ? true : identity.synchronize);
|
||||
@@ -864,7 +864,7 @@ public class FragmentIdentity extends FragmentBase {
|
||||
spAccount.setSelection(pos);
|
||||
// OAuth token could be updated
|
||||
if (pos > 0 && accounts.get(pos).auth_type != Helper.AUTH_TYPE_PASSWORD)
|
||||
tilPassword.getEditText().setText(accounts.get(pos).password);
|
||||
tilPassword.getEditText().setText(accounts.get(pos).getPassword());
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -335,7 +335,7 @@ public class FragmentQuickSetup extends FragmentBase {
|
||||
account.insecure = false;
|
||||
account.port = provider.imap_port;
|
||||
account.user = user;
|
||||
account.password = password;
|
||||
account.setPassword(password);
|
||||
|
||||
account.name = provider.name;
|
||||
account.color = null;
|
||||
@@ -389,7 +389,7 @@ public class FragmentQuickSetup extends FragmentBase {
|
||||
identity.insecure = false;
|
||||
identity.port = provider.smtp_port;
|
||||
identity.user = user;
|
||||
identity.password = password;
|
||||
identity.setPassword(password);
|
||||
identity.synchronize = true;
|
||||
identity.primary = true;
|
||||
|
||||
|
||||
@@ -42,7 +42,10 @@ import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.os.PowerManager;
|
||||
import android.preference.PreferenceManager;
|
||||
import android.security.keystore.KeyGenParameterSpec;
|
||||
import android.security.keystore.KeyProperties;
|
||||
import android.text.TextUtils;
|
||||
import android.util.Base64;
|
||||
import android.view.Menu;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
@@ -77,6 +80,8 @@ import java.io.UnsupportedEncodingException;
|
||||
import java.net.InetAddress;
|
||||
import java.net.URL;
|
||||
import java.net.UnknownHostException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.KeyStore;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.text.DateFormat;
|
||||
@@ -91,6 +96,10 @@ import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.ThreadFactory;
|
||||
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.KeyGenerator;
|
||||
import javax.crypto.SecretKey;
|
||||
import javax.crypto.spec.GCMParameterSpec;
|
||||
import javax.mail.Address;
|
||||
import javax.mail.AuthenticationFailedException;
|
||||
import javax.mail.FolderClosedException;
|
||||
@@ -100,6 +109,7 @@ import javax.mail.internet.InternetAddress;
|
||||
import javax.net.ssl.HttpsURLConnection;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.RequiresApi;
|
||||
import androidx.browser.customtabs.CustomTabsIntent;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.lifecycle.Lifecycle;
|
||||
@@ -809,12 +819,12 @@ public class Helper {
|
||||
|
||||
static void connect(Context context, IMAPStore istore, EntityAccount account) throws MessagingException {
|
||||
try {
|
||||
istore.connect(account.host, account.port, account.user, account.password);
|
||||
istore.connect(account.host, account.port, account.user, account.getPassword());
|
||||
} catch (AuthenticationFailedException ex) {
|
||||
if (account.auth_type == AUTH_TYPE_GMAIL) {
|
||||
account.password = refreshToken(context, "com.google", account.user, account.password);
|
||||
account.setPassword(refreshToken(context, "com.google", account.user, account.getPassword()));
|
||||
DB.getInstance(context).account().setAccountPassword(account.id, account.password);
|
||||
istore.connect(account.host, account.port, account.user, account.password);
|
||||
istore.connect(account.host, account.port, account.user, account.getPassword());
|
||||
} else
|
||||
throw ex;
|
||||
}
|
||||
@@ -1043,4 +1053,56 @@ public class Helper {
|
||||
return organization;
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(api = Build.VERSION_CODES.M)
|
||||
private static SecretKey getSecretKey() throws Throwable {
|
||||
final String alias = BuildConfig.APPLICATION_ID + ".key";
|
||||
|
||||
KeyStore store = KeyStore.getInstance("AndroidKeyStore");
|
||||
store.load(null);
|
||||
|
||||
KeyStore.SecretKeyEntry entry = (KeyStore.SecretKeyEntry) store.getEntry(alias, null);
|
||||
if (entry != null)
|
||||
return entry.getSecretKey();
|
||||
|
||||
KeyGenerator generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore");
|
||||
KeyGenParameterSpec spec = new KeyGenParameterSpec.Builder(alias,
|
||||
KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT)
|
||||
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
||||
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||
.build();
|
||||
generator.init(spec);
|
||||
return generator.generateKey();
|
||||
}
|
||||
|
||||
@RequiresApi(api = Build.VERSION_CODES.M)
|
||||
static String decryptPassword(String secret) {
|
||||
try {
|
||||
int slash = secret.indexOf('/');
|
||||
byte[] iv = Base64.decode(secret.substring(0, slash), Base64.URL_SAFE);
|
||||
byte[] encrypted = Base64.decode(secret.substring(slash + 1), Base64.URL_SAFE);
|
||||
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
|
||||
GCMParameterSpec spec = new GCMParameterSpec(128, iv);
|
||||
cipher.init(Cipher.DECRYPT_MODE, getSecretKey(), spec);
|
||||
byte[] decrypted = cipher.doFinal(encrypted);
|
||||
return new String(decrypted, StandardCharsets.UTF_8);
|
||||
} catch (Throwable ex) {
|
||||
Log.e(ex);
|
||||
return secret;
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(api = Build.VERSION_CODES.M)
|
||||
static String encryptPassword(String plain) {
|
||||
try {
|
||||
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
|
||||
cipher.init(Cipher.ENCRYPT_MODE, getSecretKey());
|
||||
byte[] iv = cipher.getIV();
|
||||
byte[] encrypted = cipher.doFinal(plain.getBytes(StandardCharsets.UTF_8));
|
||||
return Base64.encodeToString(iv, Base64.URL_SAFE) + "/" + Base64.encodeToString(encrypted, Base64.URL_SAFE);
|
||||
} catch (Throwable ex) {
|
||||
Log.e(ex);
|
||||
return plain;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,13 +294,13 @@ public class ServiceSend extends LifecycleService {
|
||||
// Connect transport
|
||||
db.identity().setIdentityState(ident.id, "connecting");
|
||||
try {
|
||||
itransport.connect(ident.host, ident.port, ident.user, ident.password);
|
||||
itransport.connect(ident.host, ident.port, ident.user, ident.getPassword());
|
||||
} catch (AuthenticationFailedException ex) {
|
||||
if (ident.auth_type == Helper.AUTH_TYPE_GMAIL) {
|
||||
EntityAccount account = db.account().getAccount(ident.account);
|
||||
ident.password = Helper.refreshToken(this, "com.google", ident.user, account.password);
|
||||
ident.setPassword(Helper.refreshToken(this, "com.google", ident.user, account.getPassword()));
|
||||
DB.getInstance(this).identity().setIdentityPassword(ident.id, ident.password);
|
||||
itransport.connect(ident.host, ident.port, ident.user, ident.password);
|
||||
itransport.connect(ident.host, ident.port, ident.user, ident.getPassword());
|
||||
} else
|
||||
throw ex;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user