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) <noreply@anthropic.com>
This commit is contained in:
Jenna Vassar
2026-06-02 13:33:18 -07:00
parent 748454f34d
commit 2931418018
7 changed files with 181 additions and 1 deletions
@@ -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
}
@@ -15,4 +15,5 @@ data class ElementWellKnown(
val brandColor: String?, val brandColor: String?,
val notificationSound: String?, val notificationSound: String?,
val identityProviderAppScheme: String?, val identityProviderAppScheme: String?,
val customRecoveryPassphraseRequirements: CustomRecoveryPassphraseRequirements?,
) )
@@ -15,7 +15,15 @@ import kotlinx.serialization.Serializable
* Example: * Example:
* <pre> * <pre>
* { * {
* "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
* }
* } * }
* </pre> * </pre>
* . * .
@@ -34,4 +42,12 @@ data class InternalElementWellKnown(
val notificationSound: String? = null, val notificationSound: String? = null,
@SerialName("idp_app_scheme") @SerialName("idp_app_scheme")
val identityProviderAppScheme: String? = null, 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,
) )
@@ -8,7 +8,12 @@
package io.element.android.libraries.wellknown.impl 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 io.element.android.libraries.wellknown.api.ElementWellKnown
import timber.log.Timber
private val loggerTag = LoggerTag("Wellknown")
internal fun InternalElementWellKnown.map() = ElementWellKnown( internal fun InternalElementWellKnown.map() = ElementWellKnown(
registrationHelperUrl = registrationHelperUrl, registrationHelperUrl = registrationHelperUrl,
@@ -17,4 +22,17 @@ internal fun InternalElementWellKnown.map() = ElementWellKnown(
brandColor = brandColor, brandColor = brandColor,
notificationSound = notificationSound, notificationSound = notificationSound,
identityProviderAppScheme = identityProviderAppScheme, 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)
}
@@ -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()
}
}
@@ -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.AN_EXCEPTION
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.sessionstorage.test.InMemoryCacheStore 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.ElementWellKnown
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
import io.element.android.services.toolbox.api.systemclock.SystemClock import io.element.android.services.toolbox.api.systemclock.SystemClock
@@ -51,6 +52,7 @@ class DefaultSessionWellknownRetrieverTest {
brandColor = null, brandColor = null,
notificationSound = null, notificationSound = null,
identityProviderAppScheme = null, identityProviderAppScheme = null,
customRecoveryPassphraseRequirements = null,
) )
) )
) )
@@ -76,6 +78,7 @@ class DefaultSessionWellknownRetrieverTest {
brandColor = "#FF0000", brandColor = "#FF0000",
notificationSound = "a_notification_sound.flac", notificationSound = "a_notification_sound.flac",
identityProviderAppScheme = "an_app_scheme", identityProviderAppScheme = "an_app_scheme",
customRecoveryPassphraseRequirements = null,
) )
) )
) )
@@ -105,11 +108,86 @@ class DefaultSessionWellknownRetrieverTest {
brandColor = null, brandColor = null,
notificationSound = null, notificationSound = null,
identityProviderAppScheme = 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 @Test
fun `get element wellknown json error`() = runTest { fun `get element wellknown json error`() = runTest {
val sut = createDefaultSessionWellknownRetriever( val sut = createDefaultSessionWellknownRetriever(
@@ -157,6 +235,7 @@ class DefaultSessionWellknownRetrieverTest {
brandColor = "#FF0000", brandColor = "#FF0000",
notificationSound = "a_notification_sound.flac", notificationSound = "a_notification_sound.flac",
identityProviderAppScheme = "an_app_scheme", identityProviderAppScheme = "an_app_scheme",
customRecoveryPassphraseRequirements = null,
) )
) )
) )
@@ -210,6 +289,7 @@ class DefaultSessionWellknownRetrieverTest {
brandColor = "#FF0000", brandColor = "#FF0000",
notificationSound = "a_notification_sound.flac", notificationSound = "a_notification_sound.flac",
identityProviderAppScheme = "an_app_scheme", identityProviderAppScheme = "an_app_scheme",
customRecoveryPassphraseRequirements = null,
) )
) )
) )
@@ -8,6 +8,7 @@
package io.element.android.features.wellknown.test package io.element.android.features.wellknown.test
import io.element.android.libraries.wellknown.api.CustomRecoveryPassphraseRequirements
import io.element.android.libraries.wellknown.api.ElementWellKnown import io.element.android.libraries.wellknown.api.ElementWellKnown
fun anElementWellKnown( fun anElementWellKnown(
@@ -17,6 +18,7 @@ fun anElementWellKnown(
brandColor: String? = null, brandColor: String? = null,
notificationSound: String? = null, notificationSound: String? = null,
identityProviderAppScheme: String? = null, identityProviderAppScheme: String? = null,
customRecoveryPassphraseRequirements: CustomRecoveryPassphraseRequirements? = null,
) = ElementWellKnown( ) = ElementWellKnown(
registrationHelperUrl = registrationHelperUrl, registrationHelperUrl = registrationHelperUrl,
enforceElementPro = enforceElementPro, enforceElementPro = enforceElementPro,
@@ -24,4 +26,11 @@ fun anElementWellKnown(
brandColor = brandColor, brandColor = brandColor,
notificationSound = notificationSound, notificationSound = notificationSound,
identityProviderAppScheme = identityProviderAppScheme, identityProviderAppScheme = identityProviderAppScheme,
customRecoveryPassphraseRequirements = customRecoveryPassphraseRequirements,
)
fun aCustomRecoveryPassphraseRequirements(
minCharacterCount: Int = 8,
) = CustomRecoveryPassphraseRequirements(
minCharacterCount = minCharacterCount,
) )