From 2931418018d1f6b15cf542361f412ae2e2d74cf8 Mon Sep 17 00:00:00 2001 From: Jenna Vassar Date: Tue, 2 Jun 2026 13:33:18 -0700 Subject: [PATCH] Parse custom_recovery_passphrase_settings from Element .well-known Add CustomRecoveryPassphraseRequirements to ElementWellKnown and decode the custom_recovery_passphrase_settings block (currently just min_character_count) from the homeserver's element.json. Invalid specs (missing or non-positive min_character_count) are logged and mapped to null so the setup flow falls back to the generated-key path. Field names match the ESS well-known schema (element-enterprise#261). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../CustomRecoveryPassphraseRequirements.kt | 23 ++++++ .../wellknown/api/ElementWellKnown.kt | 1 + .../impl/InternalElementWellKnown.kt | 18 ++++- .../libraries/wellknown/impl/Mapper.kt | 18 +++++ ...ustomRecoveryPassphraseRequirementsTest.kt | 33 ++++++++ .../DefaultSessionWellknownRetrieverTest.kt | 80 +++++++++++++++++++ .../features/wellknown/test/Fixtures.kt | 9 +++ 7 files changed, 181 insertions(+), 1 deletion(-) create mode 100644 libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/CustomRecoveryPassphraseRequirements.kt create mode 100644 libraries/wellknown/impl/src/test/kotlin/io/element/android/libraries/wellknown/api/CustomRecoveryPassphraseRequirementsTest.kt diff --git a/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/CustomRecoveryPassphraseRequirements.kt b/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/CustomRecoveryPassphraseRequirements.kt new file mode 100644 index 0000000000..dc6241b0a9 --- /dev/null +++ b/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/CustomRecoveryPassphraseRequirements.kt @@ -0,0 +1,23 @@ +/* + * 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.wellknown.api + +/** + * Server-driven requirements for a user-chosen recovery passphrase. Today the only rule + * is a minimum character count; additional rules can be added here as the well-known + * schema (`custom_recovery_passphrase_settings`) grows. + */ +data class CustomRecoveryPassphraseRequirements( + val minCharacterCount: Int, +) { + /** True when [input] meets every active rule. */ + fun isSatisfiedBy(input: String): Boolean = isSatisfiedBy(input.length) + + /** True when an input of [length] characters meets every active rule. */ + fun isSatisfiedBy(length: Int): Boolean = length >= minCharacterCount +} diff --git a/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/ElementWellKnown.kt b/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/ElementWellKnown.kt index 4c1c476c7a..ac050ba94a 100644 --- a/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/ElementWellKnown.kt +++ b/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/ElementWellKnown.kt @@ -15,4 +15,5 @@ data class ElementWellKnown( val brandColor: String?, val notificationSound: String?, val identityProviderAppScheme: String?, + val customRecoveryPassphraseRequirements: CustomRecoveryPassphraseRequirements?, ) diff --git a/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/InternalElementWellKnown.kt b/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/InternalElementWellKnown.kt index 2e2b5de16f..803e498eda 100644 --- a/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/InternalElementWellKnown.kt +++ b/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/InternalElementWellKnown.kt @@ -15,7 +15,15 @@ import kotlinx.serialization.Serializable * Example: *
  * {
- *     "registration_helper_url": "https://element.io"
+ *     "registration_helper_url": "https://element.io",
+ *     "enforce_element_pro": true,
+ *     "rageshake_url": "https://example.org/rageshake",
+ *     "brand_color": "#FF0000",
+ *     "notification_sound": "ring.flac",
+ *     "idp_app_scheme": "io.element.app",
+ *     "custom_recovery_passphrase_settings": {
+ *         "min_character_count": 8
+ *     }
  * }
  * 
