HOTP support

This adds the initial (already working) draft of the HOTP implementation

Closes #5
This commit is contained in:
Jakob Nixdorf 2018-03-20 12:53:43 +01:00
parent 3294b62385
commit 836cef6265
No known key found for this signature in database
GPG key ID: BE99BF86574A7DBC
6 changed files with 224 additions and 35 deletions

View file

@ -42,15 +42,17 @@ import java.util.Set;
public class Entry {
public enum OTPType {
TOTP, STEAM
TOTP, HOTP, STEAM
}
public static Set<OTPType> PublicTypes = EnumSet.of(OTPType.TOTP);
public static Set<OTPType> PublicTypes = EnumSet.of(OTPType.TOTP, OTPType.HOTP);
private static final OTPType DEFAULT_TYPE = OTPType.TOTP;
private static final int DEFAULT_PERIOD = 30;
private static final String JSON_SECRET = "secret";
private static final String JSON_LABEL = "label";
private static final String JSON_PERIOD = "period";
private static final String JSON_COUNTER = "counter";
private static final String JSON_DIGITS = "digits";
private static final String JSON_TYPE = "type";
private static final String JSON_ALGORITHM = "algorithm";
@ -63,6 +65,7 @@ public class Entry {
private int digits = TokenCalculator.TOTP_DEFAULT_DIGITS;
private TokenCalculator.HashAlgorithm algorithm = TokenCalculator.DEFAULT_ALGORITHM;
private byte[] secret;
private long counter;
private String label;
private String currentOTP;
private boolean visible = false;
@ -84,6 +87,16 @@ public class Entry {
this.tags = tags;
}
public Entry(OTPType type, String secret, long counter, int digits, String label, TokenCalculator.HashAlgorithm algorithm, List<String> tags) {
this.type = type;
this.secret = new Base32().decode(secret.toUpperCase());
this.counter = counter;
this.digits = digits;
this.label = label;
this.algorithm = algorithm;
this.tags = tags;
}
public Entry(String contents) throws Exception {
contents = contents.replaceFirst("otpauth", "http");
Uri uri = Uri.parse(contents);
@ -95,23 +108,32 @@ public class Entry {
if(url.getHost().equals("totp")){
type = OTPType.TOTP;
} else if (url.getHost().equals("hotp")) {
type = OTPType.HOTP;
} else {
throw new Exception();
throw new Exception("unknown otp type");
}
String secret = uri.getQueryParameter("secret");
String label = uri.getPath().substring(1);
String counter = uri.getQueryParameter("counter");
String issuer = uri.getQueryParameter("issuer");
String period = uri.getQueryParameter("period");
String digits = uri.getQueryParameter("digits");
String algorithm = uri.getQueryParameter("algorithm");
List<String> tags = uri.getQueryParameters("tags");
if(issuer != null){
if (issuer != null){
label = issuer +" - "+label;
}
if (type == OTPType.HOTP && counter == null) {
throw new Exception("missing counter for HOTP");
} else {
this.counter = Long.parseLong(counter);
}
this.label = label;
this.secret = new Base32().decode(secret.toUpperCase());
@ -140,16 +162,10 @@ public class Entry {
}
}
public Entry (JSONObject jsonObj) throws JSONException {
public Entry (JSONObject jsonObj)
throws Exception {
this.secret = new Base32().decode(jsonObj.getString(JSON_SECRET).toUpperCase());
this.label = jsonObj.getString(JSON_LABEL);
this.period = jsonObj.getInt(JSON_PERIOD);
try {
this.digits = jsonObj.getInt(JSON_DIGITS);
} catch(Exception e) {
this.digits = TokenCalculator.TOTP_DEFAULT_DIGITS;
}
try {
this.type = OTPType.valueOf(jsonObj.getString(JSON_TYPE));
@ -157,6 +173,26 @@ public class Entry {
this.type = DEFAULT_TYPE;
}
try {
this.period = jsonObj.getInt(JSON_PERIOD);
} catch(Exception e) {
if (type == OTPType.TOTP)
this.period = DEFAULT_PERIOD;
}
try {
this.counter = jsonObj.getLong(JSON_COUNTER);
} catch (Exception e) {
if (type == OTPType.HOTP)
throw new Exception("missing counter for HOTP");
}
try {
this.digits = jsonObj.getInt(JSON_DIGITS);
} catch(Exception e) {
this.digits = TokenCalculator.TOTP_DEFAULT_DIGITS;
}
try {
this.algorithm = TokenCalculator.HashAlgorithm.valueOf(jsonObj.getString(JSON_ALGORITHM));
} catch (Exception e) {
@ -190,13 +226,17 @@ public class Entry {
JSONObject jsonObj = new JSONObject();
jsonObj.put(JSON_SECRET, new String(new Base32().encode(getSecret())));
jsonObj.put(JSON_LABEL, getLabel());
jsonObj.put(JSON_PERIOD, getPeriod());
jsonObj.put(JSON_DIGITS, getDigits());
jsonObj.put(JSON_TYPE, getType().toString());
jsonObj.put(JSON_ALGORITHM, algorithm.toString());
jsonObj.put(JSON_THUMBNAIL, getThumbnail().name());
jsonObj.put(JSON_LAST_USED, getLastUsed());
if (type == OTPType.TOTP)
jsonObj.put(JSON_PERIOD, getPeriod());
else if (type == OTPType.HOTP)
jsonObj.put(JSON_COUNTER, getCounter());
JSONArray tagsArray = new JSONArray();
for(String tag : tags){
tagsArray.put(tag);
@ -238,6 +278,14 @@ public class Entry {
this.period = period;
}
public long getCounter() {
return counter;
}
public void setCounter(long counter) {
this.counter = counter;
}
public int getDigits() {
return digits;
}
@ -311,6 +359,9 @@ public class Entry {
} else {
return false;
}
} else if (type == OTPType.HOTP) {
currentOTP = TokenCalculator.HOTP(secret, counter, digits, algorithm);
return true;
} else {
return false;
}
@ -324,6 +375,7 @@ public class Entry {
return period == entry.period &&
digits == entry.digits &&
type == entry.type &&
counter == entry.counter &&
algorithm == entry.algorithm &&
Arrays.equals(secret, entry.secret) &&
Objects.equals(label, entry.label);
@ -331,6 +383,6 @@ public class Entry {
@Override
public int hashCode() {
return Objects.hash(type, period, digits, algorithm, secret, label);
return Objects.hash(type, period, counter, digits, algorithm, secret, label);
}
}

View file

@ -30,7 +30,6 @@ import java.security.NoSuchAlgorithmException;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
public class TokenCalculator {
public static final int TOTP_DEFAULT_PERIOD = 30;
public static final int TOTP_DEFAULT_DIGITS = 6;
@ -84,14 +83,23 @@ public class TokenCalculator {
return tokenBuilder.toString();
}
public static int TOTP(byte[] key, int period, long time, HashAlgorithm algorithm)
public static String HOTP(byte[] secret, long counter, int digits, HashAlgorithm algorithm) {
int fullToken = HOTP(secret, counter, algorithm);
int div = (int) Math.pow(10, digits);
return String.format("%0" + digits + "d", fullToken % div);
}
private static int TOTP(byte[] key, int period, long time, HashAlgorithm algorithm) {
return HOTP(key, time / period, algorithm);
}
private static int HOTP(byte[] key, long counter, HashAlgorithm algorithm)
{
int r = 0;
try {
long timeInterval = time / period;
byte[] data = ByteBuffer.allocate(8).putLong(timeInterval).array();
byte[] data = ByteBuffer.allocate(8).putLong(counter).array();
byte[] hash = generateHash(algorithm, key, data);
int offset = hash[hash.length - 1] & 0xF;

View file

@ -28,9 +28,11 @@ import android.content.ClipboardManager;
import android.content.Context;
import android.content.DialogInterface;
import android.os.Handler;
import android.support.annotation.NonNull;
import android.support.v7.widget.PopupMenu;
import android.support.v7.widget.RecyclerView;
import android.text.Editable;
import android.text.InputType;
import android.text.TextWatcher;
import android.view.LayoutInflater;
import android.view.MenuInflater;
@ -62,6 +64,7 @@ import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.Callable;
import javax.crypto.SecretKey;
@ -122,7 +125,7 @@ public class EntriesCardAdapter extends RecyclerView.Adapter<EntryViewHolder>
return entries.indexOf(displayedEntries.get(displayPosition));
}
public void entriesChanged() {
private void entriesChanged() {
displayedEntries = sortEntries(entries);
filterByTags(tagsFilter);
notifyDataSetChanged();
@ -181,15 +184,16 @@ public class EntriesCardAdapter extends RecyclerView.Adapter<EntryViewHolder>
}
@Override
public void onBindViewHolder(EntryViewHolder entryViewHolder, int i) {
public void onBindViewHolder(@NonNull EntryViewHolder entryViewHolder, int i) {
Entry entry = displayedEntries.get(i);
entryViewHolder.updateValues(entry.getLabel(), entry.getCurrentOTP(), entry.getTags(), entry.getThumbnail(), entry.isVisible());
entryViewHolder.updateValues(entry);
if (entry.hasNonDefaultPeriod()) {
entryViewHolder.showCustomPeriod(entry.getPeriod());
} else {
entryViewHolder.hideCustomPeriod();
if (entry.getType() == Entry.OTPType.TOTP || entry.getType() == Entry.OTPType.STEAM) {
if (entry.hasNonDefaultPeriod())
entryViewHolder.showCustomPeriod(entry.getPeriod());
else
entryViewHolder.hideCustomPeriod();
}
entryViewHolder.setLabelSize(settings.getLabelSize());
@ -199,8 +203,8 @@ public class EntriesCardAdapter extends RecyclerView.Adapter<EntryViewHolder>
entryViewHolder.setLabelScroll(settings.getScrollLabel());
}
@Override
public EntryViewHolder onCreateViewHolder(ViewGroup viewGroup, int i) {
@Override @NonNull
public EntryViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int i) {
View itemView = LayoutInflater.from(viewGroup.getContext()).inflate(R.layout.component_card, viewGroup, false);
EntryViewHolder viewHolder = new EntryViewHolder(context, itemView, settings.getTapToReveal());
@ -250,6 +254,27 @@ public class EntriesCardAdapter extends RecyclerView.Adapter<EntryViewHolder>
}
}
}
@Override
public void onCounterTapped(int position) {
Entry entry = displayedEntries.get(position);
Entry realEntry = entries.get(getRealIndex(position));
long counter = entry.getCounter() + 1;
entry.setCounter(counter);
entry.updateOTP();
notifyItemChanged(position);
realEntry.setCounter(counter);
realEntry.updateOTP();
DatabaseHelper.saveDatabase(context, entries, encryptionKey);
}
@Override
public void onCounterLongPressed(int position) {
setCounter(position);
}
});
return viewHolder;
@ -275,6 +300,47 @@ public class EntriesCardAdapter extends RecyclerView.Adapter<EntryViewHolder>
}
}
private void setCounter(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.setText(String.format(Locale.ENGLISH, "%d", displayedEntries.get(pos).getCounter()));
input.setInputType(InputType.TYPE_CLASS_NUMBER);
input.setSingleLine();
FrameLayout container = new FrameLayout(context);
container.setPaddingRelative(marginMedium, marginSmall, marginMedium, 0);
container.addView(input);
builder.setTitle(R.string.dialog_title_counter)
.setView(container)
.setPositiveButton(R.string.button_save, new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialogInterface, int i) {
int realIndex = getRealIndex(pos);
long newCounter = Long.parseLong(input.getEditableText().toString());
displayedEntries.get(pos).setCounter(newCounter);
notifyItemChanged(pos);
Entry e = entries.get(realIndex);
e.setCounter(newCounter);
DatabaseHelper.saveDatabase(context, entries, encryptionKey);
}
})
.setNegativeButton(android.R.string.cancel, new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialogInterface, int i) {}
})
.create()
.show();
}
private boolean updateLastUsed(int position, int realIndex) {
long timeStamp = System.currentTimeMillis();

View file

@ -34,6 +34,7 @@ import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.TextView;
import org.shadowice.flocke.andotp.Database.Entry;
import org.shadowice.flocke.andotp.R;
import org.shadowice.flocke.andotp.Utilities.EntryThumbnail;
import org.shadowice.flocke.andotp.Utilities.Settings;
@ -41,6 +42,7 @@ import org.shadowice.flocke.andotp.Utilities.Tools;
import org.shadowice.flocke.andotp.View.ItemTouchHelper.ItemTouchHelperViewHolder;
import java.util.List;
import java.util.Locale;
public class EntryViewHolder extends RecyclerView.ViewHolder
implements ItemTouchHelperViewHolder {
@ -52,11 +54,13 @@ public class EntryViewHolder extends RecyclerView.ViewHolder
private LinearLayout valueLayout;
private LinearLayout coverLayout;
private LinearLayout customPeriodLayout;
private LinearLayout counterLayout;
private FrameLayout thumbnailFrame;
private ImageView visibleImg;
private ImageView thumbnailImg;
private TextView value;
private TextView label;
private TextView counter;
private TextView tags;
private TextView customPeriod;
@ -77,6 +81,8 @@ public class EntryViewHolder extends RecyclerView.ViewHolder
tags = v.findViewById(R.id.textViewTags);
customPeriodLayout = v.findViewById(R.id.customPeriodLayout);
customPeriod = v.findViewById(R.id.customPeriod);
counterLayout = v.findViewById(R.id.counterLayout);
counter = v.findViewById(R.id.counter);
ImageButton menuButton = v.findViewById(R.id.menuButton);
ImageButton copyButton = v.findViewById(R.id.copyButton);
@ -107,17 +113,45 @@ public class EntryViewHolder extends RecyclerView.ViewHolder
}
});
counterLayout.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
if (callback != null)
callback.onCounterTapped(getAdapterPosition());
}
});
counterLayout.setOnLongClickListener(new View.OnLongClickListener() {
@Override
public boolean onLongClick(View view) {
if (callback != null)
callback.onCounterLongPressed(getAdapterPosition());
return false;
}
});
setTapToReveal(tapToReveal);
}
public void updateValues(String label, String token, List<String> tags, EntryThumbnail.EntryThumbnails thumbnail, boolean isVisible) {
public void updateValues(Entry entry) {
Settings settings = new Settings(context);
final String tokenFormatted = Tools.formatToken(token, settings.getTokenSplitGroupSize());
this.label.setText(label);
if (entry.getType() == Entry.OTPType.HOTP) {
counterLayout.setVisibility(View.VISIBLE);
counter.setText(String.format(Locale.ENGLISH, "%d", entry.getCounter()));
} else {
counterLayout.setVisibility(View.GONE);
}
final String tokenFormatted = Tools.formatToken(entry.getCurrentOTP(), settings.getTokenSplitGroupSize());
this.label.setText(entry.getLabel());
value.setText(tokenFormatted);
// save the unformatted token to the tag of this TextView for copy/paste
value.setTag(token);
value.setTag(entry.getCurrentOTP());
List<String> tags = entry.getTags();
StringBuilder stringBuilder = new StringBuilder();
for(int i = 0; i < tags.size(); i++) {
@ -138,11 +172,11 @@ public class EntryViewHolder extends RecyclerView.ViewHolder
int thumbnailSize = settings.getThumbnailSize();
if(settings.getThumbnailVisible()) {
thumbnailImg.setImageBitmap(EntryThumbnail.getThumbnailGraphic(context, label, thumbnailSize, thumbnail));
thumbnailImg.setImageBitmap(EntryThumbnail.getThumbnailGraphic(context, entry.getLabel(), thumbnailSize, entry.getThumbnail()));
}
if (this.tapToReveal) {
if (isVisible) {
if (entry.isVisible()) {
valueLayout.setVisibility(View.VISIBLE);
coverLayout.setVisibility(View.GONE);
visibleImg.setVisibility(View.GONE);
@ -156,7 +190,7 @@ public class EntryViewHolder extends RecyclerView.ViewHolder
public void showCustomPeriod(int period) {
customPeriodLayout.setVisibility(View.VISIBLE);
customPeriod.setText(String.format(context.getString(R.string.format_custom_period), period));
customPeriod.setText(String.format(Locale.ENGLISH, context.getString(R.string.format_custom_period), period));
}
public void hideCustomPeriod() {
@ -230,5 +264,7 @@ public class EntryViewHolder extends RecyclerView.ViewHolder
void onMenuButtonClicked(View parentView, int position);
void onCopyButtonClicked(String text, int position);
void onTap(int position);
void onCounterTapped(int position);
void onCounterLongPressed(int position);
}
}

View file

@ -140,10 +140,34 @@
android:textStyle="bold" />
</LinearLayout>
<LinearLayout
android:id="@+id/counterLayout"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:padding="@dimen/activity_horizontal_margin"
android:gravity="center"
android:visibility="visible"
android:orientation="vertical" >
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textStyle="bold"
android:text="@string/label_counter_symbol" />
<TextView
android:id="@+id/counter"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textStyle="bold" />
</LinearLayout>
<ImageButton
android:id="@+id/copyButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:padding="@dimen/activity_margin_small"
android:src="@drawable/ic_content_copy_gray"
android:background="?android:attr/selectableItemBackground"/>
@ -152,6 +176,7 @@
android:id="@+id/menuButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:padding="@dimen/activity_margin_small"
android:src="@drawable/ic_more_vert_gray"
android:background="?android:attr/selectableItemBackground"/>

View file

@ -29,6 +29,7 @@
<string name="label_algorithm">Algorithm</string>
<string name="label_tags">Tags</string>
<string name="label_advanced">Advanced options</string>
<string name="label_counter_symbol" translatable="false">#</string>
<!-- Drawer -->
<string name="drawer_open">Show tags</string>
@ -63,6 +64,7 @@
<string name="dialog_title_manual_entry">Enter details</string>
<string name="dialog_title_remove">Remove</string>
<string name="dialog_title_rename">Rename</string>
<string name="dialog_title_counter">Counter</string>
<string name="dialog_title_last_used">Last used</string>
<string name="dialog_title_keystore_error">KeyStore error</string>