Skip to content

Commit 68ea357

Browse files
committed
Hash app lock secrets
1 parent d28208a commit 68ea357

11 files changed

Lines changed: 220 additions & 21 deletions

File tree

opencloudApp/src/androidTest/java/eu/opencloud/android/settings/security/PassCodeActivityTest.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ class PassCodeActivityTest {
102102
}
103103

104104
every { passCodeViewModel.getPassCode() } returns OC_PASSCODE_4_DIGITS
105+
every { passCodeViewModel.getPassCodeLength() } returns OC_PASSCODE_4_DIGITS.length
105106
every { passCodeViewModel.getNumberOfPassCodeDigits() } returns 4
106107
every { passCodeViewModel.getNumberOfAttempts() } returns 0
107108
every { passCodeViewModel.getTimeToUnlockLiveData } returns timeToUnlockLiveData
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
/**
2+
* openCloud Android client application
3+
*
4+
* Copyright (C) 2026 OpenCloud GmbH.
5+
*
6+
* This program is free software: you can redistribute it and/or modify
7+
* it under the terms of the GNU General Public License version 2,
8+
* as published by the Free Software Foundation.
9+
*
10+
* This program is distributed in the hope that it will be useful,
11+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
* GNU General Public License for more details.
14+
*
15+
* You should have received a copy of the GNU General Public License
16+
* along with this program. If not, see <http://www.gnu.org/licenses/>.
17+
*/
18+
19+
package eu.opencloud.android.presentation.security
20+
21+
import java.nio.charset.StandardCharsets
22+
import java.security.MessageDigest
23+
import java.security.SecureRandom
24+
import java.util.Base64
25+
import javax.crypto.SecretKeyFactory
26+
import javax.crypto.spec.PBEKeySpec
27+
28+
object AppLockSecretHash {
29+
private const val PREFIX = "pbkdf2-sha256"
30+
private const val VERSION = "v1"
31+
private const val ITERATIONS = 120_000
32+
private const val SALT_BYTES = 16
33+
private const val KEY_LENGTH_BITS = 256
34+
private const val FIELD_SEPARATOR = ":"
35+
private const val PARTS_COUNT = 5
36+
private const val ALGORITHM = "PBKDF2WithHmacSHA256"
37+
38+
private val secureRandom = SecureRandom()
39+
40+
fun hash(secret: String): String {
41+
val salt = ByteArray(SALT_BYTES).also(secureRandom::nextBytes)
42+
val hash = pbkdf2(secret, salt, ITERATIONS)
43+
44+
return listOf(
45+
PREFIX,
46+
VERSION,
47+
ITERATIONS.toString(),
48+
Base64.getEncoder().encodeToString(salt),
49+
Base64.getEncoder().encodeToString(hash),
50+
).joinToString(FIELD_SEPARATOR)
51+
}
52+
53+
fun verify(secret: String, storedSecret: String): Boolean =
54+
if (isHash(storedSecret)) {
55+
verifyHash(secret, storedSecret)
56+
} else {
57+
MessageDigest.isEqual(
58+
secret.toByteArray(StandardCharsets.UTF_8),
59+
storedSecret.toByteArray(StandardCharsets.UTF_8)
60+
)
61+
}
62+
63+
fun isHash(storedSecret: String): Boolean =
64+
storedSecret.startsWith("$PREFIX$FIELD_SEPARATOR$VERSION$FIELD_SEPARATOR")
65+
66+
private fun verifyHash(secret: String, storedHash: String): Boolean {
67+
val parts = storedHash.split(FIELD_SEPARATOR)
68+
if (parts.size != PARTS_COUNT || parts[0] != PREFIX || parts[1] != VERSION) return false
69+
70+
return try {
71+
val iterations = parts[2].toInt()
72+
val salt = Base64.getDecoder().decode(parts[3])
73+
val expectedHash = Base64.getDecoder().decode(parts[4])
74+
val actualHash = pbkdf2(secret, salt, iterations)
75+
MessageDigest.isEqual(expectedHash, actualHash)
76+
} catch (e: IllegalArgumentException) {
77+
false
78+
}
79+
}
80+
81+
private fun pbkdf2(secret: String, salt: ByteArray, iterations: Int): ByteArray {
82+
val spec = PBEKeySpec(secret.toCharArray(), salt, iterations, KEY_LENGTH_BITS)
83+
return try {
84+
SecretKeyFactory.getInstance(ALGORITHM).generateSecret(spec).encoded
85+
} finally {
86+
spec.clearPassword()
87+
}
88+
}
89+
}

