Allow importing TOTP configuration through QR codes (#903)
Co-authored-by: Fabian Henneke <fabian@henneke.me>
This commit is contained in:
parent
57f125a4da
commit
5e74507d5b
8 changed files with 79 additions and 11 deletions
|
@ -13,6 +13,7 @@ All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
- TOTP support is reintroduced by popular demand. HOTP continues to be unsupported and heavily discouraged.
|
- TOTP support is reintroduced by popular demand. HOTP continues to be unsupported and heavily discouraged.
|
||||||
- Initial support for detecting and filling OTP fields with Autofill
|
- Initial support for detecting and filling OTP fields with Autofill
|
||||||
|
- Importing TOTP secrets using QR codes
|
||||||
|
|
||||||
## [1.9.1] - 2020-06-28
|
## [1.9.1] - 2020-06-28
|
||||||
|
|
||||||
|
|
|
@ -93,6 +93,9 @@ dependencies {
|
||||||
implementation deps.kotlin.coroutines.android
|
implementation deps.kotlin.coroutines.android
|
||||||
implementation deps.kotlin.coroutines.core
|
implementation deps.kotlin.coroutines.core
|
||||||
|
|
||||||
|
implementation deps.first_party.openpgp_ktx
|
||||||
|
implementation deps.first_party.zxing_android_embedded
|
||||||
|
|
||||||
implementation deps.third_party.commons_codec
|
implementation deps.third_party.commons_codec
|
||||||
implementation deps.third_party.fastscroll
|
implementation deps.third_party.fastscroll
|
||||||
implementation(deps.third_party.jgit) {
|
implementation(deps.third_party.jgit) {
|
||||||
|
@ -102,7 +105,6 @@ dependencies {
|
||||||
implementation deps.third_party.sshj
|
implementation deps.third_party.sshj
|
||||||
implementation deps.third_party.bouncycastle
|
implementation deps.third_party.bouncycastle
|
||||||
implementation deps.third_party.plumber
|
implementation deps.third_party.plumber
|
||||||
implementation deps.third_party.openpgp_ktx
|
|
||||||
implementation deps.third_party.ssh_auth
|
implementation deps.third_party.ssh_auth
|
||||||
implementation deps.third_party.timber
|
implementation deps.third_party.timber
|
||||||
implementation deps.third_party.timberkt
|
implementation deps.third_party.timberkt
|
||||||
|
|
|
@ -45,6 +45,14 @@
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name="com.journeyapps.barcodescanner.CaptureActivity"
|
||||||
|
android:clearTaskOnLaunch="true"
|
||||||
|
android:stateNotNeeded="true"
|
||||||
|
android:theme="@style/zxing_CaptureTheme"
|
||||||
|
android:windowSoftInputMode="stateAlwaysHidden"
|
||||||
|
tools:node="replace" />
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".git.GitOperationActivity"
|
android:name=".git.GitOperationActivity"
|
||||||
android:theme="@style/NoBackgroundTheme" />
|
android:theme="@style/NoBackgroundTheme" />
|
||||||
|
|
|
@ -17,6 +17,8 @@ import androidx.core.widget.doOnTextChanged
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.github.ajalt.timberkt.e
|
import com.github.ajalt.timberkt.e
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
|
import com.google.zxing.integration.android.IntentIntegrator
|
||||||
|
import com.google.zxing.integration.android.IntentIntegrator.QR_CODE
|
||||||
import com.zeapo.pwdstore.R
|
import com.zeapo.pwdstore.R
|
||||||
import com.zeapo.pwdstore.autofill.oreo.AutofillPreferences
|
import com.zeapo.pwdstore.autofill.oreo.AutofillPreferences
|
||||||
import com.zeapo.pwdstore.autofill.oreo.DirectoryStructure
|
import com.zeapo.pwdstore.autofill.oreo.DirectoryStructure
|
||||||
|
@ -62,6 +64,33 @@ class PasswordCreationActivity : BasePgpActivity(), OpenPgpServiceConnection.OnB
|
||||||
with(binding) {
|
with(binding) {
|
||||||
setContentView(root)
|
setContentView(root)
|
||||||
generatePassword.setOnClickListener { generatePassword() }
|
generatePassword.setOnClickListener { generatePassword() }
|
||||||
|
otpImportButton.setOnClickListener {
|
||||||
|
registerForActivityResult(StartActivityForResult()) { result ->
|
||||||
|
if (result.resultCode == RESULT_OK) {
|
||||||
|
otpImportButton.isVisible = false
|
||||||
|
val intentResult = IntentIntegrator.parseActivityResult(RESULT_OK, result.data)
|
||||||
|
val contents = if (intentResult.contents.startsWith("otpauth://")) {
|
||||||
|
"${intentResult.contents}\n"
|
||||||
|
} else {
|
||||||
|
"totp: ${intentResult.contents}\n"
|
||||||
|
}
|
||||||
|
val currentExtras = extraContent.text.toString()
|
||||||
|
if (currentExtras.isNotEmpty() && currentExtras.last() != '\n')
|
||||||
|
extraContent.append("\n$contents")
|
||||||
|
else
|
||||||
|
extraContent.append(contents)
|
||||||
|
snackbar(message = getString(R.string.otp_import_success))
|
||||||
|
} else {
|
||||||
|
snackbar(message = getString(R.string.otp_import_failure))
|
||||||
|
}
|
||||||
|
}.launch(
|
||||||
|
IntentIntegrator(this@PasswordCreationActivity)
|
||||||
|
.setOrientationLocked(false)
|
||||||
|
.setBeepEnabled(false)
|
||||||
|
.setDesiredBarcodeFormats(QR_CODE)
|
||||||
|
.createScanIntent()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
category.apply {
|
category.apply {
|
||||||
if (suggestedName != null || suggestedPass != null || shouldGeneratePassword) {
|
if (suggestedName != null || suggestedPass != null || shouldGeneratePassword) {
|
||||||
|
@ -95,7 +124,7 @@ class PasswordCreationActivity : BasePgpActivity(), OpenPgpServiceConnection.OnB
|
||||||
val username = filename.text.toString()
|
val username = filename.text.toString()
|
||||||
val extras = "username:$username\n${extraContent.text}"
|
val extras = "username:$username\n${extraContent.text}"
|
||||||
|
|
||||||
filename.setText("")
|
filename.text?.clear()
|
||||||
extraContent.setText(extras)
|
extraContent.setText(extras)
|
||||||
} else {
|
} else {
|
||||||
// User wants to disable username encryption, so we extract the
|
// User wants to disable username encryption, so we extract the
|
||||||
|
@ -104,20 +133,20 @@ class PasswordCreationActivity : BasePgpActivity(), OpenPgpServiceConnection.OnB
|
||||||
val username = entry.username
|
val username = entry.username
|
||||||
|
|
||||||
// username should not be null here by the logic in
|
// username should not be null here by the logic in
|
||||||
// updateEncryptUsernameState, but it could still happen due to
|
// updateViewState, but it could still happen due to
|
||||||
// input lag.
|
// input lag.
|
||||||
if (username != null) {
|
if (username != null) {
|
||||||
filename.setText(username)
|
filename.setText(username)
|
||||||
extraContent.setText(entry.extraContentWithoutAuthData)
|
extraContent.setText(entry.extraContentWithoutAuthData)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
updateEncryptUsernameState()
|
updateViewState()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
listOf(filename, extraContent).forEach {
|
listOf(filename, extraContent).forEach {
|
||||||
it.doOnTextChanged { _, _, _, _ -> updateEncryptUsernameState() }
|
it.doOnTextChanged { _, _, _, _ -> updateViewState() }
|
||||||
}
|
}
|
||||||
updateEncryptUsernameState()
|
updateViewState()
|
||||||
}
|
}
|
||||||
suggestedPass?.let {
|
suggestedPass?.let {
|
||||||
password.setText(it)
|
password.setText(it)
|
||||||
|
@ -158,17 +187,18 @@ class PasswordCreationActivity : BasePgpActivity(), OpenPgpServiceConnection.OnB
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateEncryptUsernameState() = with(binding) {
|
private fun updateViewState() = with(binding) {
|
||||||
|
// Use PasswordEntry to parse extras for username
|
||||||
|
val entry = PasswordEntry("PLACEHOLDER\n${extraContent.text}")
|
||||||
encryptUsername.apply {
|
encryptUsername.apply {
|
||||||
if (visibility != View.VISIBLE)
|
if (visibility != View.VISIBLE)
|
||||||
return@with
|
return@with
|
||||||
val hasUsernameInFileName = filename.text.toString().isNotBlank()
|
val hasUsernameInFileName = filename.text.toString().isNotBlank()
|
||||||
// Use PasswordEntry to parse extras for username
|
|
||||||
val entry = PasswordEntry("PLACEHOLDER\n${extraContent.text}")
|
|
||||||
val hasUsernameInExtras = entry.hasUsername()
|
val hasUsernameInExtras = entry.hasUsername()
|
||||||
isEnabled = hasUsernameInFileName xor hasUsernameInExtras
|
isEnabled = hasUsernameInFileName xor hasUsernameInExtras
|
||||||
isChecked = hasUsernameInExtras
|
isChecked = hasUsernameInExtras
|
||||||
}
|
}
|
||||||
|
otpImportButton.isVisible = !entry.hasTotp()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
9
app/src/main/res/drawable/ic_qr_code_scanner.xml
Normal file
9
app/src/main/res/drawable/ic_qr_code_scanner.xml
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:pathData="M9.5,6.5v3h-3v-3H9.5M11,5H5v6h6V5L11,5zM9.5,14.5v3h-3v-3H9.5M11,13H5v6h6V13L11,13zM17.5,6.5v3h-3v-3H17.5M19,5h-6v6h6V5L19,5zM13,13h1.5v1.5H13V13zM14.5,14.5H16V16h-1.5V14.5zM16,13h1.5v1.5H16V13zM13,16h1.5v1.5H13V16zM14.5,17.5H16V19h-1.5V17.5zM16,16h1.5v1.5H16V16zM17.5,14.5H19V16h-1.5V14.5zM17.5,17.5H19V19h-1.5V17.5zM22,7h-2V4h-3V2h5V7zM22,22v-5h-2v3h-3v2H22zM2,22h5v-2H4v-3H2V22zM2,2v5h2V4h3V2H2z"
|
||||||
|
android:fillColor="#000000"/>
|
||||||
|
</vector>
|
|
@ -84,6 +84,17 @@
|
||||||
|
|
||||||
</com.google.android.material.textfield.TextInputLayout>
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/otp_import_button"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_margin="8dp"
|
||||||
|
android:text="@string/add_otp"
|
||||||
|
app:icon="@drawable/ic_qr_code_scanner"
|
||||||
|
app:iconTint="?attr/colorOnSecondary"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/extra_input_layout" />
|
||||||
|
|
||||||
<com.google.android.material.switchmaterial.SwitchMaterial
|
<com.google.android.material.switchmaterial.SwitchMaterial
|
||||||
android:id="@+id/encrypt_username"
|
android:id="@+id/encrypt_username"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
|
@ -92,6 +103,6 @@
|
||||||
android:text="@string/crypto_encrypt_username_label"
|
android:text="@string/crypto_encrypt_username_label"
|
||||||
android:visibility="gone"
|
android:visibility="gone"
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
app:layout_constraintTop_toBottomOf="@id/extra_input_layout"
|
app:layout_constraintTop_toBottomOf="@id/otp_import_button"
|
||||||
tools:visibility="visible" />
|
tools:visibility="visible" />
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
|
|
|
@ -383,4 +383,7 @@
|
||||||
<string name="password_creation_file_write_fail_message">Failed to write password file to the store, please try again.</string>
|
<string name="password_creation_file_write_fail_message">Failed to write password file to the store, please try again.</string>
|
||||||
<string name="password_creation_file_delete_fail_message">Failed to delete password file %1$s from the store, please delete it manually.</string>
|
<string name="password_creation_file_delete_fail_message">Failed to delete password file %1$s from the store, please delete it manually.</string>
|
||||||
<string name="password_creation_duplicate_error">File already exists, please use a different name</string>
|
<string name="password_creation_duplicate_error">File already exists, please use a different name</string>
|
||||||
|
<string name="add_otp">Add OTP</string>
|
||||||
|
<string name="otp_import_success">Successfully imported TOTP configuration</string>
|
||||||
|
<string name="otp_import_failure">Failed to import TOTP configuration</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
@ -44,6 +44,11 @@ ext.deps = [
|
||||||
swiperefreshlayout: 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
|
swiperefreshlayout: 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
|
||||||
],
|
],
|
||||||
|
|
||||||
|
first_party: [
|
||||||
|
openpgp_ktx: 'com.github.android-password-store:openpgp-ktx:2.0.0',
|
||||||
|
zxing_android_embedded: 'com.github.android-password-store:zxing-android-embedded:v4.1.0-aps'
|
||||||
|
],
|
||||||
|
|
||||||
third_party: [
|
third_party: [
|
||||||
bouncycastle: 'org.bouncycastle:bcprov-jdk15on:1.65.01',
|
bouncycastle: 'org.bouncycastle:bcprov-jdk15on:1.65.01',
|
||||||
commons_codec: 'commons-codec:commons-codec:1.13',
|
commons_codec: 'commons-codec:commons-codec:1.13',
|
||||||
|
@ -52,7 +57,6 @@ ext.deps = [
|
||||||
jgit: 'org.eclipse.jgit:org.eclipse.jgit:3.7.1.201504261725-r',
|
jgit: 'org.eclipse.jgit:org.eclipse.jgit:3.7.1.201504261725-r',
|
||||||
leakcanary: 'com.squareup.leakcanary:leakcanary-android:2.4',
|
leakcanary: 'com.squareup.leakcanary:leakcanary-android:2.4',
|
||||||
plumber: 'com.squareup.leakcanary:plumber-android:2.4',
|
plumber: 'com.squareup.leakcanary:plumber-android:2.4',
|
||||||
openpgp_ktx: 'com.github.android-password-store:openpgp-ktx:2.0.0',
|
|
||||||
sshj: 'com.hierynomus:sshj:0.29.0',
|
sshj: 'com.hierynomus:sshj:0.29.0',
|
||||||
ssh_auth: 'org.sufficientlysecure:sshauthentication-api:1.0',
|
ssh_auth: 'org.sufficientlysecure:sshauthentication-api:1.0',
|
||||||
timber: 'com.jakewharton.timber:timber:4.7.1',
|
timber: 'com.jakewharton.timber:timber:4.7.1',
|
||||||
|
|
Loading…
Reference in a new issue