mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-02 12:07:28 +00:00
Add a password strength estimator wrapping the SDK zxcvbn API (#7186)
* Add a password strength estimator wrapping the SDK zxcvbn API * Add tests for the password strength estimator SDK mapping --------- Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
+59
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.api.encryption
|
||||
|
||||
/**
|
||||
* Estimates the strength of a password or passphrase using the zxcvbn algorithm,
|
||||
* as exposed by the Rust SDK. Backed by pattern matching (dictionary words,
|
||||
* keyboard walks, repeats, dates, l33t speak, …) rather than naive per-character
|
||||
* entropy, so it agrees with the iOS client for cross-platform parity.
|
||||
*
|
||||
* App-scoped: obtain an instance via dependency injection rather than constructing one.
|
||||
*/
|
||||
interface PasswordStrengthEstimator {
|
||||
/**
|
||||
* Estimate the strength of [password].
|
||||
*
|
||||
* @param password the password or passphrase to evaluate.
|
||||
* @param userInputs optional context (e.g. user ID, display name, email) that the
|
||||
* estimator penalises when it appears inside the password, so personal information
|
||||
* does not inflate the score.
|
||||
*/
|
||||
fun estimate(password: String, userInputs: List<String> = emptyList()): PasswordStrengthEstimate
|
||||
}
|
||||
|
||||
/**
|
||||
* The result of a [PasswordStrengthEstimator.estimate] call.
|
||||
*/
|
||||
data class PasswordStrengthEstimate(
|
||||
/** Overall ranking, from [PasswordStrengthRanking.VeryWeak] to [PasswordStrengthRanking.VeryStrong]. */
|
||||
val ranking: PasswordStrengthRanking,
|
||||
/**
|
||||
* `log10` of the estimated number of guesses needed to crack the password. Grows without an
|
||||
* upper bound and is independent of any threshold configuration.
|
||||
*/
|
||||
val score: Double,
|
||||
/**
|
||||
* [score] normalized to `0.0..1.0` against the estimator's `VeryStrong` threshold
|
||||
* (`score / veryStrong`), suitable for driving a strength meter. Scores above the `VeryStrong`
|
||||
* threshold can exceed `1.0`, so clamp before use.
|
||||
*/
|
||||
val normalScore: Double,
|
||||
)
|
||||
|
||||
/**
|
||||
* A ranking of estimated password strength. Derived by the SDK from [PasswordStrengthEstimate.score]
|
||||
* against a fixed set of thresholds tuned for modern hardware.
|
||||
*/
|
||||
enum class PasswordStrengthRanking {
|
||||
VeryWeak,
|
||||
Weak,
|
||||
Fair,
|
||||
Strong,
|
||||
VeryStrong,
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.impl.encryption
|
||||
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.libraries.matrix.api.encryption.PasswordStrengthEstimate
|
||||
import io.element.android.libraries.matrix.api.encryption.PasswordStrengthEstimator
|
||||
import io.element.android.libraries.matrix.api.encryption.PasswordStrengthRanking
|
||||
import org.matrix.rustcomponents.sdk.PasswordStrengthEstimate as SdkPasswordStrengthEstimate
|
||||
import org.matrix.rustcomponents.sdk.PasswordStrengthEstimator as SdkPasswordStrengthEstimator
|
||||
import org.matrix.rustcomponents.sdk.PasswordStrengthRanking as SdkPasswordStrengthRanking
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class RustPasswordStrengthEstimator : PasswordStrengthEstimator {
|
||||
// The SDK ranking is derived from caller-supplied thresholds. We use the "modern hardware (2025)"
|
||||
// preset so Android and iOS rank passphrases identically. The zxcvbn estimator holds no per-password
|
||||
// state, so a single app-scoped instance is reused for every keystroke rather than reconstructed.
|
||||
private val estimator by lazy { SdkPasswordStrengthEstimator.withModernDefaults2025() }
|
||||
|
||||
override fun estimate(password: String, userInputs: List<String>): PasswordStrengthEstimate {
|
||||
return estimator.estimate(password, userInputs).toApiModel()
|
||||
}
|
||||
}
|
||||
|
||||
internal fun SdkPasswordStrengthEstimate.toApiModel() = PasswordStrengthEstimate(
|
||||
ranking = ranking.toApiModel(),
|
||||
score = score,
|
||||
normalScore = normalScore,
|
||||
)
|
||||
|
||||
internal fun SdkPasswordStrengthRanking.toApiModel() = when (this) {
|
||||
SdkPasswordStrengthRanking.VERY_WEAK -> PasswordStrengthRanking.VeryWeak
|
||||
SdkPasswordStrengthRanking.WEAK -> PasswordStrengthRanking.Weak
|
||||
SdkPasswordStrengthRanking.FAIR -> PasswordStrengthRanking.Fair
|
||||
SdkPasswordStrengthRanking.STRONG -> PasswordStrengthRanking.Strong
|
||||
SdkPasswordStrengthRanking.VERY_STRONG -> PasswordStrengthRanking.VeryStrong
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.impl.encryption
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.matrix.api.encryption.PasswordStrengthRanking
|
||||
import io.element.android.libraries.matrix.test.encryption.FakePasswordStrengthEstimator
|
||||
import io.element.android.libraries.matrix.test.encryption.aPasswordStrengthEstimate
|
||||
import org.junit.Test
|
||||
import org.matrix.rustcomponents.sdk.PasswordStrengthEstimate as SdkPasswordStrengthEstimate
|
||||
import org.matrix.rustcomponents.sdk.PasswordStrengthRanking as SdkPasswordStrengthRanking
|
||||
|
||||
class RustPasswordStrengthEstimatorTest {
|
||||
@Test
|
||||
fun `each SDK ranking maps to the matching api ranking`() {
|
||||
assertThat(SdkPasswordStrengthRanking.VERY_WEAK.toApiModel()).isEqualTo(PasswordStrengthRanking.VeryWeak)
|
||||
assertThat(SdkPasswordStrengthRanking.WEAK.toApiModel()).isEqualTo(PasswordStrengthRanking.Weak)
|
||||
assertThat(SdkPasswordStrengthRanking.FAIR.toApiModel()).isEqualTo(PasswordStrengthRanking.Fair)
|
||||
assertThat(SdkPasswordStrengthRanking.STRONG.toApiModel()).isEqualTo(PasswordStrengthRanking.Strong)
|
||||
assertThat(SdkPasswordStrengthRanking.VERY_STRONG.toApiModel()).isEqualTo(PasswordStrengthRanking.VeryStrong)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an SDK estimate forwards its ranking, score and normalScore to the api model`() {
|
||||
val sdkEstimate = SdkPasswordStrengthEstimate(
|
||||
ranking = SdkPasswordStrengthRanking.FAIR,
|
||||
guesses = 1_000_000uL,
|
||||
score = 6.0,
|
||||
normalScore = 0.35,
|
||||
feedback = null,
|
||||
)
|
||||
assertThat(sdkEstimate.toApiModel()).isEqualTo(
|
||||
aPasswordStrengthEstimate(
|
||||
ranking = PasswordStrengthRanking.Fair,
|
||||
score = 6.0,
|
||||
normalScore = 0.35,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the fake estimator returns its configured estimate`() {
|
||||
val estimate = aPasswordStrengthEstimate(ranking = PasswordStrengthRanking.Strong)
|
||||
val fake = FakePasswordStrengthEstimator { _, _ -> estimate }
|
||||
assertThat(fake.estimate("hunter2")).isEqualTo(estimate)
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.test.encryption
|
||||
|
||||
import io.element.android.libraries.matrix.api.encryption.PasswordStrengthEstimate
|
||||
import io.element.android.libraries.matrix.api.encryption.PasswordStrengthEstimator
|
||||
import io.element.android.libraries.matrix.api.encryption.PasswordStrengthRanking
|
||||
|
||||
class FakePasswordStrengthEstimator(
|
||||
private val estimateResult: (password: String, userInputs: List<String>) -> PasswordStrengthEstimate =
|
||||
{ _, _ -> aPasswordStrengthEstimate() },
|
||||
) : PasswordStrengthEstimator {
|
||||
override fun estimate(password: String, userInputs: List<String>): PasswordStrengthEstimate =
|
||||
estimateResult(password, userInputs)
|
||||
}
|
||||
|
||||
fun aPasswordStrengthEstimate(
|
||||
ranking: PasswordStrengthRanking = PasswordStrengthRanking.Strong,
|
||||
score: Double = 18.0,
|
||||
normalScore: Double = 0.7,
|
||||
) = PasswordStrengthEstimate(
|
||||
ranking = ranking,
|
||||
score = score,
|
||||
normalScore = normalScore,
|
||||
)
|
||||
Reference in New Issue
Block a user