Skip to content
This repository was archived by the owner on Oct 15, 2024. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions .github/workflows/pull_request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,20 @@ jobs:
if: ${{ steps.service-changed.outputs.result == 'true' }}
run: ./gradlew test${{ matrix.variant }} lint${{ matrix.variant}} -Dpre-dex=false

- name: Run instrumentation tests
if: ${{ steps.service-changed.outputs.result == 'true' }}
- name: Run instrumentation tests on free flavor
if: ${{ steps.service-changed.outputs.result == 'true' && matrix.variant == 'freeRelease' }}
uses: reactivecircus/android-emulator-runner@v2.11.0
with:
api-level: ${{ matrix.api-level }}
target: default
script: |
adb shell settings put global animator_duration_scale 0
adb shell settings put global transition_animation_scale 0
adb shell settings put global window_animation_scale 0
./gradlew :app:connectedFreeDebugAndroidTest

- name: Run instrumentation tests on nonFree flavor
if: ${{ steps.service-changed.outputs.result == 'true' && matrix.variant == 'nonFreeRelease' }}
uses: reactivecircus/android-emulator-runner@v2.11.0
with:
api-level: ${{ matrix.api-level }}
Expand All @@ -79,4 +91,4 @@ jobs:
adb shell settings put global animator_duration_scale 0
adb shell settings put global transition_animation_scale 0
adb shell settings put global window_animation_scale 0
./gradlew connectedCheck
./gradlew :app:connectedNonFreeDebugAndroidTest
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file.

## [Unreleased]

### Fixed

- Properly handle cases where files contain only TOTP secrets and no password
- Correctly hide TOTP import button when TOTP secret/OTPAUTH URL is already present in extra content
- SMS OTP Autofill no longer crashes when invoked and correctly asks for the required permission on first use

## [1.10.1] - 2020-07-23

### Fixed
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
/*
* Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
* SPDX-License-Identifier: GPL-3.0-only
*/

package com.zeapo.pwdstore.model

import com.zeapo.pwdstore.utils.Otp
import com.zeapo.pwdstore.utils.UriTotpFinder
import java.util.Date
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
import org.junit.Test

