Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/**
* openCloud Android client application
*
* Copyright (C) 2026 ownCloud 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 <http://www.gnu.org/licenses/>.
*/

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()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -195,7 +195,7 @@ class PassCodeActivity : AppCompatActivity(), NumberKeyboardListener, EnableBiom

private fun inflatePasscodeTxtLine() {
val layoutCode = findViewById<LinearLayout>(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)
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -66,7 +67,7 @@ class PassCodeViewModel(
private var confirmingPassCode = false

init {
numberOfPasscodeDigits = (getPassCode()?.length ?: getNumberOfPassCodeDigits())
numberOfPasscodeDigits = getPassCodeLength()
}

fun onNumberClicked(number: Int) {
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ class PatternActivity : AppCompatActivity(), EnableBiometrics {
}

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

override fun onComplete(list: List<Dot>) {
Expand All @@ -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()
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,15 @@ 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(
private val preferencesProvider: SharedPreferencesProvider
) : 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)
}

Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/**
* openCloud Android client application
*
* Copyright (C) 2026 ownCloud 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 <http://www.gnu.org/licenses/>.
*/

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"))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand Down
Loading
Loading