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 @@ -59,11 +59,16 @@
/**
* Encode the given username and password to a MD5 [String].
*
* MD5 is not suitable for hashing secrets. The SDK no longer uses it to store password hashes,
* it only reads the values written by previous versions. It will be removed in a future major
* release.
*
* @param username The username of the user account.
* @param password The password of the user account.
* @return An encoded MD5 [String].
*/
@Deprecated("MD5 is cryptographically weak and must not be used to hash secrets.")
fun md5(username: String, password: String): String {

Check warning on line 71 in core/src/main/java/org/hisp/dhis/android/core/arch/helpers/UserHelper.kt

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Do not forget to remove this deprecated code someday.

See more on https://sonarcloud.io/project/issues?id=dhis2_dhis2-android-sdk&issues=AaA00Cue9fB2OC_gmwES&open=AaA00Cue9fB2OC_gmwES&pullRequest=2703
return try {
val credentials = usernameAndPassword(username, password)
val md = MessageDigest.getInstance("MD5")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@
package org.hisp.dhis.android.core.arch.storage.internal

import net.openid.appauth.AuthState
import org.hisp.dhis.android.core.arch.helpers.UserHelper
import org.hisp.dhis.android.core.common.AuthorizationType
import org.hisp.dhis.android.core.user.oauth2.OAuth2State

Expand All @@ -45,8 +44,27 @@ internal data class Credentials(
val passwordOrPin: String?
get() = password ?: pin

fun getHash(): String? {
return passwordOrPin?.let { UserHelper.md5(username, it) }
/**
* Derives a new hash for the stored secret, to be persisted in the AuthenticatedUser table.
* The result is salted and therefore different on every call, so it must never be compared:
* use [matches] to verify a secret against an already stored hash.
*/
fun newPasswordHash(): String? {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would avoid adding the new word in a method name because after some versions it won't be new anymore.
Why don't keep the same name as before? This method is replacing it.

return passwordOrPin?.let { PasswordHasher.hash(it) }
}

/**
* Verifies the stored secret against [storedHash], which may be in either the current or the
* legacy MD5 format. Accounts without a secret (token based accounts with no PIN) are expected
* to have no stored hash either.
*/
fun matches(storedHash: String?): HashVerification {
val secret = passwordOrPin
return when {
secret == null && storedHash == null -> HashVerification.Match(needsUpgrade = false)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In case the existing hash was null, I am not sure if we should let the user in by providing a null value. If the hash is null, it means that the user didn't set up a PIN code.

secret == null || storedHash == null -> HashVerification.Mismatch
else -> PasswordHasher.verify(username, secret, storedHash)
}
}

override fun equals(other: Any?) =
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/*
* Copyright (c) 2004-2026, University of Oslo
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
* Neither the name of the HISP project nor the names of its contributors may
* be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
package org.hisp.dhis.android.core.arch.storage.internal

/**
* Outcome of verifying a secret against a stored password hash.
*/
internal sealed interface HashVerification {

data class Match(val needsUpgrade: Boolean) : HashVerification
data object Mismatch : HashVerification
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
/*
* Copyright (c) 2004-2026, University of Oslo
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
* Neither the name of the HISP project nor the names of its contributors may
* be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
package org.hisp.dhis.android.core.arch.storage.internal

import okio.ByteString.Companion.decodeBase64
import okio.ByteString.Companion.toByteString
import org.hisp.dhis.android.core.arch.helpers.UserHelper
import java.security.MessageDigest
import java.security.NoSuchAlgorithmException
import java.security.SecureRandom
import javax.crypto.SecretKeyFactory
import javax.crypto.spec.PBEKeySpec

/**
* Derives and verifies the password hash stored in the AuthenticatedUser table.
*
* Hashes are stored in a self-describing, PHC-like format so that the algorithm and its parameters
* travel with the value: `$pbkdf2-sha256$i=210000$<salt-base64>$<hash-base64>`.
*
* Values that do not start with the separator are legacy MD5 digests of `"username:secret"` written
* by previous SDK versions. They can still be verified, but they are never produced again: a
* successful legacy verification reports [HashVerification.Match.needsUpgrade], so the caller
* rewrites the row with a current hash. The same mechanism allows raising the iteration count later
* without invalidating anything already stored.
*/
internal object PasswordHasher {

private const val PHC_SEPARATOR = '$'
private const val ITERATIONS_PREFIX = "i="
private const val PHC_SEGMENTS = 5
private const val ALGORITHM_SEGMENT = 1
private const val ITERATIONS_SEGMENT = 2
private const val SALT_SEGMENT = 3
private const val HASH_SEGMENT = 4
private const val SALT_LENGTH_BYTES = 16
private const val KEY_LENGTH_BITS = 256

/**
* Cost factor applied to newly derived hashes.
*/
private const val ITERATIONS = 210_000

/**
* Supported PBKDF2 variants, declared from strongest to weakest: the order defines which stored
* hashes are considered outdated. PBKDF2WithHmacSHA256 is only available from API 26, so devices
* below that fall back to SHA-1, available since API 10.
*/
private enum class Pbkdf2Algorithm(
val id: String,
val jcaName: String,
val iterations: Int,
) {
SHA256("pbkdf2-sha256", "PBKDF2WithHmacSHA256", ITERATIONS),
SHA1("pbkdf2-sha1", "PBKDF2WithHmacSHA1", ITERATIONS),
;

fun isWeakerThan(other: Pbkdf2Algorithm) = ordinal > other.ordinal

companion object {
fun byId(id: String) = entries.firstOrNull { it.id == id }
}
}

private val currentAlgorithm: Pbkdf2Algorithm by lazy {
Pbkdf2Algorithm.entries.firstOrNull { it.isAvailable() }
?: throw AssertionError("No PBKDF2 implementation available")
}

/**
* Derives a new hash for the given secret using the current algorithm and parameters. Every call
* generates a fresh random salt, so the result is never the same twice: it is only valid for the
* write path, never for comparing.
*/
fun hash(secret: String): String {
val algorithm = currentAlgorithm
val salt = ByteArray(SALT_LENGTH_BYTES).also { SecureRandom().nextBytes(it) }
val derived = deriveKey(algorithm, secret, salt, algorithm.iterations)
return listOf(
"",
algorithm.id,
ITERATIONS_PREFIX + algorithm.iterations,
salt.base64(),
derived.base64(),
).joinToString(PHC_SEPARATOR.toString())
}

/**
* Verifies [secret] against [storedHash]. [username] is only used by the legacy MD5 path, which
* hashes `"username:secret"` instead of the secret alone.
*/
fun verify(username: String, secret: String, storedHash: String): HashVerification {
return if (storedHash.startsWith(PHC_SEPARATOR)) {
verifyPbkdf2(secret, storedHash)
} else {
verifyLegacyMd5(username, secret, storedHash)
}
}

@Suppress("ReturnCount")
private fun verifyPbkdf2(secret: String, storedHash: String): HashVerification {
val parsed = parse(storedHash) ?: return HashVerification.Mismatch
val candidate = try {
deriveKey(parsed.algorithm, secret, parsed.salt, parsed.iterations)
} catch (_: NoSuchAlgorithmException) {
// The hash was written on a device running a newer Android version. It cannot be
// verified here, so the account has to be authenticated online again.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We would have to check if this restriction is important when exporting/importing a DB from a newer Android version. In version 3.6 we should move to use a different password for DB encryption and then force the user the do an online login in case they want to synchronize. I think it won't be so important if we implement this workflow, but it might be important if we keep the current one.

return HashVerification.Mismatch
}

return if (MessageDigest.isEqual(candidate, parsed.hash)) {
HashVerification.Match(
needsUpgrade = parsed.algorithm.isWeakerThan(currentAlgorithm) ||
parsed.iterations < parsed.algorithm.iterations,
)
} else {
HashVerification.Mismatch
}
}

@Suppress("DEPRECATION")
private fun verifyLegacyMd5(username: String, secret: String, storedHash: String): HashVerification {
val candidate = UserHelper.md5(username, secret)
return if (MessageDigest.isEqual(candidate.encodeToByteArray(), storedHash.encodeToByteArray())) {
HashVerification.Match(needsUpgrade = true)
} else {
HashVerification.Mismatch
}
}

@Suppress("ReturnCount")
private fun parse(storedHash: String): ParsedHash? {
val segments = storedHash.split(PHC_SEPARATOR)
if (segments.size != PHC_SEGMENTS) return null

val algorithm = Pbkdf2Algorithm.byId(segments[ALGORITHM_SEGMENT]) ?: return null
val iterationsSegment = segments[ITERATIONS_SEGMENT]
if (!iterationsSegment.startsWith(ITERATIONS_PREFIX)) return null
val iterations = iterationsSegment.removePrefix(ITERATIONS_PREFIX)
.toIntOrNull()?.takeIf { it > 0 } ?: return null
val salt = segments[SALT_SEGMENT].decodeBase64()?.toByteArray() ?: return null
val hash = segments[HASH_SEGMENT].decodeBase64()?.toByteArray() ?: return null

return ParsedHash(algorithm, iterations, salt, hash)
}

private fun deriveKey(
algorithm: Pbkdf2Algorithm,
secret: String,
salt: ByteArray,
iterations: Int,
): ByteArray {
val spec = PBEKeySpec(secret.toCharArray(), salt, iterations, KEY_LENGTH_BITS)
return try {
SecretKeyFactory.getInstance(algorithm.jcaName).generateSecret(spec).encoded
} finally {
spec.clearPassword()
}
}

private fun Pbkdf2Algorithm.isAvailable(): Boolean {
return try {
SecretKeyFactory.getInstance(jcaName)
true
} catch (_: NoSuchAlgorithmException) {
false
}
}

private fun ByteArray.base64(): String = toByteString().base64()

private class ParsedHash(
val algorithm: Pbkdf2Algorithm,
val iterations: Int,
val salt: ByteArray,
val hash: ByteArray,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import org.hisp.dhis.android.core.arch.api.executors.internal.CoroutineAPICallEx
import org.hisp.dhis.android.core.arch.api.internal.ServerURLWrapper
import org.hisp.dhis.android.core.arch.storage.internal.Credentials
import org.hisp.dhis.android.core.arch.storage.internal.CredentialsSecureStore
import org.hisp.dhis.android.core.arch.storage.internal.HashVerification
import org.hisp.dhis.android.core.arch.storage.internal.UserIdInMemoryStore
import org.hisp.dhis.android.core.common.AuthorizationType
import org.hisp.dhis.android.core.configuration.internal.DatabaseConfigurationHelper
Expand Down Expand Up @@ -190,7 +191,7 @@ internal class LogInCall(
val existingUser = authenticatedUserStore.selectFirst()
val authenticatedUser = AuthenticatedUser.builder()
.user(user.uid())
.hash(credentials.getHash() ?: existingUser?.hash())
.hash(credentials.newPasswordHash() ?: existingUser?.hash())
.build()

authenticatedUserStore.updateOrInsertWhere(authenticatedUser)
Expand All @@ -215,14 +216,30 @@ internal class LogInCall(
}
val existingUser = authenticatedUserStore.selectFirst() ?: throw exceptions.noUserOfflineError()

if (credentials.getHash() != existingUser.hash()) {
throw wrongLocalCredentialsError(credentials)
when (val verification = credentials.matches(existingUser.hash())) {
is HashVerification.Mismatch -> throw wrongLocalCredentialsError(credentials)

is HashVerification.Match ->
if (verification.needsUpgrade) {
upgradeStoredHash(existingUser, credentials)
}
}
credentialsSecureStore.set(credentials)
userIdStore.set(existingUser.user()!!)
return userStore.selectByUid(existingUser.user()!!)!!
}

/**
* Rewrites a hash that was verified successfully but is stored in an outdated format, typically
* the legacy MD5 one. It happens transparently while the plaintext secret is still at hand, so
* the user is never asked to authenticate again.
*/
private suspend fun upgradeStoredHash(existingUser: AuthenticatedUser, credentials: Credentials) {
authenticatedUserStore.updateOrInsertWhere(
existingUser.toBuilder().hash(credentials.newPasswordHash()).build(),
)
}

@Suppress("TooGenericExceptionCaught")
private suspend fun importDB(serverUrl: String, credentials: Credentials): User {
try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,7 @@ internal class OAuth2HandlerImpl(
Result.Failure(logInExceptions.noAuthenticatedUserPersistedError())
} else {
authenticatedUserStore.updateOrInsertWhere(
existing.toBuilder().hash(updated.getHash()).build(),
existing.toBuilder().hash(updated.newPasswordHash()).build(),
)
Result.Success(Unit)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ internal class OpenIDConnectHandlerImpl(
Result.Failure(logInExceptions.noAuthenticatedUserPersistedError())
} else {
authenticatedUserStore.updateOrInsertWhere(
existing.toBuilder().hash(updated.getHash()).build(),
existing.toBuilder().hash(updated.newPasswordHash()).build(),
)
Result.Success(Unit)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import org.junit.runner.RunWith
import org.junit.runners.JUnit4

@RunWith(JUnit4::class)
@Suppress("DEPRECATION")
class UserHelperShould {
@Test
fun md5_evaluate_same_string() {
Expand Down
Loading