Display TOTP code if entry contains OTP secret

TOTP is calculated on display and on copy to clipboard from secret
embedded in entry (either in password or in extra) and the current time.
This commit is contained in:
Wiktor Kwapisiewicz 2017-11-06 12:41:00 +01:00 committed by Mohamed Zenadi
parent 67a7b124ee
commit 3d5dd65e30
8 changed files with 162 additions and 1 deletions

View file

@ -68,6 +68,7 @@ dependencies {
} }
compile 'com.jcraft:jsch:0.1.54' compile 'com.jcraft:jsch:0.1.54'
compile group: 'commons-io', name: 'commons-io', version: '2.4' compile group: 'commons-io', name: 'commons-io', version: '2.4'
compile group: 'commons-codec', name: 'commons-codec', version: '1.11'
compile 'com.jayway.android.robotium:robotium-solo:5.3.1' compile 'com.jayway.android.robotium:robotium-solo:5.3.1'
compile "org.jetbrains.kotlin:kotlin-stdlib-jre7:$kotlin_version" compile "org.jetbrains.kotlin:kotlin-stdlib-jre7:$kotlin_version"
compile 'com.android.support.constraint:constraint-layout:1.0.2' compile 'com.android.support.constraint:constraint-layout:1.0.2'

View file

@ -41,4 +41,22 @@ public class PasswordEntryTest extends TestCase {
assertFalse(new PasswordEntry("\n").hasUsername()); assertFalse(new PasswordEntry("\n").hasUsername());
assertFalse(new PasswordEntry("").hasUsername()); assertFalse(new PasswordEntry("").hasUsername());
} }
public void testNoTotpUriPresent() {
PasswordEntry entry = new PasswordEntry("secret\nextra\nlogin: username\ncontent");
assertFalse(entry.hasTotp());
assertNull(entry.getTotpSecret());
}
public void testTotpUriInPassword() {
PasswordEntry entry = new PasswordEntry("otpauth://totp/test?secret=JBSWY3DPEHPK3PXP");
assertTrue(entry.hasTotp());
assertEquals("JBSWY3DPEHPK3PXP", entry.getTotpSecret());
}
public void testTotpUriInContent() {
PasswordEntry entry = new PasswordEntry("secret\nusername: test\notpauth://totp/test?secret=JBSWY3DPEHPK3PXP");
assertTrue(entry.hasTotp());
assertEquals("JBSWY3DPEHPK3PXP", entry.getTotpSecret());
}
} }

View file

@ -0,0 +1,12 @@
package com.zeapo.pwdstore;
import com.zeapo.pwdstore.utils.Totp;
import junit.framework.TestCase;
public class TotpTest extends TestCase {
public void testTotp() {
String code = Totp.calculateCode("JBSWY3DPEHPK3PXP", 0L);
assertEquals("282760", code);
}
}

View file

