diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/PasswordStrengthEstimator.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/PasswordStrengthEstimator.kt new file mode 100644 index 0000000000..62322781d5 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/PasswordStrengthEstimator.kt @@ -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 = 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, +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RustPasswordStrengthEstimator.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RustPasswordStrengthEstimator.kt new file mode 100644 index 0000000000..13cba9d759 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RustPasswordStrengthEstimator.kt @@ -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): 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 +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/encryption/RustPasswordStrengthEstimatorTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/encryption/RustPasswordStrengthEstimatorTest.kt new file mode 100644 index 0000000000..affe8195d9 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/encryption/RustPasswordStrengthEstimatorTest.kt @@ -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) + } +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/encryption/FakePasswordStrengthEstimator.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/encryption/FakePasswordStrengthEstimator.kt new file mode 100644 index 0000000000..b74e021a2e --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/encryption/FakePasswordStrengthEstimator.kt @@ -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) -> PasswordStrengthEstimate = + { _, _ -> aPasswordStrengthEstimate() }, +) : PasswordStrengthEstimator { + override fun estimate(password: String, userInputs: List): 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, +)