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:
Jenna Vassar
2026-07-28 03:30:06 -07:00
committed by GitHub
parent 0cd163a680
commit fe24aeb2ce
4 changed files with 184 additions and 0 deletions
@@ -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,
}
@@ -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
}
@@ -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)
}
}
@@ -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,
)