Implement fingerprint login method (#244)

* Implement fingerprint login method

* Display a security warning

* Verify the identity before sensitive operation
This commit is contained in:
0140454 2018-04-25 04:34:39 +08:00 committed by m2049r
parent 1115bbb706
commit a2d6ca0740
18 changed files with 588 additions and 134 deletions

View File

@ -7,6 +7,7 @@
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.USE_FINGERPRINT" />
<application
android:allowBackup="true"

View File

@ -16,12 +16,15 @@
package com.m2049r.xmrwallet;
import android.app.AlertDialog;
import android.content.Context;
import android.content.DialogInterface;
import android.os.Bundle;
import android.support.annotation.Nullable;
import android.support.design.widget.TextInputLayout;
import android.support.v4.app.Fragment;
import android.text.Editable;
import android.text.Html;
import android.text.InputType;
import android.text.TextWatcher;
import android.view.KeyEvent;
@ -32,21 +35,23 @@ import android.view.View;
import android.view.ViewGroup;
import android.view.inputmethod.EditorInfo;
import android.widget.Button;
import android.widget.LinearLayout;
import android.widget.Switch;
import android.widget.TextView;
import com.m2049r.xmrwallet.model.Wallet;
import com.m2049r.xmrwallet.model.WalletManager;
import com.m2049r.xmrwallet.util.FingerprintHelper;
import com.m2049r.xmrwallet.util.Helper;
import com.m2049r.xmrwallet.util.KeyStoreHelper;
import com.m2049r.xmrwallet.util.RestoreHeight;
import com.m2049r.xmrwallet.widget.Toolbar;
import com.m2049r.xmrwallet.model.Wallet;
import com.m2049r.xmrwallet.model.WalletManager;
import com.m2049r.xmrwallet.util.Helper;
import com.nulabinc.zxcvbn.Strength;
import com.nulabinc.zxcvbn.Zxcvbn;
import java.io.File;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import timber.log.Timber;
@ -60,6 +65,7 @@ public class GenerateFragment extends Fragment {
private TextInputLayout etWalletName;
private TextInputLayout etWalletPassword;
private LinearLayout llFingerprintAuth;
private TextInputLayout etWalletAddress;
private TextInputLayout etWalletMnemonic;
private TextInputLayout etWalletViewKey;
@ -80,6 +86,7 @@ public class GenerateFragment extends Fragment {
etWalletName = (TextInputLayout) view.findViewById(R.id.etWalletName);
etWalletPassword = (TextInputLayout) view.findViewById(R.id.etWalletPassword);
llFingerprintAuth = (LinearLayout) view.findViewById(R.id.llFingerprintAuth);
etWalletMnemonic = (TextInputLayout) view.findViewById(R.id.etWalletMnemonic);
etWalletAddress = (TextInputLayout) view.findViewById(R.id.etWalletAddress);
etWalletViewKey = (TextInputLayout) view.findViewById(R.id.etWalletViewKey);
@ -147,6 +154,30 @@ public class GenerateFragment extends Fragment {
}
});
if (FingerprintHelper.isDeviceSupported(getContext())) {
llFingerprintAuth.setVisibility(View.VISIBLE);
final Switch swFingerprintAllowed = (Switch) llFingerprintAuth.getChildAt(0);
swFingerprintAllowed.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
if (!swFingerprintAllowed.isChecked()) return;
AlertDialog.Builder builder = new AlertDialog.Builder(getActivity());
builder.setMessage(Html.fromHtml(getString(R.string.generate_fingerprint_warn)))
.setCancelable(false)
.setPositiveButton(getString(R.string.label_ok), null)
.setNegativeButton(getString(R.string.label_cancel), new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialogInterface, int i) {
swFingerprintAllowed.setChecked(false);
}
})
.show();
}
});
}
if (type.equals(TYPE_NEW)) {
etWalletPassword.getEditText().setImeOptions(EditorInfo.IME_ACTION_DONE);
etWalletPassword.getEditText().setOnEditorActionListener(new TextView.OnEditorActionListener() {
@ -424,6 +455,7 @@ public class GenerateFragment extends Fragment {
String name = etWalletName.getEditText().getText().toString();
String password = etWalletPassword.getEditText().getText().toString();
boolean fingerprintAuthAllowed = ((Switch) llFingerprintAuth.getChildAt(0)).isChecked();
// create the real wallet password
String crazyPass = KeyStoreHelper.getCrazyPass(getActivity(), password);
@ -433,11 +465,17 @@ public class GenerateFragment extends Fragment {
if (type.equals(TYPE_NEW)) {
bGenerate.setEnabled(false);
if (fingerprintAuthAllowed) {
KeyStoreHelper.saveWalletUserPass(getActivity(), name, password);
}
activityCallback.onGenerate(name, crazyPass);
} else if (type.equals(TYPE_SEED)) {
if (!checkMnemonic()) return;
String seed = etWalletMnemonic.getEditText().getText().toString();
bGenerate.setEnabled(false);
if (fingerprintAuthAllowed) {
KeyStoreHelper.saveWalletUserPass(getActivity(), name, password);
}
activityCallback.onGenerate(name, crazyPass, seed, height);
} else if (type.equals(TYPE_KEY) || type.equals(TYPE_VIEWONLY)) {
if (checkAddress() && checkViewKey() && checkSpendKey()) {
@ -448,6 +486,9 @@ public class GenerateFragment extends Fragment {
if (type.equals(TYPE_KEY)) {
spendKey = etWalletSpendKey.getEditText().getText().toString();
}
if (fingerprintAuthAllowed) {
KeyStoreHelper.saveWalletUserPass(getActivity(), name, password);
}
activityCallback.onGenerate(name, crazyPass, address, viewKey, spendKey, height);
}
}

View File

@ -25,6 +25,7 @@ import android.support.annotation.Nullable;
import android.support.design.widget.TextInputLayout;
import android.support.v4.app.Fragment;
import android.text.Editable;
import android.text.Html;
import android.text.TextWatcher;
import android.view.KeyEvent;
import android.view.LayoutInflater;
@ -38,18 +39,21 @@ import android.widget.ImageButton;
import android.widget.LinearLayout;
import android.widget.ProgressBar;
import android.widget.ScrollView;
import android.widget.Switch;
import android.widget.TextView;
import android.widget.Toast;
import com.m2049r.xmrwallet.model.NetworkType;
import com.m2049r.xmrwallet.util.KeyStoreHelper;
import com.m2049r.xmrwallet.widget.Toolbar;
import com.m2049r.xmrwallet.model.Wallet;
import com.m2049r.xmrwallet.model.WalletManager;
import com.m2049r.xmrwallet.util.FingerprintHelper;
import com.m2049r.xmrwallet.util.Helper;
import com.m2049r.xmrwallet.util.KeyStoreHelper;
import com.m2049r.xmrwallet.util.MoneroThreadPoolExecutor;
import com.m2049r.xmrwallet.widget.Toolbar;
import java.io.File;
import java.security.KeyStoreException;
import timber.log.Timber;
@ -369,12 +373,21 @@ public class GenerateReviewFragment extends Fragment {
@Override
protected Boolean doInBackground(String... params) {
if (params.length != 3) return false;
if (params.length != 4) return false;
File walletFile = Helper.getWalletFile(getActivity(), params[0]);
String oldPassword = params[1];
String userPassword = params[2];
boolean fingerprintAuthAllowed = Boolean.valueOf(params[3]);
newPassword = KeyStoreHelper.getCrazyPass(getActivity(), userPassword);
return changeWalletPassword(newPassword);
boolean success = changeWalletPassword(newPassword);
if (success) {
if (fingerprintAuthAllowed) {
KeyStoreHelper.saveWalletUserPass(getActivity(), walletName, userPassword);
} else {
KeyStoreHelper.removeWalletUserPass(getActivity(), walletName);
}
}
return success;
}
@Override
@ -410,6 +423,37 @@ public class GenerateReviewFragment extends Fragment {
final TextInputLayout etPasswordB = (TextInputLayout) promptsView.findViewById(R.id.etWalletPasswordB);
etPasswordB.setHint(getString(R.string.prompt_changepwB, walletName));
LinearLayout llFingerprintAuth = (LinearLayout) promptsView.findViewById(R.id.llFingerprintAuth);
final Switch swFingerprintAllowed = (Switch) llFingerprintAuth.getChildAt(0);
if (FingerprintHelper.isDeviceSupported(getActivity())) {
llFingerprintAuth.setVisibility(View.VISIBLE);
swFingerprintAllowed.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
if (!swFingerprintAllowed.isChecked()) return;
AlertDialog.Builder builder = new AlertDialog.Builder(getActivity());
builder.setMessage(Html.fromHtml(getString(R.string.generate_fingerprint_warn)))
.setCancelable(false)
.setPositiveButton(getString(R.string.label_ok), null)
.setNegativeButton(getString(R.string.label_cancel), new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialogInterface, int i) {
swFingerprintAllowed.setChecked(false);
}
})
.show();
}
});
try {
swFingerprintAllowed.setChecked(FingerprintHelper.isFingerprintAuthAllowed(walletName));
} catch (KeyStoreException ex) {
ex.printStackTrace();
}
}
etPasswordA.getEditText().addTextChangedListener(new TextWatcher() {
@Override
public void afterTextChanged(Editable s) {
@ -483,7 +527,7 @@ public class GenerateReviewFragment extends Fragment {
} else if (!newPasswordA.equals(newPasswordB)) {
etPasswordB.setError(getString(R.string.generate_bad_passwordB));
} else if (newPasswordA.equals(newPasswordB)) {
new AsyncChangePassword().execute(walletName, walletPassword, newPasswordA);
new AsyncChangePassword().execute(walletName, walletPassword, newPasswordA, Boolean.toString(swFingerprintAllowed.isChecked()));
Helper.hideKeyboardAlways(getActivity());
openDialog.dismiss();
openDialog = null;
@ -505,7 +549,7 @@ public class GenerateReviewFragment extends Fragment {
} else if (!newPasswordA.equals(newPasswordB)) {
etPasswordB.setError(getString(R.string.generate_bad_passwordB));
} else if (newPasswordA.equals(newPasswordB)) {
new AsyncChangePassword().execute(walletName, walletPassword, newPasswordA);
new AsyncChangePassword().execute(walletName, walletPassword, newPasswordA, Boolean.toString(swFingerprintAllowed.isChecked()));
Helper.hideKeyboardAlways(getActivity());
openDialog.dismiss();
openDialog = null;

View File

@ -29,18 +29,14 @@ import android.os.AsyncTask;
import android.os.Bundle;
import android.os.Handler;
import android.support.annotation.NonNull;
import android.support.design.widget.TextInputLayout;
import android.support.v4.app.Fragment;
import android.support.v4.app.FragmentManager;
import android.support.v4.app.FragmentTransaction;
import android.text.Editable;
import android.text.TextWatcher;
import android.view.KeyEvent;
import android.view.LayoutInflater;
import android.view.MenuItem;
import android.view.View;
import android.view.inputmethod.EditorInfo;
import android.widget.Button;
import android.widget.EditText;
import android.widget.TextView;
import android.widget.Toast;
@ -54,7 +50,7 @@ import com.m2049r.xmrwallet.model.NetworkType;
import com.m2049r.xmrwallet.model.Wallet;
import com.m2049r.xmrwallet.model.WalletManager;
import com.m2049r.xmrwallet.service.WalletService;
import com.m2049r.xmrwallet.util.CrazyPassEncoder;
import com.m2049r.xmrwallet.util.FingerprintHelper;
import com.m2049r.xmrwallet.util.Helper;
import com.m2049r.xmrwallet.util.KeyStoreHelper;
import com.m2049r.xmrwallet.util.MoneroThreadPoolExecutor;
@ -67,6 +63,7 @@ import java.io.IOException;
import java.net.Socket;
import java.net.SocketAddress;
import java.nio.channels.FileChannel;
import java.security.KeyStoreException;
import java.util.Date;
import timber.log.Timber;
@ -179,9 +176,9 @@ public class LoginActivity extends SecureActivity
case DialogInterface.BUTTON_POSITIVE:
final File walletFile = Helper.getWalletFile(LoginActivity.this, walletName);
if (WalletManager.getInstance().walletExists(walletFile)) {
promptPassword(walletName, new PasswordAction() {
Helper.promptPassword(LoginActivity.this, walletName, true, new Helper.PasswordAction() {
@Override
public void action(String walletName, String password) {
public void action(String walletName, String password, boolean fingerprintUsed) {
startDetails(walletFile, password, GenerateReviewFragment.VIEW_TYPE_DETAILS);
}
});
@ -211,9 +208,9 @@ public class LoginActivity extends SecureActivity
if (checkServiceRunning()) return;
final File walletFile = Helper.getWalletFile(this, walletName);
if (WalletManager.getInstance().walletExists(walletFile)) {
promptPassword(walletName, new PasswordAction() {
Helper.promptPassword(LoginActivity.this, walletName, false, new Helper.PasswordAction() {
@Override
public void action(String walletName, String password) {
public void action(String walletName, String password, boolean fingerprintUsed) {
startReceive(walletFile, password);
}
});
@ -234,7 +231,17 @@ public class LoginActivity extends SecureActivity
if (params.length != 2) return false;
File walletFile = Helper.getWalletFile(LoginActivity.this, params[0]);
String newName = params[1];
return renameWallet(walletFile, newName);
boolean success = renameWallet(walletFile, newName);
try {
if (success && FingerprintHelper.isFingerprintAuthAllowed(params[0])) {
String savedPass = KeyStoreHelper.loadWalletUserPass(LoginActivity.this, params[0]);
KeyStoreHelper.saveWalletUserPass(LoginActivity.this, newName, savedPass);
KeyStoreHelper.removeWalletUserPass(LoginActivity.this, params[0]);
}
} catch (KeyStoreException ex) {
ex.printStackTrace();
}
return success;
}
@Override
@ -381,6 +388,7 @@ public class LoginActivity extends SecureActivity
if (params.length != 1) return false;
String walletName = params[0];
if (backupWallet(walletName) && deleteWallet(Helper.getWalletFile(LoginActivity.this, walletName))) {
KeyStoreHelper.removeWalletUserPass(LoginActivity.this, walletName);
return true;
} else {
return false;
@ -460,110 +468,6 @@ public class LoginActivity extends SecureActivity
startGenerateFragment(type);
}
AlertDialog openDialog = null; // for preventing opening of multiple dialogs
void promptPassword(final String wallet, final PasswordAction action) {
if (openDialog != null) return; // we are already asking for password
Context context = LoginActivity.this;
LayoutInflater li = LayoutInflater.from(context);
View promptsView = li.inflate(R.layout.prompt_password, null);
AlertDialog.Builder alertDialogBuilder = new AlertDialog.Builder(context);
alertDialogBuilder.setView(promptsView);
final TextInputLayout etPassword = (TextInputLayout) promptsView.findViewById(R.id.etPassword);
etPassword.setHint(LoginActivity.this.getString(R.string.prompt_password, wallet));
etPassword.getEditText().addTextChangedListener(new TextWatcher() {
@Override
public void afterTextChanged(Editable s) {
if (etPassword.getError() != null) {
etPassword.setError(null);
}
}
@Override
public void beforeTextChanged(CharSequence s, int start,
int count, int after) {
}
@Override
public void onTextChanged(CharSequence s, int start,
int before, int count) {
}
});
// set dialog message
alertDialogBuilder
.setCancelable(false)
.setPositiveButton(getString(R.string.label_ok), null)
.setNegativeButton(getString(R.string.label_cancel),
new DialogInterface.OnClickListener() {
public void onClick(DialogInterface dialog, int id) {
Helper.hideKeyboardAlways(LoginActivity.this);
dialog.cancel();
openDialog = null;
}
});
openDialog = alertDialogBuilder.create();
openDialog.setOnShowListener(new DialogInterface.OnShowListener() {
@Override
public void onShow(DialogInterface dialog) {
Button button = ((AlertDialog) dialog).getButton(AlertDialog.BUTTON_POSITIVE);
button.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
String pass = etPassword.getEditText().getText().toString();
if (processPasswordEntry(wallet, pass, action)) {
Helper.hideKeyboardAlways(LoginActivity.this);
openDialog.dismiss();
openDialog = null;
} else {
etPassword.setError(getString(R.string.bad_password));
}
}
});
}
});
// accept keyboard "ok"
etPassword.getEditText().setOnEditorActionListener(new TextView.OnEditorActionListener() {
public boolean onEditorAction(TextView v, int actionId, KeyEvent event) {
if ((event != null && (event.getKeyCode() == KeyEvent.KEYCODE_ENTER)) || (actionId == EditorInfo.IME_ACTION_DONE)) {
String pass = etPassword.getEditText().getText().toString();
if (processPasswordEntry(wallet, pass, action)) {
Helper.hideKeyboardAlways(LoginActivity.this);
openDialog.dismiss();
openDialog = null;
} else {
etPassword.setError(getString(R.string.bad_password));
}
return true;
}
return false;
}
});
Helper.showKeyboard(openDialog);
openDialog.show();
}
interface PasswordAction {
void action(String walletName, String password);
}
private boolean processPasswordEntry(String walletName, String pass, PasswordAction action) {
String walletPassword = Helper.getWalletPassword(getApplicationContext(), walletName, pass);
if (walletPassword != null) {
action.action(walletName, walletPassword);
return true;
} else {
return false;
}
}
////////////////////////////////////////
// LoginFragment.Listener
////////////////////////////////////////
@ -699,11 +603,12 @@ public class LoginActivity extends SecureActivity
}
}
void startWallet(String walletName, String walletPassword) {
void startWallet(String walletName, String walletPassword, boolean fingerprintUsed) {
Timber.d("startWallet()");
Intent intent = new Intent(getApplicationContext(), WalletActivity.class);
intent.putExtra(WalletActivity.REQUEST_ID, walletName);
intent.putExtra(WalletActivity.REQUEST_PW, walletPassword);
intent.putExtra(WalletActivity.REQUEST_FINGERPRINT_USED, fingerprintUsed);
startActivity(intent);
}
@ -1193,10 +1098,10 @@ public class LoginActivity extends SecureActivity
File walletFile = Helper.getWalletFile(this, walletNode.getName());
if (WalletManager.getInstance().walletExists(walletFile)) {
WalletManager.getInstance().setDaemon(walletNode);
promptPassword(walletNode.getName(), new PasswordAction() {
Helper.promptPassword(LoginActivity.this, walletNode.getName(), false, new Helper.PasswordAction() {
@Override
public void action(String walletName, String password) {
startWallet(walletName, password);
public void action(String walletName, String password, boolean fingerprintUsed) {
startWallet(walletName, password, fingerprintUsed);
}
});
} else { // this cannot really happen as we prefilter choices

View File

@ -53,6 +53,7 @@ import com.m2049r.xmrwallet.layout.WalletInfoAdapter;
import com.m2049r.xmrwallet.model.NetworkType;
import com.m2049r.xmrwallet.model.WalletManager;
import com.m2049r.xmrwallet.util.Helper;
import com.m2049r.xmrwallet.util.KeyStoreHelper;
import com.m2049r.xmrwallet.util.NodeList;
import com.m2049r.xmrwallet.util.Notice;
import com.m2049r.xmrwallet.widget.DropDownEditText;
@ -61,6 +62,7 @@ import com.m2049r.xmrwallet.widget.Toolbar;
import java.io.File;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import timber.log.Timber;
@ -311,6 +313,17 @@ public class LoginFragment extends Fragment implements WalletInfoAdapter.OnInter
ivGunther.setImageDrawable(null);
}
}
// remove information of non-existent wallet
Set<String> removedWallets = getActivity()
.getSharedPreferences(KeyStoreHelper.SecurityConstants.WALLET_PASS_PREFS_NAME, Context.MODE_PRIVATE)
.getAll().keySet();
for (WalletManager.WalletInfo s : walletList) {
removedWallets.remove(s.name);
}
for (String name : removedWallets) {
KeyStoreHelper.removeWalletUserPass(getActivity(), name);
}
}
private void showInfo(@NonNull String name) {

View File

@ -49,8 +49,6 @@ import com.m2049r.xmrwallet.util.Helper;
import com.m2049r.xmrwallet.util.UserNotes;
import com.m2049r.xmrwallet.widget.Toolbar;
import java.io.File;
import timber.log.Timber;
public class WalletActivity extends SecureActivity implements WalletFragment.Listener,
@ -62,8 +60,10 @@ public class WalletActivity extends SecureActivity implements WalletFragment.Lis
public static final String REQUEST_ID = "id";
public static final String REQUEST_PW = "pw";
public static final String REQUEST_FINGERPRINT_USED = "fingerprint";
private Toolbar toolbar;
private boolean needVerifyIdentity;
@Override
public void setToolbarButton(int type) {
@ -120,6 +120,7 @@ public class WalletActivity extends SecureActivity implements WalletFragment.Lis
acquireWakeLock();
String walletId = extras.getString(REQUEST_ID);
String walletPassword = extras.getString(REQUEST_PW);
needVerifyIdentity = extras.getBoolean(REQUEST_FINGERPRINT_USED);
connectWalletService(walletId, walletPassword);
} else {
finish();
@ -397,7 +398,17 @@ public class WalletActivity extends SecureActivity implements WalletFragment.Lis
@Override
public void onSendRequest() {
if (needVerifyIdentity) {
Helper.promptPassword(WalletActivity.this, getWallet().getName(), true, new Helper.PasswordAction() {
@Override
public void action(String walletName, String password, boolean fingerprintUsed) {
replaceFragment(new SendFragment(), null, null);
needVerifyIdentity = false;
}
});
} else {
replaceFragment(new SendFragment(), null, null);
}
}
@Override
@ -697,10 +708,22 @@ public class WalletActivity extends SecureActivity implements WalletFragment.Lis
public void onClick(DialogInterface dialog, int which) {
switch (which) {
case DialogInterface.BUTTON_POSITIVE:
Bundle extras = new Bundle();
final Bundle extras = new Bundle();
extras.putString("type", GenerateReviewFragment.VIEW_TYPE_WALLET);
extras.putString("password", getIntent().getExtras().getString(REQUEST_PW));
if (needVerifyIdentity) {
Helper.promptPassword(WalletActivity.this, getWallet().getName(), true, new Helper.PasswordAction() {
@Override
public void action(String walletName, String password, boolean fingerprintUsed) {
replaceFragment(new GenerateReviewFragment(), null, extras);
needVerifyIdentity = false;
}
});
} else {
replaceFragment(new GenerateReviewFragment(), null, extras);
}
break;
case DialogInterface.BUTTON_NEGATIVE:
// do nothing

View File

@ -0,0 +1,40 @@
package com.m2049r.xmrwallet.util;
import android.app.KeyguardManager;
import android.content.Context;
import android.support.v4.hardware.fingerprint.FingerprintManagerCompat;
import android.support.v4.os.CancellationSignal;
import java.security.KeyStore;
import java.security.KeyStoreException;
public class FingerprintHelper {
public static boolean isDeviceSupported(Context context) {
FingerprintManagerCompat fingerprintManager = FingerprintManagerCompat.from(context);
KeyguardManager keyguardManager = (KeyguardManager) context.getSystemService(Context.KEYGUARD_SERVICE);
return keyguardManager != null &&
keyguardManager.isKeyguardSecure() &&
fingerprintManager.isHardwareDetected() &&
fingerprintManager.hasEnrolledFingerprints();
}
public static boolean isFingerprintAuthAllowed(String wallet) throws KeyStoreException {
KeyStore keyStore = KeyStore.getInstance(KeyStoreHelper.SecurityConstants.KEYSTORE_PROVIDER_ANDROID_KEYSTORE);
try {
keyStore.load(null);
} catch (Exception ex) {
throw new IllegalStateException("Could not load KeyStore", ex);
}
return keyStore.containsAlias(KeyStoreHelper.SecurityConstants.WALLET_PASS_KEY_PREFIX + wallet);
}
public static void authenticate(Context context, CancellationSignal cancelSignal,
FingerprintManagerCompat.AuthenticationCallback callback) {
FingerprintManagerCompat manager = FingerprintManagerCompat.from(context);
manager.authenticate(null, 0, cancelSignal, callback, null);
}
}

View File

@ -18,10 +18,12 @@ package com.m2049r.xmrwallet.util;
import android.Manifest;
import android.app.Activity;
import android.app.AlertDialog;
import android.app.Dialog;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.Context;
import android.content.DialogInterface;
import android.content.pm.PackageManager;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
@ -30,13 +32,24 @@ import android.graphics.drawable.BitmapDrawable;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.VectorDrawable;
import android.os.Environment;
import android.support.design.widget.TextInputLayout;
import android.support.v4.content.ContextCompat;
import android.support.v4.hardware.fingerprint.FingerprintManagerCompat;
import android.support.v4.os.CancellationSignal;
import android.system.ErrnoException;
import android.system.Os;
import android.text.Editable;
import android.text.TextWatcher;
import android.view.KeyEvent;
import android.view.LayoutInflater;
import android.view.View;
import android.view.WindowManager;
import android.view.animation.Animation;
import android.view.animation.AnimationUtils;
import android.view.inputmethod.EditorInfo;
import android.view.inputmethod.InputMethodManager;
import android.widget.Button;
import android.widget.TextView;
import com.m2049r.xmrwallet.BuildConfig;
import com.m2049r.xmrwallet.R;
@ -51,6 +64,7 @@ import java.math.BigInteger;
import java.net.MalformedURLException;
import java.net.SocketTimeoutException;
import java.net.URL;
import java.security.KeyStoreException;
import java.util.Locale;
import javax.net.ssl.HttpsURLConnection;
@ -340,4 +354,150 @@ public class Helper {
return null;
}
static AlertDialog openDialog = null; // for preventing opening of multiple dialogs
static public void promptPassword(final Context context, final String wallet, boolean fingerprintDisabled, final PasswordAction action) {
if (openDialog != null) return; // we are already asking for password
LayoutInflater li = LayoutInflater.from(context);
final View promptsView = li.inflate(R.layout.prompt_password, null);
AlertDialog.Builder alertDialogBuilder = new AlertDialog.Builder(context);
alertDialogBuilder.setView(promptsView);
final TextInputLayout etPassword = (TextInputLayout) promptsView.findViewById(R.id.etPassword);
etPassword.setHint(context.getString(R.string.prompt_password, wallet));
boolean fingerprintAuthCheck;
try {
fingerprintAuthCheck = FingerprintHelper.isFingerprintAuthAllowed(wallet);
} catch (KeyStoreException ex) {
fingerprintAuthCheck = false;
}
final boolean fingerprintAuthAllowed = !fingerprintDisabled && fingerprintAuthCheck;
final CancellationSignal cancelSignal = new CancellationSignal();
if (fingerprintAuthAllowed) {
promptsView.findViewById(R.id.txtFingerprintAuth).setVisibility(View.VISIBLE);
}
etPassword.getEditText().addTextChangedListener(new TextWatcher() {
@Override
public void afterTextChanged(Editable s) {
if (etPassword.getError() != null) {
etPassword.setError(null);
}
}
@Override
public void beforeTextChanged(CharSequence s, int start,
int count, int after) {
}
@Override
public void onTextChanged(CharSequence s, int start,
int before, int count) {
}
});
// set dialog message
alertDialogBuilder
.setCancelable(false)
.setPositiveButton(context.getString(R.string.label_ok), null)
.setNegativeButton(context.getString(R.string.label_cancel),
new DialogInterface.OnClickListener() {
public void onClick(DialogInterface dialog, int id) {
Helper.hideKeyboardAlways((Activity) context);
cancelSignal.cancel();
dialog.cancel();
openDialog = null;
}
});
openDialog = alertDialogBuilder.create();
final FingerprintManagerCompat.AuthenticationCallback fingerprintAuthCallback = new FingerprintManagerCompat.AuthenticationCallback() {
@Override
public void onAuthenticationError(int errMsgId, CharSequence errString) {
((TextView) promptsView.findViewById(R.id.txtFingerprintAuth)).setText(errString);
}
@Override
public void onAuthenticationSucceeded(FingerprintManagerCompat.AuthenticationResult result) {
String userPass = KeyStoreHelper.loadWalletUserPass(context, wallet);
if (Helper.processPasswordEntry(context, wallet, userPass, true, action)) {
Helper.hideKeyboardAlways((Activity) context);
openDialog.dismiss();
openDialog = null;
} else {
etPassword.setError(context.getString(R.string.bad_password));
}
}
@Override
public void onAuthenticationFailed() {
((TextView) promptsView.findViewById(R.id.txtFingerprintAuth))
.setText(context.getString(R.string.bad_fingerprint));
}
};
openDialog.setOnShowListener(new DialogInterface.OnShowListener() {
@Override
public void onShow(DialogInterface dialog) {
if (fingerprintAuthAllowed) {
FingerprintHelper.authenticate(context, cancelSignal, fingerprintAuthCallback);
}
Button button = ((AlertDialog) dialog).getButton(AlertDialog.BUTTON_POSITIVE);
button.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
String pass = etPassword.getEditText().getText().toString();
if (processPasswordEntry(context, wallet, pass, false, action)) {
Helper.hideKeyboardAlways((Activity) context);
openDialog.dismiss();
openDialog = null;
} else {
etPassword.setError(context.getString(R.string.bad_password));
}
}
});
}
});
// accept keyboard "ok"
etPassword.getEditText().setOnEditorActionListener(new TextView.OnEditorActionListener() {
public boolean onEditorAction(TextView v, int actionId, KeyEvent event) {
if ((event != null && (event.getKeyCode() == KeyEvent.KEYCODE_ENTER)) || (actionId == EditorInfo.IME_ACTION_DONE)) {
String pass = etPassword.getEditText().getText().toString();
if (processPasswordEntry(context, wallet, pass, false, action)) {
Helper.hideKeyboardAlways((Activity) context);
openDialog.dismiss();
openDialog = null;
} else {
etPassword.setError(context.getString(R.string.bad_password));
}
return true;
}
return false;
}
});
Helper.showKeyboard(openDialog);
openDialog.show();
}
public interface PasswordAction {
void action(String walletName, String password, boolean fingerprintUsed);
}
static private boolean processPasswordEntry(Context context, String walletName, String pass, boolean fingerprintUsed, PasswordAction action) {
String walletPassword = Helper.getWalletPassword(context, walletName, pass);
if (walletPassword != null) {
action.action(walletName, walletPassword, fingerprintUsed);
return true;
} else {
return false;
}
}
}

View File

@ -23,6 +23,7 @@ import android.os.Build;
import android.security.KeyPairGeneratorSpec;
import android.security.keystore.KeyGenParameterSpec;
import android.security.keystore.KeyProperties;
import android.util.Base64;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
@ -35,11 +36,13 @@ import java.security.KeyStoreException;
import java.security.NoSuchAlgorithmException;
import java.security.NoSuchProviderException;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.Signature;
import java.security.SignatureException;
import java.util.Calendar;
import java.util.GregorianCalendar;
import javax.crypto.Cipher;
import javax.security.auth.x500.X500Principal;
import timber.log.Timber;
@ -71,6 +74,41 @@ public class KeyStoreHelper {
return CrazyPassEncoder.encode(cnSlowHash(sig));
}
public static void saveWalletUserPass(Context context, String wallet, String password) {
String walletKeyAlias = SecurityConstants.WALLET_PASS_KEY_PREFIX + wallet;
byte[] data = password.getBytes(StandardCharsets.UTF_8);
try {
KeyStoreHelper.createKeys(context, walletKeyAlias);
byte[] encrypted = KeyStoreHelper.encrypt(walletKeyAlias, data);
context.getSharedPreferences(SecurityConstants.WALLET_PASS_PREFS_NAME, Context.MODE_PRIVATE).edit()
.putString(wallet, Base64.encodeToString(encrypted, Base64.DEFAULT))
.apply();
} catch (Exception ex) {
throw new IllegalStateException(ex);
}
}
public static String loadWalletUserPass(Context context, String wallet) {
String walletKeyAlias = SecurityConstants.WALLET_PASS_KEY_PREFIX + wallet;
String encoded = context.getSharedPreferences(SecurityConstants.WALLET_PASS_PREFS_NAME, Context.MODE_PRIVATE)
.getString(wallet, "");
byte[] data = Base64.decode(encoded, Base64.DEFAULT);
byte[] decrypted = KeyStoreHelper.decrypt(walletKeyAlias, data);
return new String(decrypted, StandardCharsets.UTF_8);
}
public static void removeWalletUserPass(Context context, String wallet) {
String walletKeyAlias = SecurityConstants.WALLET_PASS_KEY_PREFIX + wallet;
try {
KeyStoreHelper.deleteKeys(walletKeyAlias);
context.getSharedPreferences(SecurityConstants.WALLET_PASS_PREFS_NAME, Context.MODE_PRIVATE).edit()
.remove(wallet).apply();
} catch (Exception ex) {
throw new IllegalStateException(ex);
}
}
/**
* Creates a public and private key and stores it using the Android Key
* Store, so that only this application will be able to access the keys.
@ -132,9 +170,10 @@ public class KeyStoreHelper {
KeyProperties.KEY_ALGORITHM_RSA, SecurityConstants.KEYSTORE_PROVIDER_ANDROID_KEYSTORE);
keyPairGenerator.initialize(
new KeyGenParameterSpec.Builder(
alias, KeyProperties.PURPOSE_SIGN)
alias, KeyProperties.PURPOSE_SIGN | KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT)
.setDigests(KeyProperties.DIGEST_SHA256)
.setSignaturePaddings(KeyProperties.SIGNATURE_PADDING_RSA_PKCS1)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_RSA_PKCS1)
.build());
KeyPair keyPair = keyPairGenerator.generateKeyPair();
Timber.d("M Keys created");
@ -166,6 +205,30 @@ public class KeyStoreHelper {
}
}
public static byte[] encrypt(String alias, byte[] data) {
try {
PublicKey publicKey = getPrivateKeyEntry(alias).getCertificate().getPublicKey();
Cipher cipher = Cipher.getInstance(SecurityConstants.CIPHER_RSA_ECB_PKCS1);
cipher.init(Cipher.ENCRYPT_MODE, publicKey);
return cipher.doFinal(data);
} catch (Exception ex) {
throw new IllegalStateException("Could not initialize RSA cipher", ex);
}
}
public static byte[] decrypt(String alias, byte[] data) {
try {
PrivateKey privateKey = getPrivateKeyEntry(alias).getPrivateKey();
Cipher cipher = Cipher.getInstance(SecurityConstants.CIPHER_RSA_ECB_PKCS1);
cipher.init(Cipher.DECRYPT_MODE, privateKey);
return cipher.doFinal(data);
} catch (Exception ex) {
throw new IllegalStateException("Could not initialize RSA cipher", ex);
}
}
/**
* Signs the data using the key pair stored in the Android Key Store. This
* signature can be used with the data later to verify it was signed by this
@ -213,5 +276,8 @@ public class KeyStoreHelper {
String KEYSTORE_PROVIDER_ANDROID_KEYSTORE = "AndroidKeyStore";
String TYPE_RSA = "RSA";
String SIGNATURE_SHA256withRSA = "SHA256withRSA";
String CIPHER_RSA_ECB_PKCS1 = "RSA/ECB/PKCS1Padding";
String WALLET_PASS_PREFS_NAME = "wallet";
String WALLET_PASS_KEY_PREFIX = "walletKey-";
}
}

View File

@ -0,0 +1,37 @@
<!--
Copyright (C) 2015 The Android Open Source Project
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="32dp"
android:height="32dp"
android:viewportWidth="32.0"
android:viewportHeight="32.0">
<path
android:fillColor="#6b8693"
android:pathData="M16,16m -16, 0a 16, 16 0 1, 0 32, 0a 16, 16 0 1, 0 -32, 0" />
<path
android:fillColor="#ffffff"
android:pathData="M23.7,5.9c-0.1,0.0 -0.2,0.0 -0.3,-0.1C21.0,4.5 18.6,3.9 16.0,3.9c-2.5,0.0 -4.6,0.6 -6.9,1.9C8.8,6.0 8.3,5.9 8.1,5.5C7.9,5.2 8.0,4.7 8.4,4.5c2.5,-1.4 4.9,-2.1 7.7,-2.1c2.8,0.0 5.4,0.7 8.0,2.1c0.4,0.2 0.5,0.6 0.3,1.0C24.2,5.7 24.0,5.9 23.7,5.9z"/>
<path
android:fillColor="#ffffff"
android:pathData="M5.3,13.2c-0.1,0.0 -0.3,0.0 -0.4,-0.1c-0.3,-0.2 -0.4,-0.7 -0.2,-1.0c1.3,-1.9 2.9,-3.4 4.9,-4.5c4.1,-2.2 9.3,-2.2 13.4,0.0c1.9,1.1 3.6,2.5 4.9,4.4c0.2,0.3 0.1,0.8 -0.2,1.0c-0.3,0.2 -0.8,0.1 -1.0,-0.2c-1.2,-1.7 -2.6,-3.0 -4.3,-4.0c-3.7,-2.0 -8.3,-2.0 -12.0,0.0c-1.7,0.9 -3.2,2.3 -4.3,4.0C5.7,13.1 5.5,13.2 5.3,13.2z"/>
<path
android:fillColor="#ffffff"
android:pathData="M13.3,29.6c-0.2,0.0 -0.4,-0.1 -0.5,-0.2c-1.1,-1.2 -1.7,-2.0 -2.6,-3.6c-0.9,-1.7 -1.4,-3.7 -1.4,-5.9c0.0,-4.1 3.3,-7.4 7.4,-7.4c4.1,0.0 7.4,3.3 7.4,7.4c0.0,0.4 -0.3,0.7 -0.7,0.7s-0.7,-0.3 -0.7,-0.7c0.0,-3.3 -2.7,-5.9 -5.9,-5.9c-3.3,0.0 -5.9,2.7 -5.9,5.9c0.0,2.0 0.4,3.8 1.2,5.2c0.8,1.6 1.4,2.2 2.4,3.3c0.3,0.3 0.3,0.8 0.0,1.0C13.7,29.5 13.5,29.6 13.3,29.6z"/>
<path
android:fillColor="#ffffff"
android:pathData="M22.6,27.1c-1.6,0.0 -2.9,-0.4 -4.1,-1.2c-1.9,-1.4 -3.1,-3.6 -3.1,-6.0c0.0,-0.4 0.3,-0.7 0.7,-0.7s0.7,0.3 0.7,0.7c0.0,1.9 0.9,3.7 2.5,4.8c0.9,0.6 1.9,1.0 3.2,1.0c0.3,0.0 0.8,0.0 1.3,-0.1c0.4,-0.1 0.8,0.2 0.8,0.6c0.1,0.4 -0.2,0.8 -0.6,0.8C23.4,27.1 22.8,27.1 22.6,27.1z"/>
<path
android:fillColor="#ffffff"
android:pathData="M20.0,29.9c-0.1,0.0 -0.1,0.0 -0.2,0.0c-2.1,-0.6 -3.4,-1.4 -4.8,-2.9c-1.8,-1.9 -2.8,-4.4 -2.8,-7.1c0.0,-2.2 1.8,-4.1 4.1,-4.1c2.2,0.0 4.1,1.8 4.1,4.1c0.0,1.4 1.2,2.6 2.6,2.6c1.4,0.0 2.6,-1.2 2.6,-2.6c0.0,-5.1 -4.2,-9.3 -9.3,-9.3c-3.6,0.0 -6.9,2.1 -8.4,5.4C7.3,17.1 7.0,18.4 7.0,19.8c0.0,1.1 0.1,2.7 0.9,4.9c0.1,0.4 -0.1,0.8 -0.4,0.9c-0.4,0.1 -0.8,-0.1 -0.9,-0.4c-0.6,-1.8 -0.9,-3.6 -0.9,-5.4c0.0,-1.6 0.3,-3.1 0.9,-4.4c1.7,-3.8 5.6,-6.3 9.8,-6.3c5.9,0.0 10.7,4.8 10.7,10.7c0.0,2.2 -1.8,4.1 -4.1,4.1s-4.0,-1.8 -4.0,-4.1c0.0,-1.4 -1.2,-2.6 -2.6,-2.6c-1.4,0.0 -2.6,1.2 -2.6,2.6c0.0,2.3 0.9,4.5 2.4,6.1c1.2,1.3 2.4,2.0 4.2,2.5c0.4,0.1 0.6,0.5 0.5,0.9C20.6,29.7 20.3,29.9 20.0,29.9z"/>
</vector>

View File

@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_margin="8dp">
@ -55,6 +56,27 @@
</android.support.design.widget.TextInputLayout>
</LinearLayout>
<LinearLayout
android:id="@+id/llFingerprintAuth"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:paddingBottom="16dp"
android:visibility="gone">
<Switch
android:layout_width="wrap_content"
android:layout_height="match_parent" />
<TextView
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="center_vertical"
android:text="@string/generate_fingerprint_hint"
android:textSize="18sp" />
</LinearLayout>
<android.support.design.widget.TextInputLayout
android:id="@+id/etWalletMnemonic"
android:layout_width="match_parent"

View File

@ -40,4 +40,24 @@
android:textAlignment="textStart" />
</android.support.design.widget.TextInputLayout>
<LinearLayout
android:id="@+id/llFingerprintAuth"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:visibility="gone">
<Switch
android:layout_width="wrap_content"
android:layout_height="match_parent" />
<TextView
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="center_vertical"
android:text="@string/generate_fingerprint_hint"
android:textSize="18sp" />
</LinearLayout>
</LinearLayout>

View File

@ -22,4 +22,14 @@
android:inputType="textPassword" />
</android.support.design.widget.TextInputLayout>
<TextView
android:id="@+id/txtFingerprintAuth"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:drawablePadding="10dp"
android:drawableStart="@drawable/ic_fingerprint"
android:gravity="center_vertical"
android:text="@string/prompt_fingerprint_auth"
android:visibility="gone" />
</LinearLayout>

View File

@ -91,7 +91,9 @@
<string name="prompt_changepw">Nueva contraseña para %1$s</string>
<string name="prompt_changepwB">Repetir contraseña para %1$s</string>
<string name="prompt_password">Contraseña para %1$s</string>
<string name="prompt_fingerprint_auth">[You can also open wallet using fingerprint.\nPlease touch sensor.]</string>
<string name="prompt_send_password">Confirmar Contraseña</string>
<string name="bad_fingerprint">[Fingerprint not recognized. Try again.]</string>
<string name="bad_password">¡Contraseña incorrecta!</string>
<string name="bad_wallet">¡El monedero no existe!</string>
<string name="error_not_wallet">¡Esto no es un monedero!</string>
@ -140,6 +142,19 @@
<string name="generate_title">Crear monedero</string>
<string name="generate_name_hint">Nombre del monedero</string>
<string name="generate_password_hint">Frase de Contraseña</string>
<string name="generate_fingerprint_hint">[Allow to open wallet using fingerprint]</string>
<string name="generate_fingerprint_warn">[<![CDATA[
<strong>Fingerprint Authentication</strong>
<p>With fingerprint authentication enabled, you can view wallet balance and receive funds
without entering password.</p>
<p>But for additional security, monerujo will still require you to enter password when
viewing wallet details or sending funds.</p>
<strong>Security Warning</strong>
<p>Finally, monerujo wants to remind you that anyone who can get your fingerprint will be
able to peep into your wallet balance.</p>
<p>For instance, a malicious user around you can open your wallet when you are asleep.</p>
<strong>Are you sure to enable this function?</strong>
]]>]</string>
<string name="generate_bad_passwordB">Contraseñas no coinciden</string>
<string name="generate_empty_passwordB">Contraseña no puede estar vacía</string>
<string name="generate_buttonGenerate">¡Házme ya un monedero!</string>

View File

@ -155,7 +155,9 @@
<string name="prompt_changepw">[New Passphrase for %1$s]</string>
<string name="prompt_changepwB">[Repeat Passphrase for %1$s]</string>
<string name="prompt_password">Password per %1$s</string>
<string name="prompt_fingerprint_auth">[You can also open wallet using fingerprint.\nPlease touch sensor.]</string>
<string name="prompt_send_password">Conferma Password</string>
<string name="bad_fingerprint">[Fingerprint not recognized. Try again.]</string>
<string name="bad_password">Password errata!</string>
<string name="bad_wallet">Il portafoglio non esiste!</string>
<string name="error_not_wallet">Questo non è un portafoglio!</string>
@ -207,6 +209,19 @@
<string name="generate_title">Crea portafoglio</string>
<string name="generate_name_hint">Nome del portafoglio</string>
<string name="generate_password_hint">Passphrase del portafoglio</string>
<string name="generate_fingerprint_hint">[Allow to open wallet using fingerprint]</string>
<string name="generate_fingerprint_warn">[<![CDATA[
<strong>Fingerprint Authentication</strong>
<p>With fingerprint authentication enabled, you can view wallet balance and receive funds
without entering password.</p>
<p>But for additional security, monerujo will still require you to enter password when
viewing wallet details or sending funds.</p>
<strong>Security Warning</strong>
<p>Finally, monerujo wants to remind you that anyone who can get your fingerprint will be
able to peep into your wallet balance.</p>
<p>For instance, a malicious user around you can open your wallet when you are asleep.</p>
<strong>Are you sure to enable this function?</strong>
]]>]</string>
<string name="generate_bad_passwordB">[Passphrases do not match]</string>
<string name="generate_empty_passwordB">[Passphrase may not be empty]</string>
<string name="generate_buttonGenerate">Fammi subito un portafoglio!</string>

View File

@ -153,7 +153,9 @@
<string name="prompt_changepw">[Nytt passord for %1$s]</string>
<string name="prompt_changepwB">[Gjenta passord for %1$s]</string>
<string name="prompt_password">Passord for %1$s</string>
<string name="prompt_fingerprint_auth">[You can also open wallet using fingerprint.\nPlease touch sensor.]</string>
<string name="prompt_send_password">Bekreft passord</string>
<string name="bad_fingerprint">[Fingerprint not recognized. Try again.]</string>
<string name="bad_password">Feil passord!</string>
<string name="bad_wallet">Lommebok eksisterer ikke!</string>
<string name="error_not_wallet">Dette er ikke en lommebok!</string>
@ -205,6 +207,19 @@
<string name="generate_title">Lag lommebok</string>
<string name="generate_name_hint">Lommeboknavn</string>
<string name="generate_password_hint">Lommebokpassord</string>
<string name="generate_fingerprint_hint">[Allow to open wallet using fingerprint]</string>
<string name="generate_fingerprint_warn">[<![CDATA[
<strong>Fingerprint Authentication</strong>
<p>With fingerprint authentication enabled, you can view wallet balance and receive funds
without entering password.</p>
<p>But for additional security, monerujo will still require you to enter password when
viewing wallet details or sending funds.</p>
<strong>Security Warning</strong>
<p>Finally, monerujo wants to remind you that anyone who can get your fingerprint will be
able to peep into your wallet balance.</p>
<p>For instance, a malicious user around you can open your wallet when you are asleep.</p>
<strong>Are you sure to enable this function?</strong>
]]>]</string>
<string name="generate_bad_passwordB">[Passordene stemmer ikke overens]</string>
<string name="generate_empty_passwordB">[Passord kan ikke være tomt]</string>
<string name="generate_buttonGenerate">Lag meg en lommebok!</string>

View File

@ -155,7 +155,9 @@
<string name="prompt_changepw">為 %1$s 設定新密碼</string>
<string name="prompt_changepwB">重複輸入 %1$s 的密碼</string>
<string name="prompt_password">%1$s 的密碼</string>
<string name="prompt_fingerprint_auth">你也可以使用指紋來開啟錢包。\n請輕觸你的指紋感應器。</string>
<string name="prompt_send_password">確認密碼</string>
<string name="bad_fingerprint">無法辨識的指紋,請再試一次。</string>
<string name="bad_password">密碼錯誤!</string>
<string name="bad_wallet">錢包不存在!</string>
<string name="error_not_wallet">這不是錢包!</string>
@ -207,6 +209,16 @@
<string name="generate_title">建立錢包</string>
<string name="generate_name_hint">錢包名稱</string>
<string name="generate_password_hint">錢包密碼</string>
<string name="generate_fingerprint_hint">允許使用指紋開啟錢包</string>
<string name="generate_fingerprint_warn"><![CDATA[
<strong>指紋驗證</strong>
<p>啟用指紋驗證後,您可以觀看錢包餘額並接收資金,而無需輸入密碼。</p>
<p>但為了提高安全性monerujo 仍然會要求您在觀看錢包詳細資訊或發送資金時輸入密碼。</p>
<strong>安全警告</strong>
<p>最後monerujo 想提醒您,任何可以取得您指紋的人都能夠窺視您的錢包餘額。</p>
<p>例如,您周遭的惡意使用者可以趁您睡著時使用您的指紋開啟錢包。</p>
<strong>您確定要啟用本功能嗎?</strong>
]]></string>
<string name="generate_bad_passwordB">密碼不符</string>
<string name="generate_empty_passwordB">密碼不得空白</string>
<string name="generate_buttonGenerate">建立錢包!</string>

View File

@ -158,7 +158,9 @@
<string name="prompt_changepw">New Passphrase for %1$s</string>
<string name="prompt_changepwB">Repeat Passphrase for %1$s</string>
<string name="prompt_password">Password for %1$s</string>
<string name="prompt_fingerprint_auth">You can also open wallet using fingerprint.\nPlease touch sensor.</string>
<string name="prompt_send_password">Confirm Password</string>
<string name="bad_fingerprint">Fingerprint not recognized. Try again.</string>
<string name="bad_password">Incorrect password!</string>
<string name="bad_wallet">Wallet does not exist!</string>
<string name="error_not_wallet">This is not a wallet!</string>
@ -211,6 +213,19 @@
<string name="generate_title">Create Wallet</string>
<string name="generate_name_hint">Wallet Name</string>
<string name="generate_password_hint">Wallet Passphrase</string>
<string name="generate_fingerprint_hint">Allow to open wallet using fingerprint</string>
<string name="generate_fingerprint_warn"><![CDATA[
<strong>Fingerprint Authentication</strong>
<p>With fingerprint authentication enabled, you can view wallet balance and receive funds
without entering password.</p>
<p>But for additional security, monerujo will still require you to enter password when
viewing wallet details or sending funds.</p>
<strong>Security Warning</strong>
<p>Finally, monerujo wants to remind you that anyone who can get your fingerprint will be
able to peep into your wallet balance.</p>
<p>For instance, a malicious user around you can open your wallet when you are asleep.</p>
<strong>Are you sure to enable this function?</strong>
]]></string>
<string name="generate_bad_passwordB">Passphrases do not match</string>
<string name="generate_empty_passwordB">Passphrase may not be empty</string>
<string name="generate_buttonGenerate">Make me a wallet already!</string>