179 lines
6.7 KiB
Kotlin
179 lines
6.7 KiB
Kotlin
|
|
package dev.privacyllc.period.lock
|
||
|
|
|
||
|
|
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||
|
|
import dev.privacyllc.period.core.datastore.UserPreferencesRepository
|
||
|
|
import dev.privacyllc.period.core.security.AppLockRepository
|
||
|
|
import dev.privacyllc.period.core.security.LockMethod
|
||
|
|
import dev.privacyllc.period.core.security.MacProvider
|
||
|
|
import kotlinx.coroutines.CoroutineScope
|
||
|
|
import kotlinx.coroutines.Dispatchers
|
||
|
|
import kotlinx.coroutines.flow.first
|
||
|
|
import kotlinx.coroutines.runBlocking
|
||
|
|
import org.junit.Assert.assertEquals
|
||
|
|
import org.junit.Assert.assertFalse
|
||
|
|
import org.junit.Assert.assertNull
|
||
|
|
import org.junit.Assert.assertTrue
|
||
|
|
import org.junit.Before
|
||
|
|
import org.junit.Rule
|
||
|
|
import org.junit.Test
|
||
|
|
import org.junit.rules.TemporaryFolder
|
||
|
|
import org.junit.runner.RunWith
|
||
|
|
import org.robolectric.RobolectricTestRunner
|
||
|
|
import org.robolectric.annotation.Config
|
||
|
|
import javax.crypto.Mac
|
||
|
|
import javax.crypto.spec.SecretKeySpec
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Giving an existing install the lock method it was already using.
|
||
|
|
*
|
||
|
|
* The reason this has its own test file rather than a couple of cases somewhere:
|
||
|
|
* a migration that gets this wrong does not show a wrong number on a screen. It
|
||
|
|
* locks somebody out of their own history, permanently, on an update they did
|
||
|
|
* not ask for — because the policy behind this lock is that a forgotten PIN is
|
||
|
|
* not recoverable.
|
||
|
|
*
|
||
|
|
* So the load-bearing assertion here is a negative one: whatever the old install
|
||
|
|
* looked like, this can never produce a method with no PIN behind it.
|
||
|
|
*/
|
||
|
|
@RunWith(RobolectricTestRunner::class)
|
||
|
|
@Config(sdk = [34])
|
||
|
|
class LockMethodMigrationTest {
|
||
|
|
|
||
|
|
@get:Rule val temp = TemporaryFolder()
|
||
|
|
|
||
|
|
/** See LockSettingsViewModelTest: AndroidKeyStore does not exist off-device. */
|
||
|
|
private class InMemoryMacProvider : MacProvider {
|
||
|
|
private var key: ByteArray? = null
|
||
|
|
override fun hasKey() = key != null
|
||
|
|
override fun ensureKey() { if (key == null) key = ByteArray(32) { it.toByte() } }
|
||
|
|
override fun mac(data: ByteArray): ByteArray {
|
||
|
|
val k = key ?: error("the verifier key is absent")
|
||
|
|
return Mac.getInstance("HmacSHA256").apply { init(SecretKeySpec(k, "HmacSHA256")) }.doFinal(data)
|
||
|
|
}
|
||
|
|
override fun deleteKey() { key = null }
|
||
|
|
}
|
||
|
|
|
||
|
|
private lateinit var lock: AppLockRepository
|
||
|
|
private lateinit var preferences: UserPreferencesRepository
|
||
|
|
private lateinit var migration: LockMethodMigration
|
||
|
|
|
||
|
|
@Before fun setUp() {
|
||
|
|
val scope = CoroutineScope(Dispatchers.IO)
|
||
|
|
// Unique names: several tests re-run this to walk every legacy shape,
|
||
|
|
// and TemporaryFolder refuses to hand out the same file twice.
|
||
|
|
val id = System.nanoTime()
|
||
|
|
lock = AppLockRepository(
|
||
|
|
PreferenceDataStoreFactory.create(scope = scope) { temp.newFile("app_lock_$id.preferences_pb") },
|
||
|
|
InMemoryMacProvider(),
|
||
|
|
)
|
||
|
|
preferences = UserPreferencesRepository(
|
||
|
|
PreferenceDataStoreFactory.create(scope = scope) { temp.newFile("prefs_$id.preferences_pb") },
|
||
|
|
)
|
||
|
|
migration = LockMethodMigration(lock, preferences)
|
||
|
|
}
|
||
|
|
|
||
|
|
/** What an install from before the method key looked like. */
|
||
|
|
private fun legacyInstall(hasPin: Boolean, fingerprintFlag: Boolean) = runBlocking {
|
||
|
|
if (hasPin) lock.setPin("2468".toCharArray())
|
||
|
|
preferences.setBiometricLockEnabled(fingerprintFlag)
|
||
|
|
// The method key is what the old build never wrote.
|
||
|
|
if (hasPin) lock.clearStoredMethodForTest()
|
||
|
|
}
|
||
|
|
|
||
|
|
@Test fun `a PIN with the fingerprint shortcut on becomes either`() = runBlocking {
|
||
|
|
legacyInstall(hasPin = true, fingerprintFlag = true)
|
||
|
|
|
||
|
|
migration.run()
|
||
|
|
|
||
|
|
assertEquals(LockMethod.PIN_AND_BIOMETRIC, lock.method.first())
|
||
|
|
}
|
||
|
|
|
||
|
|
@Test fun `a PIN without the shortcut becomes a PIN`() = runBlocking {
|
||
|
|
legacyInstall(hasPin = true, fingerprintFlag = false)
|
||
|
|
|
||
|
|
migration.run()
|
||
|
|
|
||
|
|
assertEquals(LockMethod.PIN, lock.method.first())
|
||
|
|
}
|
||
|
|
|
||
|
|
@Test fun `no PIN becomes off, whatever the old flag said`() = runBlocking {
|
||
|
|
listOf(true, false).forEach { flag ->
|
||
|
|
setUp()
|
||
|
|
legacyInstall(hasPin = false, fingerprintFlag = flag)
|
||
|
|
|
||
|
|
migration.run()
|
||
|
|
|
||
|
|
assertEquals(LockMethod.NONE, lock.method.first())
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
@Test fun `it never produces a lock with no PIN behind it`() = runBlocking {
|
||
|
|
// All four legacy combinations. A sensor as the only way in is a
|
||
|
|
// decision the user has to make deliberately, never one an update makes
|
||
|
|
// for her.
|
||
|
|
listOf(true to true, true to false, false to true, false to false).forEach { (hasPin, flag) ->
|
||
|
|
setUp()
|
||
|
|
legacyInstall(hasPin = hasPin, fingerprintFlag = flag)
|
||
|
|
|
||
|
|
migration.run()
|
||
|
|
|
||
|
|
val method = lock.method.first()
|
||
|
|
assertTrue(
|
||
|
|
"a legacy install (pin=$hasPin, flag=$flag) migrated to $method",
|
||
|
|
method == LockMethod.NONE || method.requiresPin,
|
||
|
|
)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
@Test fun `running it twice changes nothing`() = runBlocking {
|
||
|
|
legacyInstall(hasPin = true, fingerprintFlag = true)
|
||
|
|
|
||
|
|
migration.run()
|
||
|
|
migration.run()
|
||
|
|
|
||
|
|
assertEquals(LockMethod.PIN_AND_BIOMETRIC, lock.method.first())
|
||
|
|
}
|
||
|
|
|
||
|
|
@Test fun `it leaves a method the user has already chosen alone`() = runBlocking {
|
||
|
|
// She turned the fingerprint shortcut off in a newer build; the stale
|
||
|
|
// flag must not turn it back on.
|
||
|
|
runBlocking { lock.setPin("2468".toCharArray(), LockMethod.PIN) }
|
||
|
|
preferences.setBiometricLockEnabled(true)
|
||
|
|
|
||
|
|
migration.run()
|
||
|
|
|
||
|
|
assertEquals(LockMethod.PIN, lock.method.first())
|
||
|
|
}
|
||
|
|
|
||
|
|
@Test fun `the retired flag is cleared once it has been read`() = runBlocking {
|
||
|
|
legacyInstall(hasPin = true, fingerprintFlag = true)
|
||
|
|
|
||
|
|
migration.run()
|
||
|
|
|
||
|
|
assertNull(preferences.legacyBiometricLockEnabled.first())
|
||
|
|
}
|
||
|
|
|
||
|
|
@Test fun `an unreadable preferences file still leaves a PIN path`() = runBlocking {
|
||
|
|
legacyInstall(hasPin = true, fingerprintFlag = true)
|
||
|
|
// A flag that cannot be read is a flag that was not set: the worst it
|
||
|
|
// costs is a shortcut she turns back on.
|
||
|
|
val broken = UserPreferencesRepository(
|
||
|
|
PreferenceDataStoreFactory.create(scope = CoroutineScope(Dispatchers.IO)) {
|
||
|
|
temp.newFile("broken_${System.nanoTime()}.preferences_pb")
|
||
|
|
.apply { writeBytes(byteArrayOf(9, 9, 9)) }
|
||
|
|
},
|
||
|
|
)
|
||
|
|
|
||
|
|
LockMethodMigration(lock, broken).run()
|
||
|
|
|
||
|
|
assertTrue(lock.method.first().requiresPin)
|
||
|
|
}
|
||
|
|
|
||
|
|
@Test fun `a fresh install is simply off`() = runBlocking {
|
||
|
|
migration.run()
|
||
|
|
|
||
|
|
assertEquals(LockMethod.NONE, lock.method.first())
|
||
|
|
assertFalse(lock.hasPin.first())
|
||
|
|
}
|
||
|
|
}
|