class PasswordEntryAndroidTest {

private fun makeEntry(content: String) = PasswordEntry(content, UriTotpFinder())

@Test fun testGetPassword() {
assertEquals("fooooo", makeEntry("fooooo\nbla\n").password)
assertEquals("fooooo", makeEntry("fooooo\nbla").password)
assertEquals("fooooo", makeEntry("fooooo\n").password)
assertEquals("fooooo", makeEntry("fooooo").password)
assertEquals("", makeEntry("\nblubb\n").password)
assertEquals("", makeEntry("\nblubb").password)
assertEquals("", makeEntry("\n").password)
assertEquals("", makeEntry("").password)
}

@Test fun testGetExtraContent() {
assertEquals("bla\n", makeEntry("fooooo\nbla\n").extraContent)
assertEquals("bla", makeEntry("fooooo\nbla").extraContent)
assertEquals("", makeEntry("fooooo\n").extraContent)
assertEquals("", makeEntry("fooooo").extraContent)
assertEquals("blubb\n", makeEntry("\nblubb\n").extraContent)
assertEquals("blubb", makeEntry("\nblubb").extraContent)
assertEquals("", makeEntry("\n").extraContent)
assertEquals("", makeEntry("").extraContent)
}

@Test fun testGetUsername() {
for (field in PasswordEntry.USERNAME_FIELDS) {
assertEquals("username", makeEntry("\n$field username").username)
assertEquals("username", makeEntry("\n${field.toUpperCase()} username").username)
}
assertEquals(
"username",
makeEntry("secret\nextra\nlogin: username\ncontent\n").username)
assertEquals(
"username",
makeEntry("\nextra\nusername: username\ncontent\n").username)
assertEquals(
"username", makeEntry("\nUSERNaMe: username\ncontent\n").username)
assertEquals("username", makeEntry("\nlogin: username").username)
assertEquals("foo@example.com", makeEntry("\nemail: foo@example.com").username)
assertEquals("username", makeEntry("\nidentity: username\nlogin: another_username").username)
assertEquals("username", makeEntry("\nLOGiN:username").username)
assertNull(makeEntry("secret\nextra\ncontent\n").username)
}

@Test fun testHasUsername() {
assertTrue(makeEntry("secret\nextra\nlogin: username\ncontent\n").hasUsername())
assertFalse(makeEntry("secret\nextra\ncontent\n").hasUsername())
assertFalse(makeEntry("secret\nlogin failed\n").hasUsername())
assertFalse(makeEntry("\n").hasUsername())
assertFalse(makeEntry("").hasUsername())
}

@Test fun testGeneratesOtpFromTotpUri() {
val entry = makeEntry("secret\nextra\n$TOTP_URI")
assertTrue(entry.hasTotp())
val code = Otp.calculateCode(
entry.totpSecret!!,
// The hardcoded date value allows this test to stay reproducible.
Date(8640000).time / (1000 * entry.totpPeriod),
entry.totpAlgorithm,
entry.digits
)
assertNotNull(code) { "Generated OTP cannot be null" }
assertEquals(entry.digits.toInt(), code.length)
assertEquals("545293", code)
}

@Test fun testGeneratesOtpWithOnlyUriInFile() {
val entry = makeEntry(TOTP_URI)
assertTrue(entry.password.isEmpty())
assertTrue(entry.hasTotp())
val code = Otp.calculateCode(
entry.totpSecret!!,
// The hardcoded date value allows this test to stay reproducible.
Date(8640000).time / (1000 * entry.totpPeriod),
entry.totpAlgorithm,
entry.digits
)
assertNotNull(code) { "Generated OTP cannot be null" }
assertEquals(entry.digits.toInt(), code.length)
assertEquals("545293", code)
}

@Test fun testOnlyLooksForUriInFirstLine() {
val entry = makeEntry("id:\n$TOTP_URI")
assertTrue(entry.password.isNotEmpty())
assertTrue(entry.hasTotp())
assertFalse(entry.hasUsername())
}

companion object {

const val TOTP_URI = "otpauth://totp/ACME%20Co:john@example.com?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co&algorithm=SHA1&digits=6&period=30"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,25 +16,30 @@ class UriTotpFinderTest {
fun findSecret() {
assertEquals("HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ", totpFinder.findSecret(TOTP_URI))
assertEquals("HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ", totpFinder.findSecret("name\npassword\ntotp: HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ"))
assertEquals("HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ", totpFinder.findSecret(PASS_FILE_CONTENT))
}

@Test
fun findDigits() {
assertEquals("12", totpFinder.findDigits(TOTP_URI))
assertEquals("12", totpFinder.findDigits(PASS_FILE_CONTENT))
}

@Test
fun findPeriod() {
assertEquals(25, totpFinder.findPeriod(TOTP_URI))
assertEquals(25, totpFinder.findPeriod(PASS_FILE_CONTENT))
}

@Test
fun findAlgorithm() {
assertEquals("SHA256", totpFinder.findAlgorithm(TOTP_URI))
assertEquals("SHA256", totpFinder.findAlgorithm(PASS_FILE_CONTENT))
}

companion object {

const val TOTP_URI = "otpauth://totp/ACME%20Co:john@example.com?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co&algorithm=SHA256&digits=12&period=25"
const val PASS_FILE_CONTENT = "password\n$TOTP_URI"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -177,13 +177,11 @@ class PasswordCreationActivity : BasePgpActivity(), OpenPgpServiceConnection.OnB
extraContent.setText(entry.extraContentWithoutAuthData)
}
}
updateViewState()
}
}
listOf(filename, extraContent).forEach {
it.doOnTextChanged { _, _, _, _ -> updateViewState() }
}
updateViewState()
}
suggestedPass?.let {
password.setText(it)
Expand All @@ -195,6 +193,7 @@ class PasswordCreationActivity : BasePgpActivity(), OpenPgpServiceConnection.OnB
password.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
}
}
updateViewState()
}

