diff --git a/opencloudApp/src/androidTest/java/eu/opencloud/android/settings/security/PassCodeActivityTest.kt b/opencloudApp/src/androidTest/java/eu/opencloud/android/settings/security/PassCodeActivityTest.kt index 7498d63330..8ddf8e236b 100644 --- a/opencloudApp/src/androidTest/java/eu/opencloud/android/settings/security/PassCodeActivityTest.kt +++ b/opencloudApp/src/androidTest/java/eu/opencloud/android/settings/security/PassCodeActivityTest.kt @@ -102,6 +102,7 @@ class PassCodeActivityTest { } every { passCodeViewModel.getPassCode() } returns OC_PASSCODE_4_DIGITS + every { passCodeViewModel.getPassCodeLength() } returns OC_PASSCODE_4_DIGITS.length every { passCodeViewModel.getNumberOfPassCodeDigits() } returns 4 every { passCodeViewModel.getNumberOfAttempts() } returns 0 every { passCodeViewModel.getTimeToUnlockLiveData } returns timeToUnlockLiveData diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/security/AppLockSecretHash.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/security/AppLockSecretHash.kt new file mode 100644 index 0000000000..3b3b29e139 --- /dev/null +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/security/AppLockSecretHash.kt @@ -0,0 +1,89 @@ +/** + * openCloud Android client application + * + * Copyright (C) 2026 OpenCloud GmbH. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 2, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package eu.opencloud.android.presentation.security + +import java.nio.charset.StandardCharsets +import java.security.MessageDigest +import java.security.SecureRandom +import java.util.Base64 +import javax.crypto.SecretKeyFactory +import javax.crypto.spec.PBEKeySpec + +object AppLockSecretHash { + private const val PREFIX = "pbkdf2-sha256" + private const val VERSION = "v1" + private const val ITERATIONS = 120_000 + private const val SALT_BYTES = 16 + private const val KEY_LENGTH_BITS = 256 + private const val FIELD_SEPARATOR = ":" + private const val PARTS_COUNT = 5 + private const val ALGORITHM = "PBKDF2WithHmacSHA256" + + private val secureRandom = SecureRandom() + + fun hash(secret: String): String { + val salt = ByteArray(SALT_BYTES).also(secureRandom::nextBytes) + val hash = pbkdf2(secret, salt, ITERATIONS) + + return listOf( + PREFIX, + VERSION, + ITERATIONS.toString(), + Base64.getEncoder().encodeToString(salt), + Base64.getEncoder().encodeToString(hash), + ).joinToString(FIELD_SEPARATOR) + } + + fun verify(secret: String, storedSecret: String): Boolean = + if (isHash(storedSecret)) { + verifyHash(secret, storedSecret) + } else { + MessageDigest.isEqual( + secret.toByteArray(StandardCharsets.UTF_8), + storedSecret.toByteArray(StandardCharsets.UTF_8) + ) + } + + fun isHash(storedSecret: String): Boolean = + storedSecret.startsWith("$PREFIX$FIELD_SEPARATOR$VERSION$FIELD_SEPARATOR") + + private fun verifyHash(secret: String, storedHash: String): Boolean { + val parts = storedHash.split(FIELD_SEPARATOR) + if (parts.size != PARTS_COUNT || parts[0] != PREFIX || parts[1] != VERSION) return false + + return try { + val iterations = parts[2].toInt() + val salt = Base64.getDecoder().decode(parts[3]) + val expectedHash = Base64.getDecoder().decode(parts[4]) + val actualHash = pbkdf2(secret, salt, iterations) + MessageDigest.isEqual(expectedHash, actualHash) + } catch (e: IllegalArgumentException) { + false + } + } + + private fun pbkdf2(secret: String, salt: ByteArray, iterations: Int): ByteArray { + val spec = PBEKeySpec(secret.toCharArray(), salt, iterations, KEY_LENGTH_BITS) + return try { + SecretKeyFactory.getInstance(ALGORITHM).generateSecret(spec).encoded + } finally { + spec.clearPassword() + } + } +} diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/security/biometric/BiometricViewModel.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/security/biometric/BiometricViewModel.kt index 308eb24485..a9217269d0 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/security/biometric/BiometricViewModel.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/security/biometric/BiometricViewModel.kt @@ -28,6 +28,7 @@ import androidx.biometric.BiometricPrompt import androidx.lifecycle.ViewModel import eu.opencloud.android.R import eu.opencloud.android.data.providers.SharedPreferencesProvider +import eu.opencloud.android.presentation.security.AppLockSecretHash import eu.opencloud.android.presentation.security.PREFERENCE_LAST_UNLOCK_TIMESTAMP import eu.opencloud.android.presentation.security.passcode.PassCodeActivity import eu.opencloud.android.providers.ContextProvider @@ -90,11 +91,18 @@ class BiometricViewModel( fun shouldAskForNewPassCode(): Boolean { val passCode = preferencesProvider.getString(PassCodeActivity.PREFERENCE_PASSCODE, loadPinFromOldFormatIfPossible()) val passCodeDigits = maxOf(contextProvider.getInt(R.integer.passcode_digits), PassCodeActivity.PASSCODE_MIN_LENGTH) - return (passCode != null && passCode.length < passCodeDigits) + val savedPassCodeDigits = when { + passCode == null -> null + AppLockSecretHash.isHash(passCode) -> + preferencesProvider.getInt(PassCodeActivity.PREFERENCE_PASSCODE_LENGTH, passCodeDigits) + else -> passCode.length + } + return savedPassCodeDigits != null && savedPassCodeDigits < passCodeDigits } fun removePassCode() { preferencesProvider.removePreference(PassCodeActivity.PREFERENCE_PASSCODE) + preferencesProvider.removePreference(PassCodeActivity.PREFERENCE_PASSCODE_LENGTH) preferencesProvider.putBoolean(PassCodeActivity.PREFERENCE_SET_PASSCODE, false) } diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/security/passcode/PassCodeActivity.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/security/passcode/PassCodeActivity.kt index e9f2022b02..cb5bb21fec 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/security/passcode/PassCodeActivity.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/security/passcode/PassCodeActivity.kt @@ -114,7 +114,7 @@ class PassCodeActivity : AppCompatActivity(), NumberKeyboardListener, EnableBiom showMessageInSnackbar(message = getString(R.string.biometric_not_available)) } - numberOfPasscodeDigits = passCodeViewModel.getPassCode()?.length ?: passCodeViewModel.getNumberOfPassCodeDigits() + numberOfPasscodeDigits = passCodeViewModel.getPassCodeLength() passCodeEditTexts = arrayOfNulls(numberOfPasscodeDigits) // Allow or disallow touches with other visible windows @@ -195,7 +195,7 @@ class PassCodeActivity : AppCompatActivity(), NumberKeyboardListener, EnableBiom private fun inflatePasscodeTxtLine() { val layoutCode = findViewById(R.id.layout_code) - val numberOfPasscodeDigits = (passCodeViewModel.getPassCode()?.length ?: passCodeViewModel.getNumberOfPassCodeDigits()) + val numberOfPasscodeDigits = passCodeViewModel.getPassCodeLength() for (i in 0 until numberOfPasscodeDigits) { val txt = layoutInflater.inflate(R.layout.passcode_edit_text, layoutCode, false) as EditText layoutCode.addView(txt) @@ -484,6 +484,7 @@ class PassCodeActivity : AppCompatActivity(), NumberKeyboardListener, EnableBiom // NOTE: PREFERENCE_SET_PASSCODE must have the same value as settings_security.xml-->android:key for passcode preference const val PREFERENCE_SET_PASSCODE = "set_pincode" const val PREFERENCE_PASSCODE = "PrefPinCode" + const val PREFERENCE_PASSCODE_LENGTH = "PrefPinCodeLength" const val PREFERENCE_MIGRATION_REQUIRED = "PrefMigrationRequired" // NOTE: This is required to read the legacy pin code format diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/security/passcode/PassCodeViewModel.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/security/passcode/PassCodeViewModel.kt index 11f6518776..ac42a8860a 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/security/passcode/PassCodeViewModel.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/security/passcode/PassCodeViewModel.kt @@ -28,6 +28,7 @@ import androidx.lifecycle.ViewModel import eu.opencloud.android.R import eu.opencloud.android.data.providers.SharedPreferencesProvider import eu.opencloud.android.domain.utils.Event +import eu.opencloud.android.presentation.security.AppLockSecretHash import eu.opencloud.android.presentation.security.PREFERENCE_LAST_UNLOCK_ATTEMPT_TIMESTAMP import eu.opencloud.android.presentation.security.PREFERENCE_LAST_UNLOCK_TIMESTAMP import eu.opencloud.android.presentation.security.biometric.BiometricActivity @@ -66,7 +67,7 @@ class PassCodeViewModel( private var confirmingPassCode = false init { - numberOfPasscodeDigits = (getPassCode()?.length ?: getNumberOfPassCodeDigits()) + numberOfPasscodeDigits = getPassCodeLength() } fun onNumberClicked(number: Int) { @@ -108,11 +109,11 @@ class PassCodeViewModel( } private fun actionCheckPasscode() { - if (checkPassCodeIsValid(passcodeString.toString())) { + val enteredPasscode = passcodeString.toString() + if (checkPassCodeIsValid(enteredPasscode)) { // pass code accepted in request, user is allowed to access the app setLastUnlockTimestamp() - val passCode = getPassCode() - if (passCode != null && passCode.length < getNumberOfPassCodeDigits()) { + if (getPassCodeLength() < getNumberOfPassCodeDigits()) { setMigrationRequired(true) removePassCode() _status.postValue(Status(PasscodeAction.CHECK, PasscodeType.MIGRATION)) @@ -150,24 +151,40 @@ class PassCodeViewModel( } } - fun getPassCode() = preferencesProvider.getString(PassCodeActivity.PREFERENCE_PASSCODE, loadPinFromOldFormatIfPossible()) + fun getPassCode(): String? = + getStoredPassCode()?.takeUnless(AppLockSecretHash::isHash) + + fun getPassCodeLength(): Int { + val storedPassCode = getStoredPassCode() + return when { + storedPassCode == null -> getNumberOfPassCodeDigits() + AppLockSecretHash.isHash(storedPassCode) -> + preferencesProvider.getInt(PassCodeActivity.PREFERENCE_PASSCODE_LENGTH, getNumberOfPassCodeDigits()) + else -> storedPassCode.length + } + } fun setPassCode() { - preferencesProvider.putString(PassCodeActivity.PREFERENCE_PASSCODE, firstPasscode) - preferencesProvider.putBoolean(PassCodeActivity.PREFERENCE_SET_PASSCODE, true) - numberOfPasscodeDigits = (getPassCode()?.length ?: getNumberOfPassCodeDigits()) + storePassCode(firstPasscode) + numberOfPasscodeDigits = getPassCodeLength() } fun removePassCode() { preferencesProvider.removePreference(PassCodeActivity.PREFERENCE_PASSCODE) + preferencesProvider.removePreference(PassCodeActivity.PREFERENCE_PASSCODE_LENGTH) preferencesProvider.putBoolean(PassCodeActivity.PREFERENCE_SET_PASSCODE, false) - numberOfPasscodeDigits = (getPassCode()?.length ?: getNumberOfPassCodeDigits()) + numberOfPasscodeDigits = getPassCodeLength() } fun checkPassCodeIsValid(passcode: String): Boolean { - val passCodeString = getPassCode() - if (passCodeString.isNullOrEmpty()) return false - return passcode == passCodeString + val storedPassCode = getStoredPassCode() + if (storedPassCode.isNullOrEmpty()) return false + + val isValid = AppLockSecretHash.verify(passcode, storedPassCode) + if (isValid && !AppLockSecretHash.isHash(storedPassCode)) { + storePassCode(passcode) + } + return isValid } fun getNumberOfPassCodeDigits(): Int { @@ -230,6 +247,22 @@ class PassCodeViewModel( return pinString.ifEmpty { null } } + private fun getStoredPassCode(): String? = + preferencesProvider.getString(PassCodeActivity.PREFERENCE_PASSCODE, loadPinFromOldFormatIfPossible()) + + private fun storePassCode(passcode: String) { + preferencesProvider.putString(PassCodeActivity.PREFERENCE_PASSCODE, AppLockSecretHash.hash(passcode)) + preferencesProvider.putInt(PassCodeActivity.PREFERENCE_PASSCODE_LENGTH, passcode.length) + preferencesProvider.putBoolean(PassCodeActivity.PREFERENCE_SET_PASSCODE, true) + removeLegacyPinFormat() + } + + private fun removeLegacyPinFormat() { + for (i in 1..4) { + preferencesProvider.removePreference(PassCodeActivity.PREFERENCE_PASSCODE_D + i) + } + } + fun setBiometricsState(enabled: Boolean) { preferencesProvider.putBoolean(BiometricActivity.PREFERENCE_SET_BIOMETRIC, enabled) } diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/security/pattern/PatternActivity.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/security/pattern/PatternActivity.kt index 9728b1e299..3bc4c41b91 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/security/pattern/PatternActivity.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/security/pattern/PatternActivity.kt @@ -183,7 +183,7 @@ class PatternActivity : AppCompatActivity(), EnableBiometrics { } override fun onProgress(list: List) { - Timber.d("Pattern Progress %s", PatternLockUtils.patternToString(binding.patternLockView, list)) + Timber.d("Pattern drawing in progress") } override fun onComplete(list: List) { @@ -205,7 +205,7 @@ class PatternActivity : AppCompatActivity(), EnableBiometrics { } else { patternValue = PatternLockUtils.patternToString(binding.patternLockView, list) } - Timber.d("Pattern %s", PatternLockUtils.patternToString(binding.patternLockView, list)) + Timber.d("Pattern drawing completed") processPattern() } diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/security/pattern/PatternViewModel.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/security/pattern/PatternViewModel.kt index 112a5e5bb4..0850aa1193 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/security/pattern/PatternViewModel.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/security/pattern/PatternViewModel.kt @@ -22,6 +22,7 @@ package eu.opencloud.android.presentation.security.pattern import androidx.lifecycle.ViewModel import eu.opencloud.android.data.providers.SharedPreferencesProvider +import eu.opencloud.android.presentation.security.AppLockSecretHash import eu.opencloud.android.presentation.security.biometric.BiometricActivity class PatternViewModel( @@ -29,7 +30,7 @@ class PatternViewModel( ) : ViewModel() { fun setPattern(pattern: String) { - preferencesProvider.putString(PatternActivity.PREFERENCE_PATTERN, pattern) + preferencesProvider.putString(PatternActivity.PREFERENCE_PATTERN, AppLockSecretHash.hash(pattern)) preferencesProvider.putBoolean(PatternActivity.PREFERENCE_SET_PATTERN, true) } @@ -39,8 +40,16 @@ class PatternViewModel( } fun checkPatternIsValid(patternValue: String?): Boolean { + if (patternValue == null) return false + val savedPattern = preferencesProvider.getString(PatternActivity.PREFERENCE_PATTERN, null) - return savedPattern != null && savedPattern == patternValue + if (savedPattern.isNullOrEmpty()) return false + + val isValid = AppLockSecretHash.verify(patternValue, savedPattern) + if (isValid && !AppLockSecretHash.isHash(savedPattern)) { + setPattern(patternValue) + } + return isValid } fun setBiometricsState(enabled: Boolean) { diff --git a/opencloudApp/src/test/java/eu/opencloud/android/presentation/viewmodels/security/AppLockSecretHashTest.kt b/opencloudApp/src/test/java/eu/opencloud/android/presentation/viewmodels/security/AppLockSecretHashTest.kt new file mode 100644 index 0000000000..21039ae27a --- /dev/null +++ b/opencloudApp/src/test/java/eu/opencloud/android/presentation/viewmodels/security/AppLockSecretHashTest.kt @@ -0,0 +1,46 @@ +/** + * openCloud Android client application + * + * Copyright (C) 2026 OpenCloud GmbH. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 2, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package eu.opencloud.android.presentation.viewmodels.security + +import eu.opencloud.android.presentation.security.AppLockSecretHash +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class AppLockSecretHashTest { + + @Test + fun `hash hides secret and validates matching secret`() { + val secret = "1234" + + val storedSecret = AppLockSecretHash.hash(secret) + + assertNotEquals(secret, storedSecret) + assertTrue(AppLockSecretHash.isHash(storedSecret)) + assertTrue(AppLockSecretHash.verify(secret, storedSecret)) + assertFalse(AppLockSecretHash.verify("4321", storedSecret)) + } + + @Test + fun `verify still accepts legacy plaintext secret`() { + assertTrue(AppLockSecretHash.verify("1234", "1234")) + assertFalse(AppLockSecretHash.verify("4321", "1234")) + } +} diff --git a/opencloudApp/src/test/java/eu/opencloud/android/presentation/viewmodels/security/BiometricViewModelTest.kt b/opencloudApp/src/test/java/eu/opencloud/android/presentation/viewmodels/security/BiometricViewModelTest.kt index b062e96918..8ac3c01800 100644 --- a/opencloudApp/src/test/java/eu/opencloud/android/presentation/viewmodels/security/BiometricViewModelTest.kt +++ b/opencloudApp/src/test/java/eu/opencloud/android/presentation/viewmodels/security/BiometricViewModelTest.kt @@ -107,6 +107,7 @@ class BiometricViewModelTest : ViewModelTest() { verify(exactly = 1) { preferencesProvider.removePreference(PassCodeActivity.PREFERENCE_PASSCODE) + preferencesProvider.removePreference(PassCodeActivity.PREFERENCE_PASSCODE_LENGTH) preferencesProvider.putBoolean(PassCodeActivity.PREFERENCE_SET_PASSCODE, false) } } diff --git a/opencloudApp/src/test/java/eu/opencloud/android/presentation/viewmodels/security/PassCodeViewModelTest.kt b/opencloudApp/src/test/java/eu/opencloud/android/presentation/viewmodels/security/PassCodeViewModelTest.kt index ca2ffb6083..c3998dbebd 100644 --- a/opencloudApp/src/test/java/eu/opencloud/android/presentation/viewmodels/security/PassCodeViewModelTest.kt +++ b/opencloudApp/src/test/java/eu/opencloud/android/presentation/viewmodels/security/PassCodeViewModelTest.kt @@ -23,6 +23,7 @@ package eu.opencloud.android.presentation.viewmodels.security import android.os.SystemClock import eu.opencloud.android.R import eu.opencloud.android.data.providers.SharedPreferencesProvider +import eu.opencloud.android.presentation.security.AppLockSecretHash import eu.opencloud.android.presentation.security.PREFERENCE_LAST_UNLOCK_ATTEMPT_TIMESTAMP import eu.opencloud.android.presentation.security.PREFERENCE_LAST_UNLOCK_TIMESTAMP import eu.opencloud.android.presentation.security.passcode.PassCodeViewModel @@ -30,6 +31,7 @@ import eu.opencloud.android.presentation.viewmodels.ViewModelTest import eu.opencloud.android.presentation.security.passcode.PassCodeActivity import eu.opencloud.android.presentation.security.passcode.PassCodeActivity.Companion.PREFERENCE_PASSCODE import eu.opencloud.android.presentation.security.passcode.PassCodeActivity.Companion.PREFERENCE_PASSCODE_D +import eu.opencloud.android.presentation.security.passcode.PassCodeActivity.Companion.PREFERENCE_PASSCODE_LENGTH import eu.opencloud.android.presentation.security.passcode.PassCodeActivity.Companion.PREFERENCE_SET_PASSCODE import eu.opencloud.android.presentation.security.passcode.PasscodeAction import eu.opencloud.android.presentation.security.passcode.PasscodeType @@ -72,6 +74,7 @@ class PassCodeViewModelTest : ViewModelTest() { for (i in 0..4) { every { preferencesProvider.getString(PREFERENCE_PASSCODE_D + i, null) } returns passcodeD //loadPinFromOldFormatIfPossible() } + every { preferencesProvider.getInt(PREFERENCE_PASSCODE_LENGTH, any()) } returns (passcode?.length ?: passcodeDigits) every { preferencesProvider.getInt(PREFERENCE_LOCK_ATTEMPTS, any()) } returns lockAttempts //getNumberOfAttempts() every { preferencesProvider.getLong(PREFERENCE_LAST_UNLOCK_ATTEMPT_TIMESTAMP, any()) } returns lastUnlockAttempt //getTimeToUnlockLeft() } @@ -234,7 +237,11 @@ class PassCodeViewModelTest : ViewModelTest() { assertEquals(Status(PasscodeAction.CREATE, PasscodeType.CONFIRM), passCodeViewModel.status.value) verify(exactly = 1) { - preferencesProvider.putString(PREFERENCE_PASSCODE, any()) + preferencesProvider.putString( + PREFERENCE_PASSCODE, + match { it != OC_PASSCODE_4_DIGITS && AppLockSecretHash.verify(OC_PASSCODE_4_DIGITS, it) } + ) + preferencesProvider.putInt(PREFERENCE_PASSCODE_LENGTH, OC_PASSCODE_4_DIGITS.length) preferencesProvider.putBoolean(PREFERENCE_SET_PASSCODE, true) } } diff --git a/opencloudApp/src/test/java/eu/opencloud/android/presentation/viewmodels/security/PatternViewModelTest.kt b/opencloudApp/src/test/java/eu/opencloud/android/presentation/viewmodels/security/PatternViewModelTest.kt index 2420d60c32..75fc6481df 100644 --- a/opencloudApp/src/test/java/eu/opencloud/android/presentation/viewmodels/security/PatternViewModelTest.kt +++ b/opencloudApp/src/test/java/eu/opencloud/android/presentation/viewmodels/security/PatternViewModelTest.kt @@ -21,6 +21,7 @@ package eu.opencloud.android.presentation.viewmodels.security import eu.opencloud.android.data.providers.SharedPreferencesProvider +import eu.opencloud.android.presentation.security.AppLockSecretHash import eu.opencloud.android.presentation.security.pattern.PatternActivity import eu.opencloud.android.presentation.security.pattern.PatternViewModel import eu.opencloud.android.presentation.viewmodels.ViewModelTest @@ -51,7 +52,10 @@ class PatternViewModelTest : ViewModelTest() { patternViewModel.setPattern(pattern) verify(exactly = 1) { - preferencesProvider.putString(PatternActivity.PREFERENCE_PATTERN, pattern) + preferencesProvider.putString( + PatternActivity.PREFERENCE_PATTERN, + match { it != pattern && AppLockSecretHash.verify(pattern, it) } + ) preferencesProvider.putBoolean(PatternActivity.PREFERENCE_SET_PATTERN, true) } }