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()) } }