override fun onCreateOptionsMenu(menu: Menu?): Boolean {
Expand Down Expand Up @@ -235,7 +234,7 @@ class PasswordCreationActivity : BasePgpActivity(), OpenPgpServiceConnection.OnB
val entry = PasswordEntry("PLACEHOLDER\n${extraContent.text}")
encryptUsername.apply {
if (visibility != View.VISIBLE)
return@with
return@apply
val hasUsernameInFileName = filename.text.toString().isNotBlank()
val hasUsernameInExtras = entry.hasUsername()
isEnabled = hasUsernameInFileName xor hasUsernameInExtras
Expand Down Expand Up @@ -420,8 +419,7 @@ class PasswordCreationActivity : BasePgpActivity(), OpenPgpServiceConnection.OnB
AutofillPreferences.directoryStructure(applicationContext)
val entry = PasswordEntry(content)
returnIntent.putExtra(RETURN_EXTRA_PASSWORD, entry.password)
val username = PasswordEntry(content).username
?: directoryStructure.getUsernameFor(file)
val username = entry.username ?: directoryStructure.getUsernameFor(file)
returnIntent.putExtra(RETURN_EXTRA_USERNAME, username)
}

Expand Down
8 changes: 5 additions & 3 deletions app/src/main/java/com/zeapo/pwdstore/model/PasswordEntry.kt
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ class PasswordEntry(content: String, private val totpFinder: TotpFinder = UriTot

init {
val passContent = content.split("\n".toRegex(), 2).toTypedArray()
password = passContent[0]
password = if (UriTotpFinder.TOTP_FIELDS.any { passContent[0].startsWith(it) }) "" else passContent[0]
extraContent = findExtraContent(passContent)
username = findUsername()
digits = findOtpDigits(content)
Expand Down Expand Up @@ -85,8 +85,10 @@ class PasswordEntry(content: String, private val totpFinder: TotpFinder = UriTot
return null
}

private fun findExtraContent(passContent: Array<String>): String {
return if (passContent.size > 1) passContent[1] else ""
private fun findExtraContent(passContent: Array<String>) = when {
password.isEmpty() && passContent[0].isNotEmpty() -> passContent[0]
passContent.size > 1 -> passContent[1]
else -> ""
}

private fun findTotpSecret(decryptedContent: String): String? {
Expand Down
17 changes: 12 additions & 5 deletions app/src/main/java/com/zeapo/pwdstore/utils/UriTotpFinder.kt
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,10 @@ class UriTotpFinder : TotpFinder {

override fun findSecret(content: String): String? {
content.split("\n".toRegex()).forEach { line ->
if (line.startsWith("otpauth://totp/")) {
if (line.startsWith(TOTP_FIELDS[0])) {
return Uri.parse(line).getQueryParameter("secret")
}
if (line.startsWith("totp:", ignoreCase = true)) {
if (line.startsWith(TOTP_FIELDS[1], ignoreCase = true)) {
return line.split(": *".toRegex(), 2).toTypedArray()[1]
}
}
Expand All @@ -26,7 +26,7 @@ class UriTotpFinder : TotpFinder {

override fun findDigits(content: String): String {
content.split("\n".toRegex()).forEach { line ->
if (line.startsWith("otpauth://totp/") &&
if (line.startsWith(TOTP_FIELDS[0]) &&
Uri.parse(line).getQueryParameter("digits") != null) {
return Uri.parse(line).getQueryParameter("digits")!!
}
Expand All @@ -36,7 +36,7 @@ class UriTotpFinder : TotpFinder {

override fun findPeriod(content: String): Long {
content.split("\n".toRegex()).forEach { line ->
if (line.startsWith("otpauth://totp/") &&
if (line.startsWith(TOTP_FIELDS[0]) &&
Uri.parse(line).getQueryParameter("period") != null) {
val period = Uri.parse(line).getQueryParameter("period")!!.toLongOrNull()
if (period != null && period > 0)
Expand All @@ -48,11 +48,18 @@ class UriTotpFinder : TotpFinder {

override fun findAlgorithm(content: String): String {
content.split("\n".toRegex()).forEach { line ->
if (line.startsWith("otpauth://totp/") &&
if (line.startsWith(TOTP_FIELDS[0]) &&
Uri.parse(line).getQueryParameter("algorithm") != null) {
return Uri.parse(line).getQueryParameter("algorithm")!!
}
}
return "sha1"
}

companion object {
val TOTP_FIELDS = arrayOf(
"otpauth://totp",
"totp:"
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,30 @@ import com.google.android.gms.auth.api.phone.SmsRetriever
import com.google.android.gms.common.ConnectionResult
import com.google.android.gms.common.GoogleApiAvailability
import com.google.android.gms.common.api.ResolvableApiException
import com.google.android.gms.tasks.Tasks
import com.google.android.gms.tasks.Task
import com.zeapo.pwdstore.autofill.oreo.AutofillAction
import com.zeapo.pwdstore.autofill.oreo.Credentials
import com.zeapo.pwdstore.autofill.oreo.FillableForm
import com.zeapo.pwdstore.databinding.ActivityOreoAutofillSmsBinding
import com.zeapo.pwdstore.utils.viewBinding
import java.util.concurrent.ExecutionException
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext

suspend fun <T> Task<T>.suspendableAwait() = suspendCoroutine<T> { cont ->
addOnSuccessListener { result: T ->
cont.resume(result)
}
addOnFailureListener { e ->
// Unwrap specific exceptions (e.g. ResolvableApiException) from ExecutionException.
val cause = (e as? ExecutionException)?.cause ?: e
cont.resumeWithException(cause)
}
}

@RequiresApi(Build.VERSION_CODES.O)
class AutofillSmsActivity : AppCompatActivity() {
Expand Down Expand Up @@ -105,15 +122,21 @@ class AutofillSmsActivity : AppCompatActivity() {
}
}

private fun waitForSms() {
private suspend fun waitForSms() {
val smsClient = SmsCodeRetriever.getAutofillClient(this@AutofillSmsActivity)
try {
Tasks.await(smsClient.startSmsCodeRetriever())
withContext(Dispatchers.IO) {
smsClient.startSmsCodeRetriever().suspendableAwait()
}
} catch (e: ResolvableApiException) {
e.startResolutionForResult(this, 1)
withContext(Dispatchers.Main) {
e.startResolutionForResult(this@AutofillSmsActivity, 1)
}
} catch (e: Exception) {
e(e)
finish()
withContext(Dispatchers.Main) {
finish()
}
}
}

Expand Down
23 changes: 23 additions & 0 deletions app/src/test/java/com/zeapo/pwdstore/model/PasswordEntryTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,29 @@ class PasswordEntryTest {
assertEquals("545293", code)
}

@Test fun testGeneratesOtpWithOnlyUriInFile() {
val entry = makeEntry(TOTP_URI)
assertTrue(entry.password.isEmpty())
assertTrue(entry.hasTotp())
val code = Otp.calculateCode(
entry.totpSecret!!,
// The hardcoded date value allows this test to stay reproducible.
Date(8640000).time / (1000 * entry.totpPeriod),
entry.totpAlgorithm,
entry.digits
)
assertNotNull(code) { "Generated OTP cannot be null" }
assertEquals(entry.digits.toInt(), code.length)
assertEquals("545293", code)
}

@Test fun testOnlyLooksForUriInFirstLine() {
val entry = makeEntry("id:\n$TOTP_URI")
assertTrue(entry.password.isNotEmpty())
assertTrue(entry.hasTotp())
assertFalse(entry.hasUsername())
}

companion object {

const val TOTP_URI = "otpauth://totp/ACME%20Co:john@example.com?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co&algorithm=SHA1&digits=6&period=30"
Expand Down