Merge pull request #617 from mantinan/master

This implements MOTP support
This commit is contained in:
Jakob Nixdorf 2021-03-15 21:23:38 +01:00 committed by GitHub
commit cca3972b6d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 218 additions and 17 deletions

View file

@ -26,6 +26,7 @@ package org.shadowice.flocke.andotp.Database;
import android.net.Uri;
import org.apache.commons.codec.binary.Base32;
import org.apache.commons.codec.binary.Hex;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
@ -40,11 +41,12 @@ import java.util.Objects;
public class Entry {
public enum OTPType {
TOTP, HOTP, STEAM
TOTP, HOTP, MOTP, STEAM
}
private static final OTPType DEFAULT_TYPE = OTPType.TOTP;
private static final int DEFAULT_PERIOD = 30;
private static final String MOTP_NO_PIN_CODE = "PINREQ";
private static final String JSON_SECRET = "secret";
private static final String JSON_ISSUER = "issuer";
@ -79,6 +81,7 @@ public class Entry {
public static final int COLOR_RED = 1;
private static final int EXPIRY_TIME = 8;
private int color = COLOR_DEFAULT;
private String pin = "";
private long listId = 0;
public Entry(){}
@ -107,6 +110,16 @@ public class Entry {
setThumbnailFromIssuer(issuer);
}
public Entry(OTPType type, String secret, String issuer, String label, List<String> tags) {
this.type = type;
this.secret = secret.getBytes();
this.issuer = issuer;
this.label = label;
this.tags = tags;
this.period = TokenCalculator.TOTP_DEFAULT_PERIOD;
setThumbnailFromIssuer(issuer);
}
public Entry(String contents) throws Exception {
contents = contents.replaceFirst("otpauth", "http");
Uri uri = Uri.parse(contents);
@ -123,6 +136,9 @@ public class Entry {
case "hotp":
type = OTPType.HOTP;
break;
case "motp":
type = OTPType.MOTP;
break;
case "steam":
type = OTPType.STEAM;
break;
@ -156,7 +172,11 @@ public class Entry {
this.issuer = issuer;
this.label = label;
this.secret = new Base32().decode(secret.toUpperCase());
if(type == OTPType.MOTP) {
this.secret = secret.getBytes();
} else {
this.secret = new Base32().decode(secret.toUpperCase());
}
if (digits != null) {
this.digits = Integer.parseInt(digits);
@ -292,14 +312,22 @@ public class Entry {
case STEAM:
type = "steam";
break;
case MOTP:
type = "motp";
break;
default:
return null;
}
Uri.Builder builder = new Uri.Builder()
.scheme("otpauth")
.authority(type)
.appendPath(this.label)
.appendQueryParameter("secret", new Base32().encodeAsString(this.secret));
.appendPath(this.label);
if (this.type == OTPType.MOTP)
builder.appendQueryParameter("secret", new String(this.secret));
else
builder.appendQueryParameter("secret", new Base32().encodeAsString(this.secret));
if (this.issuer != null) {
builder.appendQueryParameter("issuer", this.issuer);
}
@ -324,7 +352,7 @@ public class Entry {
}
public boolean isTimeBased() {
return type == OTPType.TOTP || type == OTPType.STEAM;
return type == OTPType.TOTP || type == OTPType.STEAM || type == OTPType.MOTP;
}
public boolean isCounterBased() { return type == OTPType.HOTP; }
@ -342,7 +370,10 @@ public class Entry {
}
public String getSecretEncoded() {
return new String(new Base32().encode(secret));
if (type == OTPType.MOTP)
return new String(secret);
else
return new String(new Base32().encode(secret));
}
public void setSecret(byte[] secret) {
@ -444,6 +475,14 @@ public class Entry {
return currentOTP;
}
public String getPin() {
return pin;
}
public void setPin(String pin) {
this.pin = pin;
}
public long getListId() {
return listId;
}
@ -452,12 +491,12 @@ public class Entry {
listId = newId;
}
public boolean updateOTP(boolean force) {
public boolean updateOTP(boolean updateNow) {
if (type == OTPType.TOTP || type == OTPType.STEAM) {
long time = System.currentTimeMillis() / 1000;
long counter = time / this.getPeriod();
if (force || counter > last_update) {
if (updateNow || counter > last_update) {
if (type == OTPType.TOTP)
currentOTP = TokenCalculator.TOTP_RFC6238(secret, period, digits, algorithm);
else if (type == OTPType.STEAM)
@ -473,6 +512,22 @@ public class Entry {
} else if (type == OTPType.HOTP) {
currentOTP = TokenCalculator.HOTP(secret, counter, digits, algorithm);
return true;
} else if (type == OTPType.MOTP) {
long time = System.currentTimeMillis() / 1000;
long counter = time / this.getPeriod();
if (counter > last_update || updateNow) {
String currentPin = this.getPin();
if (currentPin.isEmpty()) {
currentOTP = MOTP_NO_PIN_CODE;
} else {
currentOTP = TokenCalculator.MOTP(currentPin, new String(this.secret), time);
}
last_update = counter;
setColor(COLOR_DEFAULT);
return true;
} else {
return false;
}
} else {
return false;
}
@ -482,7 +537,7 @@ public class Entry {
* Checks if the OTP is expiring. The color for the entry will be changed to red if the expiry time is less than or equal to 8 seconds
* COLOR_DEFAULT indicates that the OTP has not expired. In this case check if the OTP is about to expire. Update color to COLOR_RED if it's about to expire
* COLOR_RED indicates that the OTP is already about to expire. Don't check again.
* The color will be reset to COLOR_DEFAULT in {@link #updateOTP(boolean force)} method
* The color will be reset to COLOR_DEFAULT in {@link #updateOTP(boolean updateNow)} method
*
* @return Return true only if the color has changed to red to save from unnecessary notifying dataset
* */
@ -542,9 +597,12 @@ public class Entry {
return color;
}
public static boolean validateSecret(String secret) {
public static boolean validateSecret(String secret, OTPType type) {
try {
new Base32().decode(secret.toUpperCase());
if (type == OTPType.MOTP)
Hex.decodeHex(secret);
else
new Base32().decode(secret.toUpperCase());
} catch (Exception e) {
return false;
}

View file

@ -82,6 +82,7 @@ public class ManualEntryDialog {
final LinearLayout periodLayout = inputView.findViewById(R.id.manual_layout_period);
final Spinner algorithmInput = inputView.findViewById(R.id.manual_algorithm);
final Button tagsInput = inputView.findViewById(R.id.manual_tags);
final Button expandButton = inputView.findViewById(R.id.dialog_expand_button);
final ArrayAdapter<TokenCalculator.HashAlgorithm> algorithmAdapter = new ArrayAdapter<>(callingActivity, android.R.layout.simple_expandable_list_item_1, TokenCalculator.HashAlgorithm.values());
final ArrayAdapter<Entry.OTPType> typeAdapter = new ArrayAdapter<>(callingActivity, android.R.layout.simple_expandable_list_item_1, Entry.OTPType.values());
@ -101,6 +102,7 @@ public class ManualEntryDialog {
if (type == Entry.OTPType.STEAM) {
counterLayout.setVisibility(View.GONE);
periodLayout.setVisibility(View.VISIBLE);
expandButton.setVisibility(View.VISIBLE);
digitsInput.setText(String.format(Locale.US, "%d", TokenCalculator.STEAM_DEFAULT_DIGITS));
periodInput.setText(String.format(Locale.US, "%d", TokenCalculator.TOTP_DEFAULT_PERIOD));
@ -112,6 +114,7 @@ public class ManualEntryDialog {
} else if (type == Entry.OTPType.TOTP) {
counterLayout.setVisibility(View.GONE);
periodLayout.setVisibility(View.VISIBLE);
expandButton.setVisibility(View.VISIBLE);
if (isNewEntry)
digitsInput.setText(String.format(Locale.US, "%d", TokenCalculator.TOTP_DEFAULT_DIGITS));
@ -120,11 +123,19 @@ public class ManualEntryDialog {
} else if (type == Entry.OTPType.HOTP) {
counterLayout.setVisibility(View.VISIBLE);
periodLayout.setVisibility(View.GONE);
expandButton.setVisibility(View.VISIBLE);
if (isNewEntry)
digitsInput.setText(String.format(Locale.US, "%d", TokenCalculator.TOTP_DEFAULT_DIGITS));
algorithmInput.setEnabled(isNewEntry);
}else if (type == Entry.OTPType.MOTP) {
counterLayout.setVisibility(View.GONE);
periodLayout.setVisibility(View.VISIBLE);
digitsInput.setText(String.format(Locale.US, "%d", TokenCalculator.TOTP_DEFAULT_DIGITS));
expandButton.setVisibility(View.GONE);
algorithmInput.setEnabled(isNewEntry);
}
}
@ -156,8 +167,6 @@ public class ManualEntryDialog {
tagsInput.setOnClickListener(view -> TagsDialog.show(callingActivity, tagsAdapter, tagsCallable, tagsCallable));
final Button expandButton = inputView.findViewById(R.id.dialog_expand_button);
// Dirty fix for the compound drawable to avoid crashes on KitKat
expandButton.setCompoundDrawablesWithIntrinsicBounds(null, null, ResourcesCompat.getDrawable(callingActivity.getResources(), R.drawable.ic_arrow_down_accent, null), null);
@ -193,13 +202,13 @@ public class ManualEntryDialog {
positiveButton.setOnClickListener(view -> {
//Replace spaces with empty characters
String secret = secretInput.getText().toString().replaceAll("\\s+","");
Entry.OTPType type = (Entry.OTPType) typeInput.getSelectedItem();
if (!Entry.validateSecret(secret)) {
if (!Entry.validateSecret(secret, type)) {
secretInput.setError(callingActivity.getString(R.string.error_invalid_secret));
return;
}
Entry.OTPType type = (Entry.OTPType) typeInput.getSelectedItem();
TokenCalculator.HashAlgorithm algorithm = (TokenCalculator.HashAlgorithm) algorithmInput.getSelectedItem();
int digits = Integer.parseInt(digitsInput.getText().toString());
@ -250,6 +259,27 @@ public class ManualEntryDialog {
if (updateCallback != null)
updateCallback.onUpdate();
}
} else if (type == Entry.OTPType.MOTP) {
if (isNewEntry) {
Entry newEntry;
newEntry = new Entry(type, secret, issuer, label, tagsAdapter.getActiveTags());
newEntry.updateOTP(false);
newEntry.setLastUsed(System.currentTimeMillis());
adapter.addEntry(newEntry);
} else {
oldEntry.setIssuer(issuer, true);
oldEntry.setLabel(label);
oldEntry.setTags(tagsAdapter.getActiveTags());
oldEntry.updateOTP(false);
if (updateCallback != null)
updateCallback.onUpdate();
}
callingActivity.refreshTags();
}
dialog.dismiss();

View file

@ -23,8 +23,11 @@
package org.shadowice.flocke.andotp.Utilities;
import org.apache.commons.codec.binary.Hex;
import java.nio.ByteBuffer;
import java.security.InvalidKeyException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import javax.crypto.Mac;
@ -115,4 +118,26 @@ public class TokenCalculator {
return r;
}
public static String MOTP(String PIN, String secret, long epoch)
{
String epochText = String.valueOf(epoch / 10);
String hashText = epochText + secret + PIN;
String otp = "";
try {
// Create MD5 Hash
MessageDigest digest = MessageDigest.getInstance("MD5");
digest.update(hashText.getBytes());
byte[] messageDigest = digest.digest();
// Create Hex String
String hexString = Hex.encodeHexString(messageDigest);
otp = hexString.substring(0, 6);
} catch (NoSuchAlgorithmException e) {
e.printStackTrace();
}
return otp;
}
}

View file

@ -48,10 +48,17 @@ public class UIHelper {
.show();
}
public static void showKeyboard(Context context, View view) {
public static void showKeyboard(Context context, View view){
showKeyboard(context,view,false);
}
public static void showKeyboard(Context context, View view, Boolean showForced) {
if (view != null) {
InputMethodManager imm = (InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE);
imm.showSoftInput(view, 0);
if(showForced)
imm.toggleSoftInput(InputMethodManager.SHOW_FORCED, InputMethodManager.HIDE_IMPLICIT_ONLY);
else
imm.showSoftInput(view, 0);
}
}

View file

@ -37,8 +37,10 @@ import android.text.Editable;
import android.text.InputType;
import android.text.TextUtils;
import android.text.TextWatcher;
import android.text.method.PasswordTransformationMethod;
import android.view.LayoutInflater;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
@ -66,6 +68,7 @@ import org.shadowice.flocke.andotp.Utilities.EncryptionHelper;
import org.shadowice.flocke.andotp.Utilities.EntryThumbnail;
import org.shadowice.flocke.andotp.Utilities.Settings;
import org.shadowice.flocke.andotp.Utilities.Tools;
import org.shadowice.flocke.andotp.Utilities.UIHelper;
import org.shadowice.flocke.andotp.View.ItemTouchHelper.ItemTouchHelperAdapter;
import java.util.ArrayList;
@ -93,6 +96,8 @@ public class EntriesCardAdapter extends RecyclerView.Adapter<EntryViewHolder>
private final TagsAdapter tagsFilterAdapter;
private final Settings settings;
private static final int ESTABLISH_PIN_MENU_INDEX = 4;
public EntriesCardAdapter(Context context, TagsAdapter tagsFilterAdapter) {
this.context = context;
this.tagsFilterAdapter = tagsFilterAdapter;
@ -306,15 +311,21 @@ public class EntriesCardAdapter extends RecyclerView.Adapter<EntryViewHolder>
public void onCardSingleClicked(final int position, final String text) {
switch (settings.getTapSingle()) {
case REVEAL:
establishPinIfNeeded(position);
cardTapToRevealHandler(position);
break;
case COPY:
establishPinIfNeeded(position);
copyHandler(position, text, false);
break;
case COPY_BACKGROUND:
establishPinIfNeeded(position);
copyHandler(position, text, true);
break;
default:
// If tap-to-reveal is disabled a single tab still needs to establish the PIN
if (!settings.getTapToReveal())
establishPinIfNeeded(position);
break;
}
}
@ -323,12 +334,15 @@ public class EntriesCardAdapter extends RecyclerView.Adapter<EntryViewHolder>
public void onCardDoubleClicked(final int position, final String text) {
switch (settings.getTapDouble()) {
case REVEAL:
establishPinIfNeeded(position);
cardTapToRevealHandler(position);
break;
case COPY:
establishPinIfNeeded(position);
copyHandler(position, text, false);
break;
case COPY_BACKGROUND:
establishPinIfNeeded(position);
copyHandler(position, text, true);
break;
default:
@ -350,6 +364,13 @@ public class EntriesCardAdapter extends RecyclerView.Adapter<EntryViewHolder>
return viewHolder;
}
private void establishPinIfNeeded(int position) {
final Entry entry = displayedEntries.get(position);
if (entry.getType() == Entry.OTPType.MOTP && entry.getPin().isEmpty())
establishPIN(position);
}
private void copyHandler(final int position, final String text, final boolean dropToBackground) {
Tools.copyToClipboard(context, text);
updateLastUsedAndFrequency(position, getRealIndex(position));
@ -593,6 +614,44 @@ public class EntriesCardAdapter extends RecyclerView.Adapter<EntryViewHolder>
alert.show();
}
public void establishPIN(final int pos) {
AlertDialog.Builder builder = new AlertDialog.Builder(context);
int marginSmall = context.getResources().getDimensionPixelSize(R.dimen.activity_margin_small);
int marginMedium = context.getResources().getDimensionPixelSize(R.dimen.activity_margin_medium);
final EditText input = new EditText(context);
input.setLayoutParams(new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT));
input.setRawInputType(InputType.TYPE_NUMBER_VARIATION_PASSWORD | InputType.TYPE_CLASS_NUMBER);
input.setText(displayedEntries.get(pos).getPin());
input.setSingleLine();
input.requestFocus();
input.setTransformationMethod(new PasswordTransformationMethod());
UIHelper.showKeyboard(context, input, true);
FrameLayout container = new FrameLayout(context);
container.setPaddingRelative(marginMedium, marginSmall, marginMedium, 0);
container.addView(input);
builder.setTitle(R.string.dialog_title_pin)
.setCancelable(false)
.setView(container)
.setPositiveButton(R.string.button_accept, (dialogInterface, i) -> {
int realIndex = getRealIndex(pos);
String newPin = input.getEditableText().toString();
displayedEntries.get(pos).setPin(newPin);
Entry e = entries.getEntry(realIndex);
e.setPin(newPin);
e.updateOTP(true);
notifyItemChanged(pos);
UIHelper.hideKeyboard(context, input);
})
.setNegativeButton(android.R.string.cancel, (dialogInterface, i) -> UIHelper.hideKeyboard(context, input))
.create()
.show();
}
@SuppressLint("StringFormatInvalid")
public void removeItem(final int pos) {
AlertDialog.Builder builder = new AlertDialog.Builder(context);
@ -650,6 +709,11 @@ public class EntriesCardAdapter extends RecyclerView.Adapter<EntryViewHolder>
MenuInflater inflate = popup.getMenuInflater();
inflate.inflate(R.menu.menu_popup, popup.getMenu());
if (displayedEntries.get(pos).getType() == Entry.OTPType.MOTP){
MenuItem item = popup.getMenu().getItem(ESTABLISH_PIN_MENU_INDEX);
item.setVisible(true);
}
popup.setOnMenuItemClickListener(item -> {
int id = item.getItemId();
@ -659,6 +723,9 @@ public class EntriesCardAdapter extends RecyclerView.Adapter<EntryViewHolder>
} else if(id == R.id.menu_popup_changeImage) {
changeThumbnail(pos);
return true;
} else if (id == R.id.menu_popup_establishPin) {
establishPIN(pos);
return true;
} else if (id == R.id.menu_popup_remove) {
removeItem(pos);
return true;

View file

@ -7,6 +7,8 @@
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/activity_margin_xxsmall"
android:layout_marginBottom="@dimen/activity_margin_xxsmall"
android:clickable="true"
android:foreground="?android:attr/selectableItemBackground"
app:contentPadding="0dp"
style="?attr/cardStyle">

View file

@ -7,6 +7,8 @@
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/activity_margin_xsmall"
android:layout_marginBottom="@dimen/activity_margin_xsmall"
android:clickable="true"
android:foreground="?android:attr/selectableItemBackground"
app:contentPadding="0dp"
style="?attr/cardStyle">

View file

@ -8,6 +8,8 @@
android:layout_marginTop="@dimen/activity_margin_xsmall"
android:layout_marginBottom="@dimen/activity_margin_xsmall"
app:contentPadding="0dp"
android:clickable="true"
android:foreground="?android:attr/selectableItemBackground"
style="?attr/cardStyle">
<LinearLayout

View file

@ -13,6 +13,11 @@
android:id="@+id/menu_popup_show_qr_code"
android:title="@string/menu_popup_show_qr_code" />
<item
android:id="@+id/menu_popup_establishPin"
android:title="@string/menu_popup_establish_pin"
android:visible="false"/>
<item
android:id="@+id/menu_popup_remove"
android:title="@string/menu_popup_remove" />

View file

@ -11,6 +11,7 @@
<string name="button_all_tags">All tags</string>
<string name="button_no_tags">No tags</string>
<string name="button_qr_from_image">QR code from image</string>
<string name="button_accept">Accept</string>
<!-- Custom formatting -->
<string name="format_custom_period">%d s</string>
@ -56,6 +57,7 @@
<string name="menu_popup_change_image">Change image</string>
<string name="menu_popup_remove">Remove</string>
<string name="menu_popup_show_qr_code">Show QR Code</string>
<string name="menu_popup_establish_pin">Enter pin</string>
<!-- Buttons -->
<string name="button_card_options">More options</string>
@ -93,6 +95,7 @@
<string name="dialog_title_used_tokens">Tokens usage</string>
<string name="dialog_title_keystore_error">KeyStore error</string>
<string name="dialog_title_qr_code">QR Code</string>
<string name="dialog_title_pin">PIN</string>
<string name="dialog_title_enter_password">Enter password</string>