opencloudApp/src/main/java/eu/opencloud/android/presentation/security/biometric/BiometricViewModel.kt

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import androidx.biometric.BiometricPrompt
2828
import androidx.lifecycle.ViewModel
2929
import eu.opencloud.android.R
3030
import eu.opencloud.android.data.providers.SharedPreferencesProvider
31+
import eu.opencloud.android.presentation.security.AppLockSecretHash
3132
import eu.opencloud.android.presentation.security.PREFERENCE_LAST_UNLOCK_TIMESTAMP
3233
import eu.opencloud.android.presentation.security.passcode.PassCodeActivity
3334
import eu.opencloud.android.providers.ContextProvider
@@ -90,11 +91,18 @@ class BiometricViewModel(
9091
fun shouldAskForNewPassCode(): Boolean {
9192
val passCode = preferencesProvider.getString(PassCodeActivity.PREFERENCE_PASSCODE, loadPinFromOldFormatIfPossible())
9293
val passCodeDigits = maxOf(contextProvider.getInt(R.integer.passcode_digits), PassCodeActivity.PASSCODE_MIN_LENGTH)
93-
return (passCode != null && passCode.length < passCodeDigits)
94+
val savedPassCodeDigits = when {
95+
passCode == null -> null
96+
AppLockSecretHash.isHash(passCode) ->
97+
preferencesProvider.getInt(PassCodeActivity.PREFERENCE_PASSCODE_LENGTH, passCodeDigits)
98+
else -> passCode.length
99+
}
100+
return savedPassCodeDigits != null && savedPassCodeDigits < passCodeDigits
94101
}
95102

96103
fun removePassCode() {
97104
preferencesProvider.removePreference(PassCodeActivity.PREFERENCE_PASSCODE)
105+
preferencesProvider.removePreference(PassCodeActivity.PREFERENCE_PASSCODE_LENGTH)
98106
preferencesProvider.putBoolean(PassCodeActivity.PREFERENCE_SET_PASSCODE, false)
99107
}
100108

opencloudApp/src/main/java/eu/opencloud/android/presentation/security/passcode/PassCodeActivity.kt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ class PassCodeActivity : AppCompatActivity(), NumberKeyboardListener, EnableBiom
114114
showMessageInSnackbar(message = getString(R.string.biometric_not_available))
115115
}
116116

117-
numberOfPasscodeDigits = passCodeViewModel.getPassCode()?.length ?: passCodeViewModel.getNumberOfPassCodeDigits()
117+
numberOfPasscodeDigits = passCodeViewModel.getPassCodeLength()
118118
passCodeEditTexts = arrayOfNulls(numberOfPasscodeDigits)
119119

120120
// Allow or disallow touches with other visible windows
@@ -195,7 +195,7 @@ class PassCodeActivity : AppCompatActivity(), NumberKeyboardListener, EnableBiom
195195