* . @@ -34,4 +42,12 @@ data class InternalElementWellKnown( val notificationSound: String? = null, @SerialName("idp_app_scheme") val identityProviderAppScheme: String? = null, + @SerialName("custom_recovery_passphrase_settings") + val customRecoveryPassphraseRequirements: InternalCustomRecoveryPassphraseRequirements? = null, +) + +@Serializable +data class InternalCustomRecoveryPassphraseRequirements( + @SerialName("min_character_count") + val minCharacterCount: Int? = null, ) diff --git a/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/Mapper.kt b/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/Mapper.kt index 41ed54d7db..61899578d7 100644 --- a/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/Mapper.kt +++ b/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/Mapper.kt @@ -8,7 +8,12 @@ package io.element.android.libraries.wellknown.impl +import io.element.android.libraries.core.log.logger.LoggerTag +import io.element.android.libraries.wellknown.api.CustomRecoveryPassphraseRequirements import io.element.android.libraries.wellknown.api.ElementWellKnown +import timber.log.Timber + +private val loggerTag = LoggerTag("Wellknown") internal fun InternalElementWellKnown.map() = ElementWellKnown( registrationHelperUrl = registrationHelperUrl, @@ -17,4 +22,17 @@ internal fun InternalElementWellKnown.map() = ElementWellKnown( brandColor = brandColor, notificationSound = notificationSound, identityProviderAppScheme = identityProviderAppScheme, + customRecoveryPassphraseRequirements = customRecoveryPassphraseRequirements?.toPublic(), ) + +private fun InternalCustomRecoveryPassphraseRequirements.toPublic(): CustomRecoveryPassphraseRequirements? { + val min = minCharacterCount ?: run { + Timber.tag(loggerTag.value).w("custom_recovery_passphrase_settings missing min_character_count; ignoring spec") + return null + } + if (min <= 0) { + Timber.tag(loggerTag.value).w("custom_recovery_passphrase_settings.min_character_count must be > 0; ignoring spec") + return null + } + return CustomRecoveryPassphraseRequirements(minCharacterCount = min) +} diff --git a/libraries/wellknown/impl/src/test/kotlin/io/element/android/libraries/wellknown/api/CustomRecoveryPassphraseRequirementsTest.kt b/libraries/wellknown/impl/src/test/kotlin/io/element/android/libraries/wellknown/api/CustomRecoveryPassphraseRequirementsTest.kt new file mode 100644 index 0000000000..c8f10fe2ca --- /dev/null +++ b/libraries/wellknown/impl/src/test/kotlin/io/element/android/libraries/wellknown/api/CustomRecoveryPassphraseRequirementsTest.kt @@ -0,0 +1,33 @@ +/* + * 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.wellknown.api + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class CustomRecoveryPassphraseRequirementsTest { + @Test + fun `empty input never satisfies a positive minimum`() { + assertThat(CustomRecoveryPassphraseRequirements(minCharacterCount = 1).isSatisfiedBy("")).isFalse() + } + + @Test + fun `input shorter than minimum is not satisfied`() { + assertThat(CustomRecoveryPassphraseRequirements(minCharacterCount = 8).isSatisfiedBy("abc")).isFalse() + } + + @Test + fun `input exactly at minimum is satisfied`() { + assertThat(CustomRecoveryPassphraseRequirements(minCharacterCount = 8).isSatisfiedBy("abcdefgh")).isTrue() + } + + @Test + fun `input longer than minimum is satisfied`() { + assertThat(CustomRecoveryPassphraseRequirements(minCharacterCount = 4).isSatisfiedBy("abcdefgh")).isTrue() + } +} diff --git a/libraries/wellknown/impl/src/test/kotlin/io/element/android/libraries/wellknown/impl/DefaultSessionWellknownRetrieverTest.kt b/libraries/wellknown/impl/src/test/kotlin/io/element/android/libraries/wellknown/impl/DefaultSessionWellknownRetrieverTest.kt index cca40e283f..68d4d818c2 100644 --- a/libraries/wellknown/impl/src/test/kotlin/io/element/android/libraries/wellknown/impl/DefaultSessionWellknownRetrieverTest.kt +++ b/libraries/wellknown/impl/src/test/kotlin/io/element/android/libraries/wellknown/impl/DefaultSessionWellknownRetrieverTest.kt @@ -19,6 +19,7 @@ import io.element.android.libraries.cachestore.api.CacheStore import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.sessionstorage.test.InMemoryCacheStore +import io.element.android.libraries.wellknown.api.CustomRecoveryPassphraseRequirements import io.element.android.libraries.wellknown.api.ElementWellKnown import io.element.android.libraries.wellknown.api.WellknownRetrieverResult import io.element.android.services.toolbox.api.systemclock.SystemClock @@ -51,6 +52,7 @@ class DefaultSessionWellknownRetrieverTest { brandColor = null, notificationSound = null, identityProviderAppScheme = null, + customRecoveryPassphraseRequirements = null, ) ) ) @@ -76,6 +78,7 @@ class DefaultSessionWellknownRetrieverTest { brandColor = "#FF0000", notificationSound = "a_notification_sound.flac", identityProviderAppScheme = "an_app_scheme", + customRecoveryPassphraseRequirements = null, ) ) ) @@ -105,11 +108,86 @@ class DefaultSessionWellknownRetrieverTest { brandColor = null, notificationSound = null, identityProviderAppScheme = null, + customRecoveryPassphraseRequirements = null, ) ) ) } + @Test + fun `get element wellknown with custom recovery passphrase requirements`() = runTest { + val sut = createDefaultSessionWellknownRetriever( + getUrlLambda = { + Result.success( + """{ + "custom_recovery_passphrase_settings": { + "min_character_count": 8 + } + }""".trimIndent().toByteArray() + ) + }, + ) + assertThat(sut.getElementWellKnown()).isEqualTo( + WellknownRetrieverResult.Success( + anElementWellKnown( + customRecoveryPassphraseRequirements = CustomRecoveryPassphraseRequirements(minCharacterCount = 8) + ) + ) + ) + } + + @Test + fun `get element wellknown with custom recovery passphrase requirements missing min character count maps to null`() = runTest { + val sut = createDefaultSessionWellknownRetriever( + getUrlLambda = { + Result.success( + """{ + "custom_recovery_passphrase_settings": {} + }""".trimIndent().toByteArray() + ) + }, + ) + assertThat(sut.getElementWellKnown()).isEqualTo( + WellknownRetrieverResult.Success(anElementWellKnown()) + ) + } + + @Test + fun `get element wellknown with non-positive min character count maps to null`() = runTest { + val sut = createDefaultSessionWellknownRetriever( + getUrlLambda = { + Result.success( + """{ + "custom_recovery_passphrase_settings": { + "min_character_count": 0 + } + }""".trimIndent().toByteArray() + ) + }, + ) + assertThat(sut.getElementWellKnown()).isEqualTo( + WellknownRetrieverResult.Success(anElementWellKnown()) + ) + } + + @Test + fun `get element wellknown with negative min character count maps to null`() = runTest { + val sut = createDefaultSessionWellknownRetriever( + getUrlLambda = { + Result.success( + """{ + "custom_recovery_passphrase_settings": { + "min_character_count": -5 + } + }""".trimIndent().toByteArray() + ) + }, + ) + assertThat(sut.getElementWellKnown()).isEqualTo( + WellknownRetrieverResult.Success(anElementWellKnown()) + ) + } + @Test fun `get element wellknown json error`() = runTest { val sut = createDefaultSessionWellknownRetriever( @@ -157,6 +235,7 @@ class DefaultSessionWellknownRetrieverTest { brandColor = "#FF0000", notificationSound = "a_notification_sound.flac", identityProviderAppScheme = "an_app_scheme", + customRecoveryPassphraseRequirements = null, ) ) ) @@ -210,6 +289,7 @@ class DefaultSessionWellknownRetrieverTest { brandColor = "#FF0000", notificationSound = "a_notification_sound.flac", identityProviderAppScheme = "an_app_scheme", + customRecoveryPassphraseRequirements = null, ) ) ) diff --git a/libraries/wellknown/test/src/main/kotlin/io/element/android/features/wellknown/test/Fixtures.kt b/libraries/wellknown/test/src/main/kotlin/io/element/android/features/wellknown/test/Fixtures.kt index ae7d1c629c..1ed69207ba 100644 --- a/libraries/wellknown/test/src/main/kotlin/io/element/android/features/wellknown/test/Fixtures.kt +++ b/libraries/wellknown/test/src/main/kotlin/io/element/android/features/wellknown/test/Fixtures.kt @@ -8,6 +8,7 @@ package io.element.android.features.wellknown.test +import io.element.android.libraries.wellknown.api.CustomRecoveryPassphraseRequirements import io.element.android.libraries.wellknown.api.ElementWellKnown fun anElementWellKnown( @@ -17,6 +18,7 @@ fun anElementWellKnown( brandColor: String? = null, notificationSound: String? = null, identityProviderAppScheme: String? = null, + customRecoveryPassphraseRequirements: CustomRecoveryPassphraseRequirements? = null, ) = ElementWellKnown( registrationHelperUrl = registrationHelperUrl, enforceElementPro = enforceElementPro, @@ -24,4 +26,11 @@ fun anElementWellKnown( brandColor = brandColor, notificationSound = notificationSound, identityProviderAppScheme = identityProviderAppScheme, + customRecoveryPassphraseRequirements = customRecoveryPassphraseRequirements, +) + +fun aCustomRecoveryPassphraseRequirements( + minCharacterCount: Int = 8, +) = CustomRecoveryPassphraseRequirements( + minCharacterCount = minCharacterCount, )