@ -1,5 +1,7 @@
package com.zeapo.pwdstore; package com.zeapo.pwdstore;
import android.net.Uri;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.io.UnsupportedEncodingException; import java.io.UnsupportedEncodingException;
@ -13,6 +15,7 @@ public class PasswordEntry {
private final String extraContent; private final String extraContent;
private final String password; private final String password;
private final String username; private final String username;
private final String totpSecret;
public PasswordEntry(final ByteArrayOutputStream os) throws UnsupportedEncodingException { public PasswordEntry(final ByteArrayOutputStream os) throws UnsupportedEncodingException {
this(os.toString("UTF-8")); this(os.toString("UTF-8"));
@ -23,6 +26,7 @@ public class PasswordEntry {
password = passContent[0]; password = passContent[0];
extraContent = passContent.length > 1 ? passContent[1] : ""; extraContent = passContent.length > 1 ? passContent[1] : "";
username = findUsername(); username = findUsername();
totpSecret = findTotpSecret(decryptedContent);
} }
public String getPassword() { public String getPassword() {
@ -37,6 +41,10 @@ public class PasswordEntry {
return username; return username;
} }
public String getTotpSecret() {
return totpSecret;
}
public boolean hasExtraContent() { public boolean hasExtraContent() {
return extraContent.length() != 0; return extraContent.length() != 0;
} }
@ -45,6 +53,8 @@ public class PasswordEntry {
return username != null; return username != null;
} }
public boolean hasTotp() { return totpSecret != null; }
private String findUsername() { private String findUsername() {
final String[] extraLines = extraContent.split("\n"); final String[] extraLines = extraContent.split("\n");
for (String line : extraLines) { for (String line : extraLines) {
@ -56,4 +66,13 @@ public class PasswordEntry {
} }
return null; return null;
} }
private String findTotpSecret(String decryptedContent) {
for (String line : decryptedContent.split("\n")) {
if (line.startsWith("otpauth://totp/")) {
return Uri.parse(line).getQueryParameter("secret");
}
}
return null;
}
} }

View file

@ -20,6 +20,7 @@ import com.zeapo.pwdstore.PasswordEntry
import com.zeapo.pwdstore.R import com.zeapo.pwdstore.R
import com.zeapo.pwdstore.UserPreference import com.zeapo.pwdstore.UserPreference
import com.zeapo.pwdstore.pwgenDialogFragment import com.zeapo.pwdstore.pwgenDialogFragment
import com.zeapo.pwdstore.utils.Totp
import kotlinx.android.synthetic.main.decrypt_layout.* import kotlinx.android.synthetic.main.decrypt_layout.*
import kotlinx.android.synthetic.main.encrypt_layout.* import kotlinx.android.synthetic.main.encrypt_layout.*
import org.apache.commons.io.FileUtils import org.apache.commons.io.FileUtils
@ -32,6 +33,7 @@ import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.io.File import java.io.File
import java.nio.charset.Charset import java.nio.charset.Charset
import java.util.Date
class PgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBound { class PgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBound {
private val clipboard: ClipboardManager by lazy { private val clipboard: ClipboardManager by lazy {
@ -231,6 +233,20 @@ class PgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBound {
} }
} }
if (entry.hasTotp()) {
crypto_totp_show.visibility = View.VISIBLE
crypto_totp_show_label.visibility = View.VISIBLE
crypto_copy_totp.visibility = View.VISIBLE
crypto_copy_totp.setOnClickListener { copyTotpToClipBoard(Totp.calculateCode(entry.totpSecret, Date().time / 1000)) }
crypto_totp_show.typeface = monoTypeface
crypto_totp_show.text = Totp.calculateCode(entry.totpSecret, Date().time / 1000);
} else {
crypto_totp_show.visibility = View.GONE
crypto_totp_show_label.visibility = View.GONE
crypto_copy_totp.visibility = View.GONE
}
if (settings.getBoolean("copy_on_decrypt", true)) { if (settings.getBoolean("copy_on_decrypt", true)) {
copyPasswordToClipBoard() copyPasswordToClipBoard()
} }
@ -460,6 +476,12 @@ class PgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBound {
showToast(resources.getString(R.string.clipboard_username_toast_text)) showToast(resources.getString(R.string.clipboard_username_toast_text))
} }
private fun copyTotpToClipBoard(code: String) {
val clip = ClipData.newPlainText("pgp_handler_result_pm", code)
clipboard.primaryClip = clip
showToast(resources.getString(R.string.clipboard_totp_toast_text))
}
private fun shareAsPlaintext() { private fun shareAsPlaintext() {
if (findViewById<View>(R.id.share_password_as_plaintext) == null) if (findViewById<View>(R.id.share_password_as_plaintext) == null)

View file

@ -0,0 +1,50 @@
package com.zeapo.pwdstore.utils;
import android.util.Log;
import org.apache.commons.codec.binary.Base32;
import java.math.BigInteger;
import java.nio.ByteBuffer;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.Arrays;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
public class Totp {
private static final String ALGORITHM = "HmacSHA1";
private static final int TIME_WINDOW = 30;
private static final int CODE_DIGITS = 6;
private static final Base32 BASE_32 = new Base32();
private Totp() {
}
public static String calculateCode(String secret, long epochSeconds) {
SecretKeySpec signingKey = new SecretKeySpec(BASE_32.decode(secret), ALGORITHM);
Mac mac = null;
try {
mac = Mac.getInstance(ALGORITHM);
mac.init(signingKey);
} catch (NoSuchAlgorithmException e) {
Log.e("TOTP", ALGORITHM + " unavailable - should never happen", e);
return null;
} catch (InvalidKeyException e) {
Log.e("TOTP", "Key is malformed", e);
return null;
}
long time = epochSeconds / TIME_WINDOW;
byte[] digest = mac.doFinal(ByteBuffer.allocate(8).putLong(time).array());
int offset = digest[digest.length - 1] & 0xf;
byte[] code = Arrays.copyOfRange(digest, offset, offset + 4);
code[0] = (byte) (0x7f & code[0]);
String strCode = new BigInteger(code).toString();
return strCode.substring(strCode.length() - CODE_DIGITS);
}
}

View file

@ -148,13 +148,50 @@
android:textIsSelectable="true" android:textIsSelectable="true"
android:typeface="monospace" /> android:typeface="monospace" />
<ImageButton
android:id="@+id/crypto_copy_totp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentRight="true"
android:layout_alignParentEnd="true"
android:layout_alignParentTop="true"
android:contentDescription="@string/copy_username"
android:background="@color/background"
android:src="@drawable/ic_content_copy"/>
<TextView
android:id="@+id/crypto_totp_show_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentLeft="true"
android:layout_alignParentStart="true"
android:layout_alignParentTop="true"
android:layout_toLeftOf="@id/crypto_copy_totp"
android:layout_toStartOf="@id/crypto_copy_totp"
android:text="@string/totp"
android:textColor="@android:color/black"
android:textStyle="bold" />
<TextView
android:id="@+id/crypto_totp_show"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_alignParentLeft="true"
android:layout_alignParentStart="true"
android:layout_below="@id/crypto_totp_show_label"
android:layout_toLeftOf="@id/crypto_copy_totp"
android:layout_toStartOf="@id/crypto_copy_totp"
android:textColor="@android:color/black"
android:textIsSelectable="true"
android:typeface="monospace" />
<TextView <TextView
android:id="@+id/crypto_extra_show_label" android:id="@+id/crypto_extra_show_label"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_alignParentLeft="true" android:layout_alignParentLeft="true"
android:layout_alignParentStart="true" android:layout_alignParentStart="true"
android:layout_below="@id/crypto_username_show" android:layout_below="@id/crypto_totp_show"
android:text="@string/extra_content" android:text="@string/extra_content"
android:textColor="@android:color/black" android:textColor="@android:color/black"
android:textStyle="bold" /> android:textStyle="bold" />

View file

@ -30,6 +30,7 @@
<string name="provider_toast_text">No OpenPGP Provider selected!</string> <string name="provider_toast_text">No OpenPGP Provider selected!</string>
<string name="clipboard_password_toast_text">Password copied to clipboard, you have %d seconds to paste it somewhere.</string> <string name="clipboard_password_toast_text">Password copied to clipboard, you have %d seconds to paste it somewhere.</string>
<string name="clipboard_username_toast_text">Username copied to clipboard</string> <string name="clipboard_username_toast_text">Username copied to clipboard</string>
<string name="clipboard_totp_toast_text">TOTP code copied to clipboard</string>
<string name="file_toast_text">Please provide a file name</string> <string name="file_toast_text">Please provide a file name</string>
<string name="empty_toast_text">You cannot use an empty password or empty extra content</string> <string name="empty_toast_text">You cannot use an empty password or empty extra content</string>
@ -92,6 +93,7 @@
<string name="password">Password:</string> <string name="password">Password:</string>
<string name="extra_content">Extra content:</string> <string name="extra_content">Extra content:</string>
<string name="username">Username:</string> <string name="username">Username:</string>
<string name="totp">TOTP:</string>
<string name="edit_password">Edit password</string> <string name="edit_password">Edit password</string>
<string name="copy_password">Copy password</string> <string name="copy_password">Copy password</string>
<string name="copy_username">Copy username</string> <string name="copy_username">Copy username</string>