196196
private fun inflatePasscodeTxtLine() {
197197
val layoutCode = findViewById<LinearLayout>(R.id.layout_code)
198-
val numberOfPasscodeDigits = (passCodeViewModel.getPassCode()?.length ?: passCodeViewModel.getNumberOfPassCodeDigits())
198+
val numberOfPasscodeDigits = passCodeViewModel.getPassCodeLength()
199199
for (i in 0 until numberOfPasscodeDigits) {
200200
val txt = layoutInflater.inflate(R.layout.passcode_edit_text, layoutCode, false) as EditText
201201
layoutCode.addView(txt)
@@ -484,6 +484,7 @@ class PassCodeActivity : AppCompatActivity(), NumberKeyboardListener, EnableBiom
484484
// NOTE: PREFERENCE_SET_PASSCODE must have the same value as settings_security.xml-->android:key for passcode preference
485485
const val PREFERENCE_SET_PASSCODE = "set_pincode"
486486
const val PREFERENCE_PASSCODE = "PrefPinCode"
487+
const val PREFERENCE_PASSCODE_LENGTH = "PrefPinCodeLength"
487488
const val PREFERENCE_MIGRATION_REQUIRED = "PrefMigrationRequired"
488489

489490
// NOTE: This is required to read the legacy pin code format

opencloudApp/src/main/java/eu/opencloud/android/presentation/security/passcode/PassCodeViewModel.kt

Lines changed: 45 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import androidx.lifecycle.ViewModel
2828
import eu.opencloud.android.R
2929
import eu.opencloud.android.data.providers.SharedPreferencesProvider
3030
import eu.opencloud.android.domain.utils.Event
31+
import eu.opencloud.android.presentation.security.AppLockSecretHash
3132
import eu.opencloud.android.presentation.security.PREFERENCE_LAST_UNLOCK_ATTEMPT_TIMESTAMP
3233
import eu.opencloud.android.presentation.security.PREFERENCE_LAST_UNLOCK_TIMESTAMP
3334
import eu.opencloud.android.presentation.security.biometric.BiometricActivity
@@ -66,7 +67,7 @@ class PassCodeViewModel(
6667
private var confirmingPassCode = false
6768

6869
init {
69-
numberOfPasscodeDigits = (getPassCode()?.length ?: getNumberOfPassCodeDigits())
70+
numberOfPasscodeDigits = getPassCodeLength()
7071
}
7172

7273
fun onNumberClicked(number: Int) {
@@ -108,11 +109,11 @@ class PassCodeViewModel(
108109
}
109110

110111
private fun actionCheckPasscode() {
111-
if (checkPassCodeIsValid(passcodeString.toString())) {
112+
val enteredPasscode = passcodeString.toString()
113+
if (checkPassCodeIsValid(enteredPasscode)) {
112114
// pass code accepted in request, user is allowed to access the app
113115
setLastUnlockTimestamp()
114-
val passCode = getPassCode()
115-
if (passCode != null && passCode.length < getNumberOfPassCodeDigits()) {
116+
if (getPassCodeLength() < getNumberOfPassCodeDigits()) {
116117
setMigrationRequired(true)
117118
removePassCode()
118119
_status.postValue(Status(PasscodeAction.CHECK, PasscodeType.MIGRATION))
@@ -150,24 +151,40 @@ class PassCodeViewModel(
150151
}
151152
}
152153

153-
fun getPassCode() = preferencesProvider.getString(PassCodeActivity.PREFERENCE_PASSCODE, loadPinFromOldFormatIfPossible())
154+
fun getPassCode(): String? =
155+
getStoredPassCode()?.takeUnless(AppLockSecretHash::isHash)
156+
157+
fun getPassCodeLength(): Int {
158+
val storedPassCode = getStoredPassCode()
159+
return when {
160+
storedPassCode == null -> getNumberOfPassCodeDigits()
161+
AppLockSecretHash.isHash(storedPassCode) ->
162+
preferencesProvider.getInt(PassCodeActivity.PREFERENCE_PASSCODE_LENGTH, getNumberOfPassCodeDigits())
163+
else -> storedPassCode.length
164+
}
165+
}
154166

155167
fun setPassCode() {
156-
preferencesProvider.putString(PassCodeActivity.PREFERENCE_PASSCODE, firstPasscode)
157-
preferencesProvider.putBoolean(PassCodeActivity.PREFERENCE_SET_PASSCODE, true)
158-
numberOfPasscodeDigits = (getPassCode()?.length ?: getNumberOfPassCodeDigits())
168+
storePassCode(firstPasscode)
169+
numberOfPasscodeDigits = getPassCodeLength()
159170
}
160171

161172
fun removePassCode() {
162173
preferencesProvider.removePreference(PassCodeActivity.PREFERENCE_PASSCODE)
174+
preferencesProvider.removePreference(PassCodeActivity.PREFERENCE_PASSCODE_LENGTH)
163175
preferencesProvider.putBoolean(PassCodeActivity.PREFERENCE_SET_PASSCODE, false)
164-
numberOfPasscodeDigits = (getPassCode()?.length ?: getNumberOfPassCodeDigits())
176+
numberOfPasscodeDigits = getPassCodeLength()
165177
}
166178

167179
fun checkPassCodeIsValid(passcode: String): Boolean {
168-
val passCodeString = getPassCode()
169-
if (passCodeString.isNullOrEmpty()) return false
170-
return passcode == passCodeString
180+
val storedPassCode = getStoredPassCode()
181+
if (storedPassCode.isNullOrEmpty()) return false
182+
183+
val isValid = AppLockSecretHash.verify(passcode, storedPassCode)
184+
if (isValid && !AppLockSecretHash.isHash(storedPassCode)) {
185+
storePassCode(passcode)
186+
}
187+
return isValid
171188
}
172189

173190
fun getNumberOfPassCodeDigits(): Int {
@@ -230,6 +247,22 @@ class PassCodeViewModel(
230247
return pinString.ifEmpty { null }
231248
}
232249

250+
private fun getStoredPassCode(): String? =
251+
preferencesProvider.getString(PassCodeActivity.PREFERENCE_PASSCODE, loadPinFromOldFormatIfPossible())
252+
253+
private fun storePassCode(passcode: String) {
254+
preferencesProvider.putString(PassCodeActivity.PREFERENCE_PASSCODE, AppLockSecretHash.hash(passcode))
255+
preferencesProvider.putInt(PassCodeActivity.PREFERENCE_PASSCODE_LENGTH, passcode.length)
256+
preferencesProvider.putBoolean(PassCodeActivity.PREFERENCE_SET_PASSCODE, true)
257+
removeLegacyPinFormat()
258+
}
259+
260+
private fun removeLegacyPinFormat() {
261+
for (i in 1..4) {
262+
preferencesProvider.removePreference(PassCodeActivity.PREFERENCE_PASSCODE_D + i)
263+
}
264+
}
265+
233266
fun setBiometricsState(enabled: Boolean) {
234267
preferencesProvider.putBoolean(BiometricActivity.PREFERENCE_SET_BIOMETRIC, enabled)
235268
}

opencloudApp/src/main/java/eu/opencloud/android/presentation/security/pattern/PatternActivity.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,7 @@ class PatternActivity : AppCompatActivity(), EnableBiometrics {
183183
}
184184

185185
override fun onProgress(list: List<Dot>) {
186-
Timber.d("Pattern Progress %s", PatternLockUtils.patternToString(binding.patternLockView, list))
186+
Timber.d("Pattern drawing in progress")
187187
}
188188

189189
override fun onComplete(list: List<Dot>) {
@@ -205,7 +205,7 @@ class PatternActivity : AppCompatActivity(), EnableBiometrics {
205205
} else {
206206
patternValue = PatternLockUtils.patternToString(binding.patternLockView, list)
207207
}
208-
Timber.d("Pattern %s", PatternLockUtils.patternToString(binding.patternLockView, list))
208+
Timber.d("Pattern drawing completed")
209209
processPattern()
210210
}
211211

opencloudApp/src/main/java/eu/opencloud/android/presentation/security/pattern/PatternViewModel.kt

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,15 @@ package eu.opencloud.android.presentation.security.pattern
2222

2323
import androidx.lifecycle.ViewModel
2424
import eu.opencloud.android.data.providers.SharedPreferencesProvider
25+
import eu.opencloud.android.presentation.security.AppLockSecretHash
2526
import eu.opencloud.android.presentation.security.biometric.BiometricActivity
2627

2728
class PatternViewModel(
2829
private val preferencesProvider: SharedPreferencesProvider
2930
) : ViewModel() {
3031

3132
fun setPattern(pattern: String) {
32-
preferencesProvider.putString(PatternActivity.PREFERENCE_PATTERN, pattern)
33+
preferencesProvider.putString(PatternActivity.PREFERENCE_PATTERN, AppLockSecretHash.hash(pattern))
3334
preferencesProvider.putBoolean(PatternActivity.PREFERENCE_SET_PATTERN, true)
3435
}
3536

@@ -39,8 +40,16 @@ class PatternViewModel(
3940
}
4041

4142
fun checkPatternIsValid(patternValue: String?): Boolean {
43+
if (patternValue == null) return false
44+
4245
val savedPattern = preferencesProvider.getString(PatternActivity.PREFERENCE_PATTERN, null)
43-
return savedPattern != null && savedPattern == patternValue
46+
if (savedPattern.isNullOrEmpty()) return false
47+
48+
val isValid = AppLockSecretHash.verify(patternValue, savedPattern)
49+
if (isValid && !AppLockSecretHash.isHash(savedPattern)) {
50+
setPattern(patternValue)
51+
}
52+
return isValid
4453
}
4554

4655
fun setBiometricsState(enabled: Boolean) {
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
/**
2+
* openCloud Android client application
3+
*
4+
* Copyright (C) 2026 OpenCloud GmbH.
5+
*
6+
* This program is free software: you can redistribute it and/or modify
7+
* it under the terms of the GNU General Public License version 2,
8+
* as published by the Free Software Foundation.
9+
*
10+
* This program is distributed in the hope that it will be useful,
11+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
* GNU General Public License for more details.
14+
*
15+
* You should have received a copy of the GNU General Public License
16+
* along with this program. If not, see <http://www.gnu.org/licenses/>.
17+
*/
18+
19+
package eu.opencloud.android.presentation.viewmodels.security
20+
21+
import eu.opencloud.android.presentation.security.AppLockSecretHash
22+
import org.junit.Assert.assertFalse
23+
import org.junit.Assert.assertNotEquals
24+
import org.junit.Assert.assertTrue
25+
import org.junit.Test
26+
27+
class AppLockSecretHashTest {
28+
29+
@Test
30+
fun `hash hides secret and validates matching secret`() {
31+
val secret = "1234"
32+
33+
val storedSecret = AppLockSecretHash.hash(secret)
34+
35+
assertNotEquals(secret, storedSecret)
36+
assertTrue(AppLockSecretHash.isHash(storedSecret))
37+
assertTrue(AppLockSecretHash.verify(secret, storedSecret))
38+
assertFalse(AppLockSecretHash.verify("4321", storedSecret))
39+
}
40+
41+
@Test
42+
fun `verify still accepts legacy plaintext secret`() {
43+
assertTrue(AppLockSecretHash.verify("1234", "1234"))
44+
assertFalse(AppLockSecretHash.verify("4321", "1234"))
45+
}
46+
}

opencloudApp/src/test/java/eu/opencloud/android/presentation/viewmodels/security/BiometricViewModelTest.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ class BiometricViewModelTest : ViewModelTest() {
107107

108108
verify(exactly = 1) {
109109
preferencesProvider.removePreference(PassCodeActivity.PREFERENCE_PASSCODE)
110+
preferencesProvider.removePreference(PassCodeActivity.PREFERENCE_PASSCODE_LENGTH)
110111
preferencesProvider.putBoolean(PassCodeActivity.PREFERENCE_SET_PASSCODE, false)
111112
}
112113
}

0 commit comments

Comments
 (0)