diff --git a/app/build.gradle b/app/build.gradle index 5d5cd006..2894c5e6 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -143,6 +143,8 @@ dependencies { githubImplementation "com.squareup.retrofit2:retrofit:2.9.0" githubImplementation 'org.kohsuke:github-api:1.314' + implementation 'com.opencsv:opencsv:5.8' + implementation "androidx.room:room-runtime:2.6.0" annotationProcessor "androidx.room:room-compiler:2.6.0" implementation "androidx.room:room-rxjava3:2.6.0" diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 0be39320..c79454c2 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,31 +3,51 @@ xmlns:tools="http://schemas.android.com/tools"> - + - - + + + tools:targetApi="34"> + + + + + + + + + + + - - - + android:theme="@style/AppTheme.NoActionBar" + android:windowSoftInputMode="adjustNothing" /> + + + @@ -44,6 +64,7 @@ + diff --git a/app/src/main/java/de/davis/passwordmanager/PasswordManagerApplication.java b/app/src/main/java/de/davis/passwordmanager/PasswordManagerApplication.java index 54bf2999..405b7809 100644 --- a/app/src/main/java/de/davis/passwordmanager/PasswordManagerApplication.java +++ b/app/src/main/java/de/davis/passwordmanager/PasswordManagerApplication.java @@ -25,6 +25,7 @@ public class PasswordManagerApplication extends Application { @Override public void onCreate() { super.onCreate(); + SecureElementDatabase.createAndGet(this); DynamicColors.applyToActivitiesIfAvailable(this); diff --git a/app/src/main/java/de/davis/passwordmanager/backup/DataBackup.java b/app/src/main/java/de/davis/passwordmanager/backup/DataBackup.java new file mode 100644 index 00000000..dcee037d --- /dev/null +++ b/app/src/main/java/de/davis/passwordmanager/backup/DataBackup.java @@ -0,0 +1,137 @@ +package de.davis.passwordmanager.backup; + +import static de.davis.passwordmanager.utils.BackgroundUtil.doInBackground; + +import android.content.ContentResolver; +import android.content.Context; +import android.net.Uri; +import android.os.Handler; +import android.os.Looper; +import android.widget.Toast; + +import androidx.annotation.IntDef; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.core.os.HandlerCompat; + +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + +import java.io.InputStream; +import java.io.OutputStream; + +import javax.crypto.AEADBadTagException; + +import de.davis.passwordmanager.R; +import de.davis.passwordmanager.dialog.LoadingDialog; + +public abstract class DataBackup { + + + public static final int TYPE_EXPORT = 0; + public static final int TYPE_IMPORT = 1; + + @IntDef({TYPE_EXPORT, TYPE_IMPORT}) + public @interface Type{} + + + private final Context context; + private LoadingDialog loadingDialog; + private final Handler handler = HandlerCompat.createAsync(Looper.getMainLooper()); + + public DataBackup(Context context) { + this.context = context; + } + + public Context getContext() { + return context; + } + + @Nullable + protected abstract Result runExport(OutputStream outputStream) throws Exception; + @Nullable + protected abstract Result runImport(InputStream inputStream) throws Exception; + + public void execute(@Type int type, @Nullable Uri uri){ + execute(type, uri, null); + } + + public void execute(@Type int type, @Nullable Uri uri, OnSyncedHandler onSyncedHandler){ + ContentResolver resolver = getContext().getContentResolver(); + + loadingDialog = new LoadingDialog(getContext()) + .setTitle(type == TYPE_EXPORT ? R.string.export : R.string.import_str) + .setMessage(R.string.wait_text); + AlertDialog alertDialog = loadingDialog.show(); + + doInBackground(() -> { + Result result = null; + try{ + switch (type){ + case TYPE_EXPORT -> result = runExport(resolver.openOutputStream(uri)); + case TYPE_IMPORT -> result = runImport(resolver.openInputStream(uri)); + } + + handleResult(result, onSyncedHandler); + }catch (Exception e){ + e.printStackTrace(); + if(e instanceof NullPointerException) + return; + + error(e); + }finally { + alertDialog.dismiss(); + } + }); + } + + protected void error(Exception exception){ + handler.post(() -> { + String msg = exception.getMessage(); + if(exception instanceof AEADBadTagException) + msg = getContext().getString(R.string.password_does_not_match); + + new MaterialAlertDialogBuilder(getContext()) + .setTitle(R.string.error_title) + .setMessage(msg) + .setPositiveButton(R.string.ok, (dialog, which) -> {}) + .show(); + }); + exception.printStackTrace(); + } + + protected void handleResult(Result result, OnSyncedHandler onSyncedHandler){ + handler.post(() -> { + if(result instanceof Result.Error error) + new MaterialAlertDialogBuilder(getContext()) + .setTitle(R.string.error_title) + .setMessage(error.getMessage()) + .setPositiveButton(R.string.ok, (dialog, which) -> handleSyncHandler(onSyncedHandler, result)) + .show(); + + else if (result instanceof Result.Duplicate duplicate) + new MaterialAlertDialogBuilder(getContext()) + .setTitle(R.string.warning) + .setMessage(getContext().getResources().getQuantityString(R.plurals.item_existed, duplicate.getCount(), duplicate.getCount())) + .setPositiveButton(R.string.ok, (dialog, which) -> handleSyncHandler(onSyncedHandler, result)) + .show(); + + else if (result instanceof Result.Success success) { + Toast.makeText(getContext(), success.getType() == TYPE_EXPORT ? R.string.backup_stored : R.string.backup_restored, Toast.LENGTH_LONG).show(); + handleSyncHandler(onSyncedHandler, result); + } + }); + } + + private void handleSyncHandler(OnSyncedHandler onSyncedHandler, Result result){ + if(onSyncedHandler != null) + onSyncedHandler.onSynced(result); + } + + protected void notifyUpdate(int current, int max){ + handler.post(() -> loadingDialog.updateProgress(current, max)); + } + + public interface OnSyncedHandler { + void onSynced(Result result); + } +} diff --git a/app/src/main/java/de/davis/passwordmanager/backup/Result.java b/app/src/main/java/de/davis/passwordmanager/backup/Result.java new file mode 100644 index 00000000..199eee8b --- /dev/null +++ b/app/src/main/java/de/davis/passwordmanager/backup/Result.java @@ -0,0 +1,46 @@ +package de.davis.passwordmanager.backup; + +import static de.davis.passwordmanager.backup.DataBackup.TYPE_IMPORT; + +public class Result { + public static class Success extends Result { + + @DataBackup.Type + private int type; + + public Success(int type) { + this.type = type; + } + + public int getType() { + return type; + } + } + + public static class Error extends Result { + + private final String message; + + public Error(String message) { + this.message = message; + } + + public String getMessage() { + return message; + } + } + + public static class Duplicate extends Success { + + private final int count; + + public Duplicate(int count) { + super(TYPE_IMPORT); + this.count = count; + } + + public int getCount() { + return count; + } + } +} diff --git a/app/src/main/java/de/davis/passwordmanager/backup/SecureDataBackup.java b/app/src/main/java/de/davis/passwordmanager/backup/SecureDataBackup.java new file mode 100644 index 00000000..54c6a103 --- /dev/null +++ b/app/src/main/java/de/davis/passwordmanager/backup/SecureDataBackup.java @@ -0,0 +1,70 @@ +package de.davis.passwordmanager.backup; + +import android.content.Context; +import android.content.DialogInterface; +import android.net.Uri; +import android.text.InputType; +import android.widget.EditText; + +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.content.res.AppCompatResources; + +import com.google.android.material.textfield.TextInputLayout; + +import de.davis.passwordmanager.R; +import de.davis.passwordmanager.dialog.EditDialogBuilder; +import de.davis.passwordmanager.ui.views.InformationView; + +public abstract class SecureDataBackup extends DataBackup { + + private String password; + + public SecureDataBackup(Context context) { + super(context); + } + + public String getPassword() { + return password; + } + + private void requestPassword(@Type int type, @Nullable Uri uri, OnSyncedHandler onSyncedHandler){ + InformationView.Information i = new InformationView.Information(); + i.setHint(getContext().getString(R.string.password)); + i.setInputType(InputType.TYPE_TEXT_VARIATION_PASSWORD); + i.setSecret(true); + + AlertDialog alertDialog = new EditDialogBuilder(getContext()) + .setTitle(R.string.password) + .setPositiveButton(R.string.yes, (dialog, which) -> {}) + .withInformation(i) + .withStartIcon(AppCompatResources.getDrawable(getContext(), R.drawable.ic_baseline_password_24)) + .setCancelable(type == TYPE_IMPORT) + .show(); + + /* + Needed for the error message that appears when the password (field) is empty. + otherwise the dialogue would close itself + */ + alertDialog.getButton(DialogInterface.BUTTON_POSITIVE).setOnClickListener(v -> { + String password = ((EditText)alertDialog.findViewById(R.id.textInputEditText)).getText().toString(); + + if(password.isEmpty()){ + ((TextInputLayout)alertDialog.findViewById(R.id.textInputLayout)) + .setError(getContext().getString(R.string.is_not_filled_in)); + return; + } + + alertDialog.dismiss(); + this.password = password; + + + super.execute(type, uri, onSyncedHandler); + }); + } + + @Override + public void execute(int type, @Nullable Uri uri, OnSyncedHandler onSyncedHandler) { + requestPassword(type, uri, onSyncedHandler); + } +} diff --git a/app/src/main/java/de/davis/passwordmanager/backup/csv/CsvBackup.java b/app/src/main/java/de/davis/passwordmanager/backup/csv/CsvBackup.java new file mode 100644 index 00000000..196efc18 --- /dev/null +++ b/app/src/main/java/de/davis/passwordmanager/backup/csv/CsvBackup.java @@ -0,0 +1,105 @@ +package de.davis.passwordmanager.backup.csv; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import com.opencsv.CSVReader; +import com.opencsv.CSVReaderBuilder; +import com.opencsv.CSVWriter; +import com.opencsv.CSVWriterBuilder; +import com.opencsv.validators.RowFunctionValidator; + +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.util.List; +import java.util.stream.Collectors; + +import de.davis.passwordmanager.R; +import de.davis.passwordmanager.backup.DataBackup; +import de.davis.passwordmanager.backup.Result; +import de.davis.passwordmanager.database.SecureElementDatabase; +import de.davis.passwordmanager.security.element.SecureElement; +import de.davis.passwordmanager.security.element.SecureElementManager; +import de.davis.passwordmanager.security.element.password.PasswordDetails; + +public class CsvBackup extends DataBackup { + + + public CsvBackup(Context context) { + super(context); + } + + @NonNull + @Override + protected Result runImport(InputStream inputStream) throws Exception { + CSVReader csvReader = new CSVReaderBuilder(new InputStreamReader(inputStream)) + .withSkipLines(1) + .withRowValidator(new RowFunctionValidator(s -> s.length == 5, getContext().getString(R.string.csv_row_number_error))) + .withRowValidator(new RowFunctionValidator(s -> s.length == 5, getContext().getString(R.string.csv_row_number_error))) + .build(); + + String[] line; + + List elements = SecureElementDatabase.getInstance() + .getSecureElementDao() + .getAllByType(SecureElement.TYPE_PASSWORD) + .blockingGet(); + + int existed = 0; + while ((line = csvReader.readNext()) != null) { + if(line[0].isEmpty() || line[3].isEmpty()) // name and password must not be empty + continue; + + String title = line[0]; + String origin = line[1]; + String username = line[2]; + String pwd = line[3]; + if(elements.stream().anyMatch(element -> element.getTitle().equals(title) + && ((PasswordDetails)element.getDetail()).getPassword().equals(pwd) + && ((PasswordDetails)element.getDetail()).getUsername().equals(username) + && ((PasswordDetails)element.getDetail()).getOrigin().equals(origin))) { + existed++; + continue; + } + + PasswordDetails details = new PasswordDetails(pwd, origin, username); + SecureElementManager.getInstance().createElement(new SecureElement(details, title)); + } + + csvReader.close(); + + if(existed != 0) + return new Result.Duplicate(existed); + + return new Result.Success(TYPE_IMPORT); + } + + @NonNull + @Override + protected Result runExport(OutputStream outputStream) throws Exception { + CSVWriter csvWriter = (CSVWriter) new CSVWriterBuilder(new OutputStreamWriter(outputStream)) + .build(); + + List elements = SecureElementDatabase.getInstance() + .getSecureElementDao() + .getAllByType(SecureElement.TYPE_PASSWORD) + .blockingGet(); + + csvWriter.writeNext(new String[]{"name", "url", "username", "password", "note"}); + + + csvWriter.writeAll(elements.stream().map(pwd -> new String[]{pwd.getTitle(), + ((PasswordDetails)pwd.getDetail()).getOrigin(), + ((PasswordDetails)pwd.getDetail()).getUsername(), + ((PasswordDetails)pwd.getDetail()).getPassword(), + null}).collect(Collectors.toList())); + + csvWriter.flush(); + csvWriter.close(); + + return new Result.Success(TYPE_EXPORT); + } +} diff --git a/app/src/main/java/de/davis/passwordmanager/backup/keygo/KeyGoBackup.java b/app/src/main/java/de/davis/passwordmanager/backup/keygo/KeyGoBackup.java new file mode 100644 index 00000000..5c987b04 --- /dev/null +++ b/app/src/main/java/de/davis/passwordmanager/backup/keygo/KeyGoBackup.java @@ -0,0 +1,132 @@ +package de.davis.passwordmanager.backup.keygo; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonDeserializationContext; +import com.google.gson.JsonDeserializer; +import com.google.gson.JsonElement; +import com.google.gson.JsonParseException; +import com.google.gson.JsonSerializationContext; +import com.google.gson.JsonSerializer; +import com.google.gson.reflect.TypeToken; + +import org.apache.commons.io.IOUtils; + +import java.io.InputStream; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.List; + +import de.davis.passwordmanager.R; +import de.davis.passwordmanager.backup.Result; +import de.davis.passwordmanager.backup.SecureDataBackup; +import de.davis.passwordmanager.database.SecureElementDatabase; +import de.davis.passwordmanager.gson.strategies.ExcludeAnnotationStrategy; +import de.davis.passwordmanager.security.Cryptography; +import de.davis.passwordmanager.security.element.ElementDetail; +import de.davis.passwordmanager.security.element.SecureElement; +import de.davis.passwordmanager.security.element.SecureElementDetail; +import de.davis.passwordmanager.security.element.SecureElementManager; +import de.davis.passwordmanager.security.element.password.PasswordDetails; + +public class KeyGoBackup extends SecureDataBackup { + + public static class ElementDetailTypeAdapter implements JsonSerializer, JsonDeserializer { + + @Override + public ElementDetail deserialize(JsonElement json, java.lang.reflect.Type typeOfT, JsonDeserializationContext context) throws JsonParseException { + int type = json.getAsJsonObject().get("type").getAsInt(); + if(type == SecureElement.TYPE_PASSWORD) { + JsonArray passwordArray = new JsonArray(); + for (byte b : Cryptography.encryptAES(json.getAsJsonObject().get("password").getAsString().getBytes())) { + passwordArray.add(b); + } + json.getAsJsonObject().add("password", passwordArray); + } + return context.deserialize(json, SecureElementDetail.getFor(type).getElementDetailClass()); + } + + @Override + public JsonElement serialize(ElementDetail src, java.lang.reflect.Type typeOfSrc, JsonSerializationContext context) { + JsonElement jsonObject = context.serialize(src); + if(src instanceof PasswordDetails pwdSrc) + jsonObject.getAsJsonObject().addProperty("password", pwdSrc.getPassword()); + + jsonObject.getAsJsonObject().addProperty("type", src.getType()); + return jsonObject; + } + } + + private final Gson gson; + + public KeyGoBackup(Context context) { + super(context); + this.gson = new GsonBuilder() + .registerTypeAdapter(ElementDetail.class, new ElementDetailTypeAdapter()) + .setExclusionStrategies(new ExcludeAnnotationStrategy()) + .create(); + } + + @NonNull + @Override + protected Result runImport(InputStream inputStream) throws Exception{ + byte[] file = IOUtils.toByteArray(inputStream); + if(file.length == 0) + return new Result.Error(getContext().getString(R.string.invalid_file_length)); + + file = Cryptography.decryptWithPwd(file, getPassword()); + List list; + try{ + list = gson.fromJson(new String(file), new TypeToken>(){}.getType()); + }catch (Exception e){ + return new Result.Error(getContext().getString(R.string.invalid_file)); + } + + List elements = SecureElementDatabase.getInstance() + .getSecureElementDao() + .getAllOnce() + .blockingGet(); + + int existed = 0; + int length = list.size(); + for (int i = 0; i < length; i++) { + SecureElement element = list.get(i); + if(elements.stream().anyMatch(e -> e.getTitle().equals(element.getTitle()) + && e.getDetail().equals(element.getDetail()))) { + existed++; + + notifyUpdate(i+1, length); + continue; + } + + SecureElementManager.getInstance().createElement(element); + notifyUpdate(i+1, length); + } + + if(existed != 0) + return new Result.Duplicate(existed); + + return new Result.Success(TYPE_IMPORT); + } + + @NonNull + @Override + protected Result runExport(OutputStream outputStream) throws Exception { + List elements = SecureElementDatabase.getInstance() + .getSecureElementDao() + .getAllOnce() + .blockingGet(); + + String j = gson.toJson(elements); + + outputStream.write(Cryptography.encryptWithPwd(j.getBytes(), getPassword())); + outputStream.close(); + + return new Result.Success(TYPE_EXPORT); + } +} diff --git a/app/src/main/java/de/davis/passwordmanager/database/daos/SecureElementDao.java b/app/src/main/java/de/davis/passwordmanager/database/daos/SecureElementDao.java index e8d186a6..d9611bfa 100644 --- a/app/src/main/java/de/davis/passwordmanager/database/daos/SecureElementDao.java +++ b/app/src/main/java/de/davis/passwordmanager/database/daos/SecureElementDao.java @@ -45,6 +45,9 @@ public void update(SecureElement element){ @Query("SELECT * FROM SecureElement ORDER BY ROWID ASC") public abstract LiveData> getAll(); + @Query("SELECT * FROM SecureElement ORDER BY ROWID ASC") + public abstract Single> getAllOnce(); + @Query("SELECT * FROM SecureElement WHERE type = :type ORDER BY ROWID ASC") public abstract Single> getAllByType(@SecureElement.ElementType int type); diff --git a/app/src/main/java/de/davis/passwordmanager/dialog/LoadingDialog.java b/app/src/main/java/de/davis/passwordmanager/dialog/LoadingDialog.java new file mode 100644 index 00000000..ec5f888b --- /dev/null +++ b/app/src/main/java/de/davis/passwordmanager/dialog/LoadingDialog.java @@ -0,0 +1,31 @@ +package de.davis.passwordmanager.dialog; + +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; + +import androidx.annotation.NonNull; + +import de.davis.passwordmanager.databinding.LoadingLayoutBinding; + +public class LoadingDialog extends BaseDialogBuilder { + + private LoadingLayoutBinding binding; + + public LoadingDialog(@NonNull Context context) { + super(context); + setCancelable(false); + } + + public void updateProgress(int current, int max){ + double progress = (current * 100d / max); + binding.progress.setIndeterminate(false); + binding.progress.setProgressCompat((int) progress, true); + } + + @Override + public View onCreateView(LayoutInflater inflater) { + binding = LoadingLayoutBinding.inflate(inflater); + return binding.getRoot(); + } +} diff --git a/app/src/main/java/de/davis/passwordmanager/gson/annotations/Exclude.java b/app/src/main/java/de/davis/passwordmanager/gson/annotations/Exclude.java new file mode 100644 index 00000000..7392857e --- /dev/null +++ b/app/src/main/java/de/davis/passwordmanager/gson/annotations/Exclude.java @@ -0,0 +1,11 @@ +package de.davis.passwordmanager.gson.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.FIELD) +public @interface Exclude { +} diff --git a/app/src/main/java/de/davis/passwordmanager/gson/strategies/ExcludeAnnotationStrategy.java b/app/src/main/java/de/davis/passwordmanager/gson/strategies/ExcludeAnnotationStrategy.java new file mode 100644 index 00000000..85d0cf25 --- /dev/null +++ b/app/src/main/java/de/davis/passwordmanager/gson/strategies/ExcludeAnnotationStrategy.java @@ -0,0 +1,18 @@ +package de.davis.passwordmanager.gson.strategies; + +import com.google.gson.ExclusionStrategy; +import com.google.gson.FieldAttributes; + +import de.davis.passwordmanager.gson.annotations.Exclude; + +public class ExcludeAnnotationStrategy implements ExclusionStrategy { + @Override + public boolean shouldSkipField(FieldAttributes f) { + return f.getAnnotation(Exclude.class) != null; + } + + @Override + public boolean shouldSkipClass(Class clazz) { + return false; + } +} diff --git a/app/src/main/java/de/davis/passwordmanager/security/Cryptography.java b/app/src/main/java/de/davis/passwordmanager/security/Cryptography.java index bb000bde..4dc6b5bc 100644 --- a/app/src/main/java/de/davis/passwordmanager/security/Cryptography.java +++ b/app/src/main/java/de/davis/passwordmanager/security/Cryptography.java @@ -4,10 +4,17 @@ import java.io.IOException; import java.security.GeneralSecurityException; +import java.security.spec.KeySpec; import java.util.Arrays; +import javax.crypto.BadPaddingException; import javax.crypto.Cipher; +import javax.crypto.IllegalBlockSizeException; +import javax.crypto.SecretKey; +import javax.crypto.SecretKeyFactory; import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.PBEKeySpec; +import javax.crypto.spec.SecretKeySpec; import at.favre.lib.crypto.bcrypt.BCrypt; import at.favre.lib.crypto.bcrypt.LongPasswordStrategies; @@ -26,18 +33,48 @@ public static boolean checkBcryptHash(String plaintext, byte[] hash){ return BCrypt.verifyer(VERSION_2A, LongPasswordStrategies.hashSha512(VERSION_2A)).verify(plaintext.getBytes(), hash).verified; } + public static byte[] encryptWithPwd(byte[] data, String pwd) throws GeneralSecurityException{ + byte[] salt = new byte[16]; + SecretKeyFactory factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256"); + KeySpec spec = new PBEKeySpec(pwd.toCharArray(), salt, 65536, 256); + SecretKey secretKey = factory.generateSecret(spec); + SecretKeySpec secret = new SecretKeySpec(secretKey.getEncoded(), "AES"); + + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(Cipher.ENCRYPT_MODE, secret); + + return encryptWithIV(cipher, data); + } + + public static byte[] decryptWithPwd(byte[] data, String pwd) throws GeneralSecurityException{ + byte[] salt = new byte[16]; + SecretKeyFactory factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256"); + KeySpec spec = new PBEKeySpec(pwd.toCharArray(), salt, 65536, 256); + SecretKey secretKey = factory.generateSecret(spec); + SecretKeySpec secret = new SecretKeySpec(secretKey.getEncoded(), "AES"); + + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(Cipher.DECRYPT_MODE, secret, new GCMParameterSpec(128, data, 0, IV_SIZE)); + + return cipher.doFinal(data, IV_SIZE, data.length-IV_SIZE); + } + + private static byte[] encryptWithIV(Cipher cipher, byte[] data) throws IllegalBlockSizeException, BadPaddingException { + byte[] iv = cipher.getIV(); + + byte[] encrypted = cipher.doFinal(data); + byte[] encryptedData = Arrays.copyOf(iv, iv.length + encrypted.length ); + System.arraycopy(encrypted, 0, encryptedData, iv.length, encrypted.length); + + return encryptedData; + } + public static byte[] encryptAES(byte[] data) { try { Cipher cipher = KeyUtil.getCipher(); cipher.init(Cipher.ENCRYPT_MODE, KeyUtil.getSecretKey()); - byte[] iv = cipher.getIV(); - - byte[] encrypted = cipher.doFinal(data); - byte[] encryptedData = Arrays.copyOf(iv, iv.length + encrypted.length ); - System.arraycopy(encrypted, 0, encryptedData, iv.length, encrypted.length); - - return encryptedData; + return encryptWithIV(cipher, data); }catch (GeneralSecurityException | IOException e){ e.printStackTrace(); } diff --git a/app/src/main/java/de/davis/passwordmanager/security/element/SecureElement.java b/app/src/main/java/de/davis/passwordmanager/security/element/SecureElement.java index aabc948e..5f23265e 100644 --- a/app/src/main/java/de/davis/passwordmanager/security/element/SecureElement.java +++ b/app/src/main/java/de/davis/passwordmanager/security/element/SecureElement.java @@ -21,6 +21,7 @@ import de.davis.passwordmanager.R; import de.davis.passwordmanager.dashboard.Item; +import de.davis.passwordmanager.gson.annotations.Exclude; @Entity public class SecureElement implements Serializable, Comparable, Item { @@ -45,6 +46,7 @@ public class SecureElement implements Serializable, Comparable, I private boolean favorite; @PrimaryKey(autoGenerate = true) + @Exclude private long id; @ColumnInfo(name = "created_at") diff --git a/app/src/main/java/de/davis/passwordmanager/security/element/SecureElementManager.java b/app/src/main/java/de/davis/passwordmanager/security/element/SecureElementManager.java index 94b7451b..99a23ee6 100644 --- a/app/src/main/java/de/davis/passwordmanager/security/element/SecureElementManager.java +++ b/app/src/main/java/de/davis/passwordmanager/security/element/SecureElementManager.java @@ -12,25 +12,23 @@ public class SecureElementManager { private static SecureElementManager instance; private final DashboardAdapter adapter; - private final TriggerDataChanged triggerDataChanged; + private TriggerDataChanged triggerDataChanged; - private SecureElementManager(TriggerDataChanged triggerDataChanged) { - this.triggerDataChanged = triggerDataChanged; + private SecureElementManager() { adapter = new DashboardAdapter(); } - public static SecureElementManager createNew(TriggerDataChanged triggerDataChanged){ - instance = new SecureElementManager(triggerDataChanged); - return instance; - } - public static SecureElementManager getInstance(){ if(instance == null) - throw new NullPointerException("call createNew first"); + instance = new SecureElementManager(); return instance; } + public void setTriggerDataChanged(TriggerDataChanged triggerDataChanged) { + this.triggerDataChanged = triggerDataChanged; + } + public DashboardAdapter getAdapter() { return adapter; } diff --git a/app/src/main/java/de/davis/passwordmanager/security/element/creditcard/CreditCardDetails.java b/app/src/main/java/de/davis/passwordmanager/security/element/creditcard/CreditCardDetails.java index 125b05b8..208c7891 100644 --- a/app/src/main/java/de/davis/passwordmanager/security/element/creditcard/CreditCardDetails.java +++ b/app/src/main/java/de/davis/passwordmanager/security/element/creditcard/CreditCardDetails.java @@ -1,5 +1,7 @@ package de.davis.passwordmanager.security.element.creditcard; +import java.util.Objects; + import de.davis.passwordmanager.security.element.ElementDetail; import de.davis.passwordmanager.security.element.SecureElement; import de.davis.passwordmanager.utils.CreditCardUtil; @@ -67,4 +69,28 @@ public void setExpirationDate(String expirationDate) { public int getType() { return SecureElement.TYPE_CREDIT_CARD; } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + CreditCardDetails that = (CreditCardDetails) o; + + if (!Objects.equals(cardholder, that.cardholder)) + return false; + if (!Objects.equals(expirationDate, that.expirationDate)) + return false; + if (!cardNumber.equals(that.cardNumber)) return false; + return Objects.equals(cvv, that.cvv); + } + + @Override + public int hashCode() { + int result = cardholder != null ? cardholder.hashCode() : 0; + result = 31 * result + (expirationDate != null ? expirationDate.hashCode() : 0); + result = 31 * result + cardNumber.hashCode(); + result = 31 * result + (cvv != null ? cvv.hashCode() : 0); + return result; + } } diff --git a/app/src/main/java/de/davis/passwordmanager/security/element/password/PasswordDetails.java b/app/src/main/java/de/davis/passwordmanager/security/element/password/PasswordDetails.java index 0306590e..5e58d8b7 100644 --- a/app/src/main/java/de/davis/passwordmanager/security/element/password/PasswordDetails.java +++ b/app/src/main/java/de/davis/passwordmanager/security/element/password/PasswordDetails.java @@ -1,5 +1,7 @@ package de.davis.passwordmanager.security.element.password; +import java.util.Objects; + import de.davis.passwordmanager.security.Cryptography; import de.davis.passwordmanager.security.element.ElementDetail; import de.davis.passwordmanager.security.element.SecureElement; @@ -57,4 +59,17 @@ public String getPassword(){ public int getType() { return SecureElement.TYPE_PASSWORD; } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + PasswordDetails that = (PasswordDetails) o; + return getPassword().equals(that.getPassword()) && Objects.equals(origin, that.origin) && Objects.equals(username, that.username); + } + + @Override + public int hashCode() { + return Objects.hash(origin, username, getPassword()); + } } diff --git a/app/src/main/java/de/davis/passwordmanager/ui/backup/BackupFragment.java b/app/src/main/java/de/davis/passwordmanager/ui/backup/BackupFragment.java new file mode 100644 index 00000000..935f0543 --- /dev/null +++ b/app/src/main/java/de/davis/passwordmanager/ui/backup/BackupFragment.java @@ -0,0 +1,135 @@ +package de.davis.passwordmanager.ui.backup; + +import android.content.Intent; +import android.os.Bundle; + +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; +import androidx.annotation.Nullable; +import androidx.preference.PreferenceFragmentCompat; + +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + +import de.davis.passwordmanager.PasswordManagerApplication; +import de.davis.passwordmanager.R; +import de.davis.passwordmanager.backup.DataBackup; +import de.davis.passwordmanager.backup.csv.CsvBackup; +import de.davis.passwordmanager.backup.keygo.KeyGoBackup; +import de.davis.passwordmanager.ui.login.LoginActivity; + +public class BackupFragment extends PreferenceFragmentCompat { + + ActivityResultLauncher auth; + + private static final String TYPE_KEYGO = "keygo"; + private static final String TYPE_CSV = "csv"; + + @Override + public void onCreatePreferences(@Nullable Bundle savedInstanceState, @Nullable String rootKey) { + addPreferencesFromResource(R.xml.backup_preferences); + + ActivityResultLauncher csvImportLauncher = registerForActivityResult(new ActivityResultContracts.OpenDocument(), result -> { + CsvBackup backup = new CsvBackup(requireContext()); + backup.execute(DataBackup.TYPE_IMPORT, result); + }); + + ActivityResultLauncher csvExportLauncher = registerForActivityResult(new ActivityResultContracts.CreateDocument("text/comma-separated-values"), result -> { + if(result == null) + return; + + + CsvBackup backup = new CsvBackup(requireContext()); + backup.execute(DataBackup.TYPE_EXPORT, result); + }); + + ActivityResultLauncher keygoExportLauncher = registerForActivityResult(new ActivityResultContracts.CreateDocument("application/octet-stream"), result -> { + if(result == null) + return; + + KeyGoBackup backup = new KeyGoBackup(requireContext()); + backup.execute(DataBackup.TYPE_EXPORT, result); + }); + + ActivityResultLauncher keygoImportLauncher = registerForActivityResult(new ActivityResultContracts.OpenDocument(), result -> { + if(result == null) + return; + + KeyGoBackup backup = new KeyGoBackup(requireContext()); + backup.execute(DataBackup.TYPE_IMPORT, result); + }); + + auth = registerForActivityResult(new ActivityResultContracts.StartActivityForResult(), result -> { + if(result == null || result.getData() == null) + return; + + Bundle data = result.getData().getExtras(); + if(data == null) + return; + + String formatType = data.getString("format_type"); + if(formatType == null) + return; + + switch (data.getInt("type")){ + case DataBackup.TYPE_EXPORT -> { + if(formatType.equals(TYPE_CSV)){ + ((PasswordManagerApplication)requireActivity().getApplication()).setShouldAuthenticate(false); + csvExportLauncher.launch("keygo-passwords.csv"); + }else if(formatType.equals(TYPE_KEYGO)){ + ((PasswordManagerApplication)requireActivity().getApplication()).setShouldAuthenticate(false); + keygoExportLauncher.launch("elements.keygo"); + } + } + case DataBackup.TYPE_IMPORT -> { + if(formatType.equals(TYPE_CSV)){ + ((PasswordManagerApplication)requireActivity().getApplication()).setShouldAuthenticate(false); + csvImportLauncher.launch(new String[]{"text/comma-separated-values"}); + }else if(formatType.equals(TYPE_KEYGO)){ + ((PasswordManagerApplication)requireActivity().getApplication()).setShouldAuthenticate(false); + keygoImportLauncher.launch(new String[]{"application/octet-stream"}); + } + } + } + + + }); + + findPreference(getString(R.string.preference_import_csv)).setOnPreferenceClickListener(preference -> { + launchAuth(DataBackup.TYPE_IMPORT, TYPE_CSV); + return true; + }); + + findPreference(getString(R.string.preference_export_csv)).setOnPreferenceClickListener(preference -> { + new MaterialAlertDialogBuilder(requireContext(), com.google.android.material.R.style.ThemeOverlay_Material3_MaterialAlertDialog_Centered) + .setTitle(R.string.warning) + .setMessage(R.string.csv_export_warning) + .setPositiveButton(R.string.text_continue, + (dialog, which) -> { + launchAuth(DataBackup.TYPE_EXPORT, TYPE_CSV); + }) + .setNegativeButton(R.string.use_keygo, (dialog, which) -> { + launchAuth(DataBackup.TYPE_EXPORT, TYPE_KEYGO); + }) + .setNeutralButton(R.string.cancel, (dialog, which) -> dialog.dismiss()) + .show(); + return true; + }); + + findPreference(getString(R.string.preference_export_keygo)).setOnPreferenceClickListener(preference -> { + launchAuth(DataBackup.TYPE_EXPORT, TYPE_KEYGO); + return true; + }); + + findPreference(getString(R.string.preference_import_keygo)).setOnPreferenceClickListener(preference -> { + launchAuth(DataBackup.TYPE_IMPORT, TYPE_KEYGO); + return true; + }); + } + + public void launchAuth(@DataBackup.Type int type, String format){ + Bundle bundle = new Bundle(); + bundle.putInt("type", type); + bundle.putString("format_type", format); + auth.launch(LoginActivity.getIntentForAuthentication(requireContext()).putExtra("data", bundle)); + } +} diff --git a/app/src/main/java/de/davis/passwordmanager/ui/dashboard/DashboardFragment.java b/app/src/main/java/de/davis/passwordmanager/ui/dashboard/DashboardFragment.java index 20d618ac..f0967f4d 100644 --- a/app/src/main/java/de/davis/passwordmanager/ui/dashboard/DashboardFragment.java +++ b/app/src/main/java/de/davis/passwordmanager/ui/dashboard/DashboardFragment.java @@ -81,7 +81,8 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat arm.registerCreate(); arm.registerEdit(null); - SecureElementManager manager = SecureElementManager.createNew(sem -> { + SecureElementManager manager = SecureElementManager.getInstance(); + manager.setTriggerDataChanged(sem -> { boolean hasElements = sem.hasElements(); binding.listPane.progress.setVisibility(View.GONE); diff --git a/app/src/main/java/de/davis/passwordmanager/ui/login/ChangePasswordFragment.java b/app/src/main/java/de/davis/passwordmanager/ui/login/ChangePasswordFragment.java index 2ef0d46e..e92c543b 100644 --- a/app/src/main/java/de/davis/passwordmanager/ui/login/ChangePasswordFragment.java +++ b/app/src/main/java/de/davis/passwordmanager/ui/login/ChangePasswordFragment.java @@ -1,5 +1,6 @@ package de.davis.passwordmanager.ui.login; +import android.app.Activity; import android.content.Intent; import android.os.Bundle; import android.view.LayoutInflater; @@ -18,6 +19,7 @@ import java.util.Objects; +import de.davis.passwordmanager.PasswordManagerApplication; import de.davis.passwordmanager.R; import de.davis.passwordmanager.databinding.FragmentChangePasswordBinding; import de.davis.passwordmanager.security.Authentication; @@ -97,8 +99,16 @@ public boolean onMenuItemSelected(@NonNull MenuItem menuItem) { PreferenceUtil.putBoolean(getContext(), R.string.preference_fingerprint, binding.fingerprint.isChecked()); Toast.makeText(getContext(), R.string.master_password_changed, Toast.LENGTH_LONG).show(); - if(requireActivity().getIntent().getExtras() == null) + //if(requireActivity().getIntent().getExtras() == null) + ((PasswordManagerApplication)requireActivity().getApplication()).setShouldAuthenticate(false); + + boolean intentAuthOnly = requireActivity().getIntent().getBooleanExtra(getString(R.string.preference_authenticate_only), false); + if(intentAuthOnly){ + requireActivity().setResult(Activity.RESULT_OK); + }else startActivity(new Intent(requireContext(), MainActivity.class)); + requireActivity().finish(); + ((PasswordManagerApplication)requireActivity().getApplication()).setShouldAuthenticate(true); } return true; diff --git a/app/src/main/java/de/davis/passwordmanager/ui/login/EnterPasswordFragment.java b/app/src/main/java/de/davis/passwordmanager/ui/login/EnterPasswordFragment.java index eb7d3302..fa5de3e0 100644 --- a/app/src/main/java/de/davis/passwordmanager/ui/login/EnterPasswordFragment.java +++ b/app/src/main/java/de/davis/passwordmanager/ui/login/EnterPasswordFragment.java @@ -59,6 +59,16 @@ public Intent onSuccess() { return null; } + boolean intentAuthOnly = requireActivity().getIntent().getBooleanExtra(getString(R.string.preference_authenticate_only), false); + if(intentAuthOnly){ + Intent i = new Intent(); + Bundle bundle = requireActivity().getIntent().getBundleExtra("data"); + if(bundle != null) + i.putExtras(bundle); + + return i; + } + startActivity(new Intent(getContext(), MainActivity.class)); return null; } diff --git a/app/src/main/java/de/davis/passwordmanager/ui/login/LoginActivity.java b/app/src/main/java/de/davis/passwordmanager/ui/login/LoginActivity.java index 951db4ce..f03ff322 100644 --- a/app/src/main/java/de/davis/passwordmanager/ui/login/LoginActivity.java +++ b/app/src/main/java/de/davis/passwordmanager/ui/login/LoginActivity.java @@ -8,7 +8,6 @@ import androidx.appcompat.app.AppCompatActivity; import de.davis.passwordmanager.R; -import de.davis.passwordmanager.database.SecureElementDatabase; import de.davis.passwordmanager.security.MasterPassword; public class LoginActivity extends AppCompatActivity { @@ -21,12 +20,7 @@ protected void onCreate(Bundle savedInstanceState) { if(savedInstanceState != null) return; - SecureElementDatabase.createAndGet(this); - if(getIntent().getBooleanExtra(getString(R.string.preference_authenticate_only), false)){ - getSupportFragmentManager().beginTransaction().replace(R.id.container, new EnterPasswordFragment()).commit(); - return; - } boolean masterPasswordAvailable = MasterPassword.getOne().blockingGet() != null; if(getIntent().getBooleanExtra(getString(R.string.preference_master_password), false)){ @@ -45,7 +39,7 @@ public static Intent getIntentForAuthentication(@NonNull Context context){ public static Intent getIntentForAuthentication(@NonNull Context context, Intent destActivity){ Intent intent = new Intent(context, LoginActivity.class); - intent.putExtra(context.getString(R.string.preference_authenticate_only), MasterPassword.getOne().blockingGet() != null); + intent.putExtra(context.getString(R.string.preference_authenticate_only), true); if(destActivity != null) intent.putExtra(context.getString(R.string.authentication_destination), destActivity); diff --git a/app/src/main/java/de/davis/passwordmanager/ui/settings/BaseSettingsFragment.java b/app/src/main/java/de/davis/passwordmanager/ui/settings/BaseSettingsFragment.java index 75783c5b..f1fc3d98 100644 --- a/app/src/main/java/de/davis/passwordmanager/ui/settings/BaseSettingsFragment.java +++ b/app/src/main/java/de/davis/passwordmanager/ui/settings/BaseSettingsFragment.java @@ -165,7 +165,17 @@ public boolean onPreferenceStartFragment(@NonNull PreferenceFragmentCompat calle return false; NavController navController = NavHostFragment.findNavController(fragment); - navController.navigate(R.id.updaterFragment); + + int id = -1; + switch (Objects.requireNonNull(pref.getFragment())){ + case "de.davis.passwordmanager.ui.settings.VersionFragment" -> id = R.id.updaterFragment; + case "de.davis.passwordmanager.ui.backup.BackupFragment" -> id = R.id.backupFragment; + } + + if(id == -1) + return false; + + navController.navigate(id); return true; } diff --git a/app/src/main/java/de/davis/passwordmanager/ui/sync/ImportActivity.java b/app/src/main/java/de/davis/passwordmanager/ui/sync/ImportActivity.java new file mode 100644 index 00000000..cbd1033a --- /dev/null +++ b/app/src/main/java/de/davis/passwordmanager/ui/sync/ImportActivity.java @@ -0,0 +1,49 @@ +package de.davis.passwordmanager.ui.sync; + +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; + +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; +import androidx.appcompat.app.AppCompatActivity; + +import de.davis.passwordmanager.backup.DataBackup; +import de.davis.passwordmanager.backup.keygo.KeyGoBackup; +import de.davis.passwordmanager.databinding.ActivityImportBinding; +import de.davis.passwordmanager.ui.MainActivity; +import de.davis.passwordmanager.ui.login.LoginActivity; + +public class ImportActivity extends AppCompatActivity { + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + ActivityImportBinding binding = ActivityImportBinding.inflate(getLayoutInflater()); + setContentView(binding.getRoot()); + + ActivityResultLauncher auth = registerForActivityResult(new ActivityResultContracts.StartActivityForResult(), result -> { + if(result.getResultCode() != RESULT_OK) + return; + + Intent intent = getIntent(); + if(intent == null || intent.getAction() == null) + return; + + if (!intent.getAction().equals(Intent.ACTION_VIEW)) + return; + + Uri fileUri = intent.getData(); + if(fileUri == null) + return; + + KeyGoBackup backup = new KeyGoBackup(this); + backup.execute(DataBackup.TYPE_IMPORT, fileUri, r -> { + startActivity(new Intent(this, MainActivity.class)); + }); + }); + + auth.launch(LoginActivity.getIntentForAuthentication(this)); + binding.button.setOnClickListener(v -> auth.launch(LoginActivity.getIntentForAuthentication(this))); + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable/baseline_settings_backup_restore_24.xml b/app/src/main/res/drawable/baseline_settings_backup_restore_24.xml new file mode 100644 index 00000000..c1f3cc27 --- /dev/null +++ b/app/src/main/res/drawable/baseline_settings_backup_restore_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/layout/activity_import.xml b/app/src/main/res/layout/activity_import.xml new file mode 100644 index 00000000..a18e6252 --- /dev/null +++ b/app/src/main/res/layout/activity_import.xml @@ -0,0 +1,26 @@ + + + + + +