diff --git a/app/src/androidTest/kotlin/io/flamenet/messenger/app/ChatFlowTest.kt b/app/src/androidTest/kotlin/io/flamenet/messenger/app/ChatFlowTest.kt index 170e940..0c6cc90 100644 --- a/app/src/androidTest/kotlin/io/flamenet/messenger/app/ChatFlowTest.kt +++ b/app/src/androidTest/kotlin/io/flamenet/messenger/app/ChatFlowTest.kt @@ -10,9 +10,9 @@ import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextClearance import androidx.compose.ui.test.performTextInput import androidx.compose.ui.test.printToString -import androidx.test.rule.GrantPermissionRule -import androidx.test.platform.app.InstrumentationRegistry import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.rule.GrantPermissionRule import org.junit.Assume.assumeTrue import org.junit.Before import org.junit.Rule @@ -21,60 +21,38 @@ import org.junit.runner.RunWith /** * A conversation with somebody who is not this app. - * - * The correspondent must be a different program. Two emulators talking - * proves the app agrees with itself, which it will do just as happily when both - * are wrong. `PEER_HANDLE` names an account created by the `:interop` peer - * tool, built from `:engine` and `:client` and driven from a shell: - * - * ./gradlew :interop:peer -Pargs="peer1.json create https://relay1.flamenet.io peerjvmone" - * ./gradlew :app:connectedDebugAndroidTest \ - * -Pandroid.testInstrumentationRunnerArguments.class=…ChatFlowTest - * ./gradlew :interop:peer -Pargs="peer1.json poll" - * - * Skipped, not failed, when the relay is unreachable. */ @RunWith(AndroidJUnit4::class) class ChatFlowTest { - - /** + /** * Granted BEFORE the activity launches, and that ordering is the point. - * - * `MainActivity.onCreate` asks for POST_NOTIFICATIONS before it calls - * `setContent`. The permission dialog is a separate activity and takes - * focus, so the Compose rule attaches to a window that has no hierarchy - * yet and every test dies with "No compose hierarchies found in the app". - * - * `order = 0` puts this outside the Compose rule, so the grant happens - * first and no dialog ever appears. */ @get:Rule(order = 0) - val permissions: GrantPermissionRule = GrantPermissionRule.grant( - android.Manifest.permission.POST_NOTIFICATIONS, - ) + val permissions: GrantPermissionRule = + GrantPermissionRule.grant( + android.Manifest.permission.POST_NOTIFICATIONS, + ) @get:Rule(order = 1) val compose = createAndroidComposeRule() /** * Which relay to drive. - * - * Defaults to the production relay, which is what this has always used, - * so an unqualified run still does what it did. Pass `-e relay ` to - * point it somewhere else, and a LOCAL one is what you want while - * developing: these tests create real accounts, and doing that on - * production for every run is a cost nobody chose. - * - * ./gradlew :app:connectedDebugAndroidTest \ - * -Pandroid.testInstrumentationRunnerArguments.relay=10.0.2.2:18810 */ private val relay: String - get() = InstrumentationRegistry.getArguments().getString("relay") - ?: "relay1.flamenet.io" + get() = + InstrumentationRegistry.getArguments().getString("relay") + ?: "relay1.flamenet.io" /** A local relay is plain HTTP; anything else is not. */ - private val relayScheme: String get() = if (relay.startsWith("10.0.2.2") || - relay.startsWith("127.0.0.1") || relay.startsWith("localhost")) "http" else "https" + private val relayScheme: String get() = + if (relay.startsWith("10.0.2.2") || + relay.startsWith("127.0.0.1") || relay.startsWith("localhost") + ) { + "http" + } else { + "https" + } private val peerHandle = "peerjvmone" private lateinit var handle: String @@ -118,20 +96,12 @@ class ChatFlowTest { android.util.Log.i("fnchat", "sent from $handle to $peerHandle: $text") - // The return trip. Requires the peer running `autoreply`; without it - // this is a wait that legitimately times out, so it is its own test - // rather than a silent extra assertion on the one above. + // The return trip. } /** * The whole round trip: the app sends, a different program answers, and the * answer appears in the thread. - * - * Needs the peer echoing in another shell, which is why it skips rather - * than fails when no reply arrives, a missing correspondent is not a - * broken app. Run: - * - * ./gradlew :interop:peer -Pargs="peer1.json autoreply 240" */ @Test fun aReplyFromSomebodyElseAppearsInTheThread() { @@ -154,12 +124,13 @@ class ChatFlowTest { compose.onNodeWithTag("send").performClick() awaitNode(hasText(text, substring = true), 60_000) - // The app polls every few seconds; the peer echoes every couple. This - // is the first thing that proves the RECEIVE path works in the UI -- - // everything before it only proved sending. + // The app polls every few seconds; the peer echoes every couple. val echo = "echo: $text" - val arrived = runCatching { awaitNode(hasText(echo, substring = true), 90_000); true } - .getOrDefault(false) + val arrived = + runCatching { + awaitNode(hasText(echo, substring = true), 90_000) + true + }.getOrDefault(false) if (!arrived) { android.util.Log.w("fnchat", "no echo for '$text', is the peer running autoreply?") return @@ -168,7 +139,7 @@ class ChatFlowTest { android.util.Log.i("fnchat", "round trip complete: $echo") } - // -- Helpers ------------------------------------------------------------- + // -- Helpers -------------------------------------------------------------. private fun signUp() { compose.onNodeWithTag("relay").performTextClearance() @@ -177,13 +148,7 @@ class ChatFlowTest { compose.onNodeWithTag("email").performTextInput("$handle@example.invalid") compose.onNodeWithTag("handle").performTextInput(handle) compose.onNodeWithTag("passphrase").performTextInput("a-long-enough-passphrase") - // The age gate, which nothing here filled. `submit` is - // `enabled = … && (!registering || birthYear.length == 4)`, so without - // this the button is DISABLED, and `performClick()` on a disabled node - // does not throw, it does nothing. Every sign-up in this suite has been - // silently tapping a dead button since the gate landed, and the failure - // surfaced two minutes later as "nav-contacts did not appear" with no - // error anywhere on screen. + // The age gate, which nothing here filled. compose.onNodeWithTag("birth-year").performTextInput("1990") compose.onNodeWithTag("submit").performClick() awaitTag("device-id", 120_000) @@ -191,25 +156,12 @@ class ChatFlowTest { /** * Get to a signed-out app. - * - * Sign-out lives on the Account screen, so a test that only looks for it - * on whatever screen the previous test left behind silently does nothing, - * and the next assertion fails looking for a sign-in field that was never - * going to be there. */ private fun signOutIfSignedIn() { - // Wait for the app to settle before deciding anything. The - // Compose rule launches a fresh Activity per test but the process, and - // the vault, survive, so the app spends its first moments reading the - // vault and showing a spinner. Checking for a signed-in control right - // away finds nothing, concludes "already signed out", and then the - // session restores underneath: every later test fails looking for a - // sign-in field. This race is why the suite passed one test at a time - // and failed as a set. + // Wait for the app to settle before deciding anything. awaitAny(listOf("relay", "nav-contacts", "nav-account", "back"), 30_000) - // Close any open thread. The chat screen replaces the bottom nav, so a - // test inheriting an open conversation cannot reach Account at all. + // Close any open thread. if (nodesWithTag("back").isNotEmpty()) { compose.onNodeWithTag("back").performClick() awaitTag("nav-contacts", 20_000) @@ -221,9 +173,11 @@ class ChatFlowTest { awaitTag("relay", 30_000) } - /** Wait until any one of these tags exists. */ - private fun awaitAny(tags: List, timeoutMillis: Long) { + private fun awaitAny( + tags: List, + timeoutMillis: Long, + ) { compose.mainClock.autoAdvance = false try { val deadline = System.currentTimeMillis() + timeoutMillis @@ -237,19 +191,18 @@ class ChatFlowTest { } } - private fun nodesWithTag(tag: String): List = runCatching { - compose.onAllNodes(hasTestTag(tag)).fetchSemanticsNodes() - }.getOrDefault(emptyList()) + private fun nodesWithTag(tag: String): List = + runCatching { + compose.onAllNodes(hasTestTag(tag)).fetchSemanticsNodes() + }.getOrDefault(emptyList()) - private fun awaitTag(tag: String, timeoutMillis: Long) = - awaitNode(hasTestTag(tag), timeoutMillis) + private fun awaitTag( + tag: String, + timeoutMillis: Long, + ) = awaitNode(hasTestTag(tag), timeoutMillis) /** - * Auto-advance is turned off only while waiting. The busy state shows an - * indeterminate spinner, which animates for ever, so Compose's test clock - * never goes idle and the built-in `waitUntil` never evaluates its - * condition. Turning it off for the whole test is the next mistake: the app - * opens on a spinner while it reads the vault, so nothing would compose. + * Auto-advance is turned off only while waiting. */ private fun awaitNode( matcher: androidx.compose.ui.test.SemanticsMatcher, @@ -260,26 +213,30 @@ class ChatFlowTest { val deadline = System.currentTimeMillis() + timeoutMillis while (System.currentTimeMillis() < deadline) { compose.mainClock.advanceTimeBy(100) - val found = runCatching { - compose.onAllNodes(matcher).fetchSemanticsNodes().isNotEmpty() - }.getOrDefault(false) + val found = + runCatching { + compose.onAllNodes(matcher).fetchSemanticsNodes().isNotEmpty() + }.getOrDefault(false) if (found) return Thread.sleep(100) } - throw AssertionError( - "nothing matched within ${timeoutMillis}ms. On screen: ${visibleText()}") + throw AssertionError("nothing matched within ${timeoutMillis}ms. On screen: ${visibleText()}") } finally { compose.mainClock.autoAdvance = true } } - private fun visibleText(): String = runCatching { - compose.onRoot().printToString(maxDepth = 30) - }.getOrElse { "(could not read the screen: $it)" } + private fun visibleText(): String = + runCatching { + compose.onRoot().printToString(maxDepth = 30) + }.getOrElse { "(could not read the screen: $it)" } - private fun relayReachable(): Boolean = runCatching { - (java.net.URL("$relayScheme://$relay/e2e/log/head").openConnection() as java.net.HttpURLConnection) - .apply { connectTimeout = 8000; readTimeout = 8000 } - .responseCode in 200..299 - }.getOrDefault(false) + private fun relayReachable(): Boolean = + runCatching { + (java.net.URL("$relayScheme://$relay/e2e/log/head").openConnection() as java.net.HttpURLConnection) + .apply { + connectTimeout = 8000 + readTimeout = 8000 + }.responseCode in 200..299 + }.getOrDefault(false) } diff --git a/app/src/androidTest/kotlin/io/flamenet/messenger/app/CrossPlatformTest.kt b/app/src/androidTest/kotlin/io/flamenet/messenger/app/CrossPlatformTest.kt index fa65d4e..84b92bf 100644 --- a/app/src/androidTest/kotlin/io/flamenet/messenger/app/CrossPlatformTest.kt +++ b/app/src/androidTest/kotlin/io/flamenet/messenger/app/CrossPlatformTest.kt @@ -10,45 +10,26 @@ import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextClearance import androidx.compose.ui.test.performTextInput import androidx.compose.ui.test.printToString -import androidx.test.rule.GrantPermissionRule import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.rule.GrantPermissionRule import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith /** * The Android half of a conversation with a client that is not Android. - * - * Driven by arguments, and deliberately NOT self-contained. Each step is - * its own test so the two platforms can be interleaved from a shell: - * - * -e handle andcross123 → sign up under a name iOS can address - * -e peer ioscross456 -e expect "hello from iOS" → wait for their message - * -e peer ioscross456 -e reply "hello back" → answer - * - * Run with `adb shell am instrument`, not `connectedAndroidTest`, which - * uninstalls the app afterwards and takes the vault, and therefore the account - * with it. The point is that the account survives between steps. */ @RunWith(AndroidJUnit4::class) class CrossPlatformTest { - - /** + /** * Granted BEFORE the activity launches, and that ordering is the point. - * - * `MainActivity.onCreate` asks for POST_NOTIFICATIONS before it calls - * `setContent`. The permission dialog is a separate activity and takes - * focus, so the Compose rule attaches to a window that has no hierarchy - * yet and every test dies with "No compose hierarchies found in the app". - * - * `order = 0` puts this outside the Compose rule, so the grant happens - * first and no dialog ever appears. */ @get:Rule(order = 0) - val permissions: GrantPermissionRule = GrantPermissionRule.grant( - android.Manifest.permission.POST_NOTIFICATIONS, - ) + val permissions: GrantPermissionRule = + GrantPermissionRule.grant( + android.Manifest.permission.POST_NOTIFICATIONS, + ) @get:Rule(order = 1) val compose = createAndroidComposeRule() @@ -60,7 +41,7 @@ class CrossPlatformTest { fun signUpUnderAKnownHandle() { val handle = args.getString("handle") ?: return settle() - if (nodesWithTag("nav-contacts").isNotEmpty()) return // already signed in + if (nodesWithTag("nav-contacts").isNotEmpty()) return // already signed in compose.onNodeWithTag("relay").performTextClearance() compose.onNodeWithTag("relay").performTextInput(relay) @@ -68,13 +49,7 @@ class CrossPlatformTest { compose.onNodeWithTag("email").performTextInput("$handle@example.invalid") compose.onNodeWithTag("handle").performTextInput(handle) compose.onNodeWithTag("passphrase").performTextInput("a-long-enough-passphrase") - // The age gate, which nothing here filled. `submit` is - // `enabled = … && (!registering || birthYear.length == 4)`, so without - // this the button is DISABLED, and `performClick()` on a disabled node - // does not throw, it does nothing. Every sign-up in this suite has been - // silently tapping a dead button since the gate landed, and the failure - // surfaced two minutes later as "nav-contacts did not appear" with no - // error anywhere on screen. + // The age gate, which nothing here filled. compose.onNodeWithTag("birth-year").performTextInput("1990") compose.onNodeWithTag("submit").performClick() awaitTag("nav-contacts", 150_000) @@ -106,17 +81,13 @@ class CrossPlatformTest { android.util.Log.i("fnxplat", "ANDROID_SENT=$body") } - // -- Helpers ------------------------------------------------------------- + // -- Helpers -------------------------------------------------------------. /** * Open the thread with `peer`, adding them if they are not a contact yet. - * - * The peer may already be a contact from an earlier step, this whole - * class is designed to be run repeatedly against a surviving account, so - * "add" has to be conditional rather than assumed. */ private fun openThreadWith(peer: String) { - if (nodesWithTag("back").isNotEmpty()) return // already in a thread + if (nodesWithTag("back").isNotEmpty()) return // already in a thread if (compose.onAllNodes(hasText(peer, substring = true)).fetchSemanticsNodes().isEmpty()) { compose.onNodeWithTag("add-contact").performClick() compose.onNodeWithTag("new-contact").performTextInput(peer) @@ -148,12 +119,15 @@ class CrossPlatformTest { } } - private fun nodesWithTag(tag: String): List = runCatching { - compose.onAllNodes(hasTestTag(tag)).fetchSemanticsNodes() - }.getOrDefault(emptyList()) + private fun nodesWithTag(tag: String): List = + runCatching { + compose.onAllNodes(hasTestTag(tag)).fetchSemanticsNodes() + }.getOrDefault(emptyList()) - private fun awaitTag(tag: String, timeoutMillis: Long) = - awaitNode(hasTestTag(tag), timeoutMillis) + private fun awaitTag( + tag: String, + timeoutMillis: Long, + ) = awaitNode(hasTestTag(tag), timeoutMillis) private fun awaitNode( matcher: androidx.compose.ui.test.SemanticsMatcher, @@ -164,20 +138,21 @@ class CrossPlatformTest { val deadline = System.currentTimeMillis() + timeoutMillis while (System.currentTimeMillis() < deadline) { compose.mainClock.advanceTimeBy(100) - val found = runCatching { - compose.onAllNodes(matcher).fetchSemanticsNodes().isNotEmpty() - }.getOrDefault(false) + val found = + runCatching { + compose.onAllNodes(matcher).fetchSemanticsNodes().isNotEmpty() + }.getOrDefault(false) if (found) return Thread.sleep(100) } - throw AssertionError( - "nothing matched within ${timeoutMillis}ms. On screen: " + visibleText()) + throw AssertionError("nothing matched within ${timeoutMillis}ms. On screen: " + visibleText()) } finally { compose.mainClock.autoAdvance = true } } - private fun visibleText(): String = runCatching { - compose.onRoot().printToString(maxDepth = 40) - }.getOrElse { "(could not read the screen: $it)" } + private fun visibleText(): String = + runCatching { + compose.onRoot().printToString(maxDepth = 40) + }.getOrElse { "(could not read the screen: $it)" } } diff --git a/app/src/androidTest/kotlin/io/flamenet/messenger/app/CrudSurfaceTest.kt b/app/src/androidTest/kotlin/io/flamenet/messenger/app/CrudSurfaceTest.kt index 1e8be4a..8ddd0a1 100644 --- a/app/src/androidTest/kotlin/io/flamenet/messenger/app/CrudSurfaceTest.kt +++ b/app/src/androidTest/kotlin/io/flamenet/messenger/app/CrudSurfaceTest.kt @@ -10,9 +10,9 @@ import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextClearance import androidx.compose.ui.test.performTextInput import androidx.compose.ui.test.printToString -import androidx.test.rule.GrantPermissionRule -import androidx.test.platform.app.InstrumentationRegistry import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.rule.GrantPermissionRule import org.junit.Assume.assumeTrue import org.junit.Before import org.junit.Rule @@ -21,59 +21,38 @@ import org.junit.runner.RunWith /** * Every capability is reachable from the UI. - * - * This exists because of a real habit. Features kept landing in - * `:client` with a live test proving them and no way for a person to use them, - * remove a contact, block someone, clear a thread, change a password, read a - * notification. A capability only reachable from a terminal is not shipped, - * and a screen that can add a contact but not remove one reads as broken - * rather than unfinished. - * - * So this walks the app and asserts the controls exist. It deliberately checks - * *presence and reachability* rather than behaviour, the behaviour is covered - * by the live tests; what was missing was the door. */ @RunWith(AndroidJUnit4::class) class CrudSurfaceTest { - - /** + /** * Granted BEFORE the activity launches, and that ordering is the point. - * - * `MainActivity.onCreate` asks for POST_NOTIFICATIONS before it calls - * `setContent`. The permission dialog is a separate activity and takes - * focus, so the Compose rule attaches to a window that has no hierarchy - * yet and every test dies with "No compose hierarchies found in the app". - * - * `order = 0` puts this outside the Compose rule, so the grant happens - * first and no dialog ever appears. */ @get:Rule(order = 0) - val permissions: GrantPermissionRule = GrantPermissionRule.grant( - android.Manifest.permission.POST_NOTIFICATIONS, - ) + val permissions: GrantPermissionRule = + GrantPermissionRule.grant( + android.Manifest.permission.POST_NOTIFICATIONS, + ) @get:Rule(order = 1) val compose = createAndroidComposeRule() /** * Which relay to drive. - * - * Defaults to the production relay, which is what this has always used, - * so an unqualified run still does what it did. Pass `-e relay ` to - * point it somewhere else, and a LOCAL one is what you want while - * developing: these tests create real accounts, and doing that on - * production for every run is a cost nobody chose. - * - * ./gradlew :app:connectedDebugAndroidTest \ - * -Pandroid.testInstrumentationRunnerArguments.relay=10.0.2.2:18810 */ private val relay: String - get() = InstrumentationRegistry.getArguments().getString("relay") - ?: "relay1.flamenet.io" + get() = + InstrumentationRegistry.getArguments().getString("relay") + ?: "relay1.flamenet.io" /** A local relay is plain HTTP; anything else is not. */ - private val relayScheme: String get() = if (relay.startsWith("10.0.2.2") || - relay.startsWith("127.0.0.1") || relay.startsWith("localhost")) "http" else "https" + private val relayScheme: String get() = + if (relay.startsWith("10.0.2.2") || + relay.startsWith("127.0.0.1") || relay.startsWith("localhost") + ) { + "http" + } else { + "https" + } private val peerHandle = "peerjvmone" private lateinit var handle: String @@ -92,10 +71,7 @@ class CrudSurfaceTest { compose.onNodeWithTag("submit").assertExists() compose.onNodeWithTag("toggle-register").assertExists() - // `assertExists`, not `assertIsDisplayed`. The sign-in column - // scrolls, so a control below the fold is present and reachable but not - // on screen, and this test is about whether the door exists, not about - // where it currently sits. + // `assertExists`, not `assertIsDisplayed`. compose.onNodeWithTag("toggle-reset").performClick() compose.onNodeWithTag("send-reset-code").assertExists() compose.onNodeWithTag("reset-code").assertExists() @@ -130,11 +106,6 @@ class CrudSurfaceTest { awaitNode(hasText(peerHandle, substring = true), 60_000) // Read the id the row was given, then open the contact screen. - // - // The actions moved OFF the row. They were three text buttons that - // unfolded under the entry, tagged per peer id; they are now the - // contact screen's rail and rows, which exist once and are not keyed - // by id. The assertions follow them rather than the old names. val peerId = peerIdFromTags() compose.onNodeWithTag("contact-menu-$peerId").performClick() compose.onNodeWithTag("contact-message").assertExists() @@ -152,8 +123,8 @@ class CrudSurfaceTest { signUp() // Stories are reachable from the contact list, and say what they are - // when there are none, an empty strip with no explanation is a - // feature nobody discovers. + // when there are none, an empty strip with no explanation is a feature + // nobody discovers. compose.onNodeWithTag("post-story").assertExists() compose.onNodeWithTag("nav-alerts").performClick() @@ -169,7 +140,6 @@ class CrudSurfaceTest { compose.onNodeWithTag("sign-out").assertExists() compose.onNodeWithTag("delete-account").assertExists() // §19 enforcement is a user-facing choice, so it has to have a control. - // A security setting reachable only from code is a setting nobody has. compose.onNodeWithTag("require-key-log").assertExists() // §14.4 rotation is the only lever against a sealed spammer, so it has // to be reachable, a lever nobody can find is not a lever. @@ -200,10 +170,7 @@ class CrudSurfaceTest { compose.onNodeWithTag("back").assertExists() // Identity state, on the thread it describes rather than buried in a - // settings screen. A freshly added contact is PINNED, trust on first - // use, so the state line and a way to reach the number both exist. - // Voice notes are reachable from the thread, and the control says what - // it does rather than being a bare icon. + // settings screen. compose.onNodeWithTag("record-voice").assertExists() // Calling is reachable from the thread it applies to. compose.onNodeWithTag("start-call").assertExists() @@ -214,13 +181,14 @@ class CrudSurfaceTest { compose.onNodeWithTag("toggle-verified").assertExists() } - // -- Helpers ------------------------------------------------------------- + // -- Helpers -------------------------------------------------------------. /** The contact row carries its user id in its tag; read it back from the tree. */ private fun peerIdFromTags(): Long { val dump = visibleText() - val match = Regex("contact-menu-(\\d+)").find(dump) - ?: throw AssertionError("no contact row found. On screen: $dump") + val match = + Regex("contact-menu-(\\d+)").find(dump) + ?: throw AssertionError("no contact row found. On screen: $dump") return match.groupValues[1].toLong() } @@ -231,13 +199,7 @@ class CrudSurfaceTest { compose.onNodeWithTag("email").performTextInput("$handle@example.invalid") compose.onNodeWithTag("handle").performTextInput(handle) compose.onNodeWithTag("passphrase").performTextInput("a-long-enough-passphrase") - // The age gate, which nothing here filled. `submit` is - // `enabled = … && (!registering || birthYear.length == 4)`, so without - // this the button is DISABLED, and `performClick()` on a disabled node - // does not throw, it does nothing. Every sign-up in this suite has been - // silently tapping a dead button since the gate landed, and the failure - // surfaced two minutes later as "nav-contacts did not appear" with no - // error anywhere on screen. + // The age gate, which nothing here filled. compose.onNodeWithTag("birth-year").performTextInput("1990") compose.onNodeWithTag("submit").performClick() awaitTag("nav-contacts", 120_000) @@ -245,25 +207,12 @@ class CrudSurfaceTest { /** * Get to a signed-out app. - * - * Sign-out lives on the Account screen, so a test that only looks for it - * on whatever screen the previous test left behind silently does nothing, - * and the next assertion fails looking for a sign-in field that was never - * going to be there. */ private fun signOutIfSignedIn() { - // Wait for the app to settle before deciding anything. The - // Compose rule launches a fresh Activity per test but the process, and - // the vault, survive, so the app spends its first moments reading the - // vault and showing a spinner. Checking for a signed-in control right - // away finds nothing, concludes "already signed out", and then the - // session restores underneath: every later test fails looking for a - // sign-in field. This race is why the suite passed one test at a time - // and failed as a set. + // Wait for the app to settle before deciding anything. awaitAny(listOf("relay", "nav-contacts", "nav-account", "back"), 30_000) - // Close any open thread. The chat screen replaces the bottom nav, so a - // test inheriting an open conversation cannot reach Account at all. + // Close any open thread. if (nodesWithTag("back").isNotEmpty()) { compose.onNodeWithTag("back").performClick() awaitTag("nav-contacts", 20_000) @@ -277,9 +226,11 @@ class CrudSurfaceTest { } } - /** Wait until any one of these tags exists. */ - private fun awaitAny(tags: List, timeoutMillis: Long) { + private fun awaitAny( + tags: List, + timeoutMillis: Long, + ) { compose.mainClock.autoAdvance = false try { val deadline = System.currentTimeMillis() + timeoutMillis @@ -293,12 +244,15 @@ class CrudSurfaceTest { } } - private fun nodesWithTag(tag: String): List = runCatching { - compose.onAllNodes(hasTestTag(tag)).fetchSemanticsNodes() - }.getOrDefault(emptyList()) + private fun nodesWithTag(tag: String): List = + runCatching { + compose.onAllNodes(hasTestTag(tag)).fetchSemanticsNodes() + }.getOrDefault(emptyList()) - private fun awaitTag(tag: String, timeoutMillis: Long) = - awaitNode(hasTestTag(tag), timeoutMillis) + private fun awaitTag( + tag: String, + timeoutMillis: Long, + ) = awaitNode(hasTestTag(tag), timeoutMillis) /** See ChatFlowTest: the busy spinner never lets the test clock go idle. */ private fun awaitNode( @@ -310,26 +264,30 @@ class CrudSurfaceTest { val deadline = System.currentTimeMillis() + timeoutMillis while (System.currentTimeMillis() < deadline) { compose.mainClock.advanceTimeBy(100) - val found = runCatching { - compose.onAllNodes(matcher).fetchSemanticsNodes().isNotEmpty() - }.getOrDefault(false) + val found = + runCatching { + compose.onAllNodes(matcher).fetchSemanticsNodes().isNotEmpty() + }.getOrDefault(false) if (found) return Thread.sleep(100) } - throw AssertionError( - "nothing matched within ${timeoutMillis}ms. On screen: ${visibleText()}") + throw AssertionError("nothing matched within ${timeoutMillis}ms. On screen: ${visibleText()}") } finally { compose.mainClock.autoAdvance = true } } - private fun visibleText(): String = runCatching { - compose.onRoot().printToString(maxDepth = 40) - }.getOrElse { "(could not read the screen: $it)" } + private fun visibleText(): String = + runCatching { + compose.onRoot().printToString(maxDepth = 40) + }.getOrElse { "(could not read the screen: $it)" } - private fun relayReachable(): Boolean = runCatching { - (java.net.URL("$relayScheme://$relay/e2e/log/head").openConnection() as java.net.HttpURLConnection) - .apply { connectTimeout = 8000; readTimeout = 8000 } - .responseCode in 200..299 - }.getOrDefault(false) + private fun relayReachable(): Boolean = + runCatching { + (java.net.URL("$relayScheme://$relay/e2e/log/head").openConnection() as java.net.HttpURLConnection) + .apply { + connectTimeout = 8000 + readTimeout = 8000 + }.responseCode in 200..299 + }.getOrDefault(false) } diff --git a/app/src/androidTest/kotlin/io/flamenet/messenger/app/EngineOnDeviceTest.kt b/app/src/androidTest/kotlin/io/flamenet/messenger/app/EngineOnDeviceTest.kt index 1419ead..da49b3c 100644 --- a/app/src/androidTest/kotlin/io/flamenet/messenger/app/EngineOnDeviceTest.kt +++ b/app/src/androidTest/kotlin/io/flamenet/messenger/app/EngineOnDeviceTest.kt @@ -20,32 +20,29 @@ import kotlin.system.measureTimeMillis /** * The engine on a real Android runtime. - * - * The JVM suite proves nothing about this. Android's provider is - * Conscrypt, not the OpenJDK one, and the two disagree about things that matter: - * Conscrypt will not build an XDH key from raw bytes, and XDH itself only - * exists from API 33. An engine that passes 81 tests on a laptop and throws on - * the first tap of a phone is the exact failure this file exists to catch, it - * has already happened once to this engine's sibling. */ @RunWith(AndroidJUnit4::class) class EngineOnDeviceTest { - private fun bundleFor(identity: DeviceIdentity): PeerBundle { val oneTime = identity.generateOneTimePreKeys(1).first() - return PeerBundle.fromWire(identity.registrationWire().put("opk", - JSONObject().put("id", oneTime.id) - .put("pub", android.util.Base64.encodeToString( - oneTime.publicKey, android.util.Base64.NO_WRAP)))) + return PeerBundle.fromWire( + identity.registrationWire().put( + "opk", + JSONObject() + .put("id", oneTime.id) + .put( + "pub", + android.util.Base64.encodeToString(oneTime.publicKey, android.util.Base64.NO_WRAP), + ), + ), + ) } @Test fun theProviderInUseIsWorthKnowing() { val providers = Security.getProviders().joinToString { "${it.name} ${it.version}" } android.util.Log.i("fnmsg", "providers: $providers") - // XDH comes from Conscrypt and is fine. Ed25519 is deliberately NOT - // requested from the JCA here, on Android only AndroidKeyStore serves - // it, and Keystore only makes keys it keeps. See Ed25519.kt. + // XDH comes from Conscrypt and is fine. assertNotNull(java.security.KeyPairGenerator.getInstance("XDH")) } @@ -54,22 +51,19 @@ class EngineOnDeviceTest { val a = X25519.generate() val b = X25519.generate() assertEquals( - android.util.Base64.encodeToString( - X25519.agree(a.private, X25519.rawFromPublicKey(b.public)), 0), - android.util.Base64.encodeToString( - X25519.agree(b.private, X25519.rawFromPublicKey(a.public)), 0), + android.util.Base64.encodeToString(X25519.agree(a.private, X25519.rawFromPublicKey(b.public)), 0), + android.util.Base64.encodeToString(X25519.agree(b.private, X25519.rawFromPublicKey(a.public)), 0), ) } /** - * The RFC 8032 vector, on device. Keygen-from-seed is our own arithmetic, - * so a platform difference in `BigInteger` or in the provider's PKCS#8 - * handling would change the account address a passphrase produces. + * The RFC 8032 vector, on device. */ @Test fun ed25519MatchesTheRfcVectorOnDevice() { val seed = "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60" val expected = "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a" + fun hex(s: String) = ByteArray(s.length / 2) { s.substring(it * 2, it * 2 + 2).toInt(16).toByte() } val pair = Ed25519.fromSeed(hex(seed)) @@ -109,20 +103,14 @@ class EngineOnDeviceTest { /** * How long a sign-in actually costs. - * - * Not a correctness test, a budget. The account service dictates - * 650,000 PBKDF2 iterations, and Android's `SecretKeyFactory` is far slower - * at that than iOS's CommonCrypto. A sign-in nobody is willing to wait for - * is a broken sign-in, so the number is measured and asserted rather than - * assumed. It is generous on purpose: this runs on an emulator in CI, which - * is slower than any phone. */ @Test fun theAccountKdfFinishesInSomethingLikeAReasonableTime() { val salt = ByteArray(16) { it.toByte() } - val elapsed = measureTimeMillis { - AccountKdf.authSecret("a reasonably long passphrase", salt) - } + val elapsed = + measureTimeMillis { + AccountKdf.authSecret("a reasonably long passphrase", salt) + } android.util.Log.i("fnmsg", "AccountKdf.authSecret took ${elapsed}ms") assertTrue( "deriving the account secret took ${elapsed}ms; a sign-in nobody waits for is broken", diff --git a/app/src/androidTest/kotlin/io/flamenet/messenger/app/FeatureUseTest.kt b/app/src/androidTest/kotlin/io/flamenet/messenger/app/FeatureUseTest.kt index c9e26f4..083b94c 100644 --- a/app/src/androidTest/kotlin/io/flamenet/messenger/app/FeatureUseTest.kt +++ b/app/src/androidTest/kotlin/io/flamenet/messenger/app/FeatureUseTest.kt @@ -1,21 +1,21 @@ package io.flamenet.messenger.app +import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.hasTestTag import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onRoot -import androidx.compose.ui.test.assertIsEnabled -import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo import androidx.compose.ui.test.performTextClearance import androidx.compose.ui.test.performTextInput import androidx.compose.ui.test.printToString -import androidx.test.rule.GrantPermissionRule -import androidx.test.platform.app.InstrumentationRegistry import androidx.test.ext.junit.runners.AndroidJUnit4 -import org.junit.Assume.assumeTrue +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.rule.GrantPermissionRule import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue import org.junit.Before import org.junit.Rule import org.junit.Test @@ -23,71 +23,40 @@ import org.junit.runner.RunWith /** * Voice notes, stories, groups and calls, used, not just reachable. - * - * `CrudSurfaceTest` already asserts these controls EXIST, and that was - * the right thing to build first: features kept landing with a live test and no - * door in the UI. But `post-story`, `record-voice` and `start-call` being - * present says nothing about what happens when somebody presses them, and until - * now nothing pressed them. Four features shipped in an APK on that basis. - * - * So this presses them and asserts the state that follows: the recorder shows a - * timer, discarding puts the composer back, a group appears in the list under - * the name it was given, the story viewer opens, the call banner comes up and - * hanging up takes it away. - * - * What this deliberately does NOT claim. An emulator has no microphone - * worth the name and no second handset, so a voice note is exercised as far as - * the recorder and a call as far as signalling. Whether audio arrives at another - * person is not something one device can answer, and pretending otherwise would - * be worse than the gap. */ @RunWith(AndroidJUnit4::class) class FeatureUseTest { - - /** + /** * Granted BEFORE the activity launches, and that ordering is the point. - * - * `MainActivity.onCreate` asks for POST_NOTIFICATIONS before it calls - * `setContent`. The permission dialog is a separate activity and takes - * focus, so the Compose rule attaches to a window that has no hierarchy - * yet and every test dies with "No compose hierarchies found in the app". - * - * `order = 0` puts this outside the Compose rule, so the grant happens - * first and no dialog ever appears. */ @get:Rule(order = 0) - val permissions: GrantPermissionRule = GrantPermissionRule.grant( - android.Manifest.permission.POST_NOTIFICATIONS, - // RECORD_AUDIO, or the recorder test cannot see the app. Tapping - // record raises the system microphone dialog, which is not part of the - // app's Compose tree, so the failure is "No compose hierarchies - // found", which reads as the app having died rather than as a dialog - // sitting on top of it. - android.Manifest.permission.RECORD_AUDIO, - ) + val permissions: GrantPermissionRule = + GrantPermissionRule.grant( + android.Manifest.permission.POST_NOTIFICATIONS, + // RECORD_AUDIO, or the recorder test cannot see the app. + android.Manifest.permission.RECORD_AUDIO, + ) @get:Rule(order = 1) val compose = createAndroidComposeRule() /** * Which relay to drive. - * - * Defaults to the production relay, which is what this has always used, - * so an unqualified run still does what it did. Pass `-e relay ` to - * point it somewhere else, and a LOCAL one is what you want while - * developing: these tests create real accounts, and doing that on - * production for every run is a cost nobody chose. - * - * ./gradlew :app:connectedDebugAndroidTest \ - * -Pandroid.testInstrumentationRunnerArguments.relay=10.0.2.2:18810 */ private val relay: String - get() = InstrumentationRegistry.getArguments().getString("relay") - ?: "relay1.flamenet.io" + get() = + InstrumentationRegistry.getArguments().getString("relay") + ?: "relay1.flamenet.io" /** A local relay is plain HTTP; anything else is not. */ - private val relayScheme: String get() = if (relay.startsWith("10.0.2.2") || - relay.startsWith("127.0.0.1") || relay.startsWith("localhost")) "http" else "https" + private val relayScheme: String get() = + if (relay.startsWith("10.0.2.2") || + relay.startsWith("127.0.0.1") || relay.startsWith("localhost") + ) { + "http" + } else { + "https" + } private lateinit var handle: String @Before @@ -96,13 +65,10 @@ class FeatureUseTest { signOutIfSignedIn() } - // -- Voice notes --------------------------------------------------------- + // -- Voice notes ---------------------------------------------------------. /** * The recorder starts, runs, and can be thrown away. - * - * The discard half matters as much as the start: a recorder you cannot back - * out of holds the microphone open and leaves the composer unusable. */ @Test fun aVoiceNoteRecordsAndCanBeDiscarded() { @@ -125,22 +91,17 @@ class FeatureUseTest { ) } - // -- Groups -------------------------------------------------------------- + // -- Groups --------------------------------------------------------------. /** * A group is created and appears in the list under the name it was given. - * - * Needs a contact: the member picker has nobody in it otherwise, and the - * relay refuses a group whose members have no device (`requireReachable`). */ @Test fun aGroupIsCreatedAndListed() { assumeTrue("the relay is unreachable - skipped, and a skip is not a pass", relayReachable()) signUpAndAddPeer() - // Groups live on the MESSAGES tab, not Home. Home is the roster and - // the stories strip; the conversation list and the groups moved when - // the tabs were split. + // Groups live on the MESSAGES tab, not Home. compose.onNodeWithTag("nav-messages").performClick() awaitTag("new-group", 20_000) compose.onNodeWithTag("new-group").performScrollTo() @@ -149,41 +110,36 @@ class FeatureUseTest { compose.onNodeWithTag("group-name").performTextInput("Test Crew") // Pick the one contact there is, by the tag the row carries. - val memberTag = Regex("group-member-(\\d+)").find(visibleText())?.value - ?: throw AssertionError( - "nobody offered as a group member, so the contact did not stick. " + - "On screen: ${visibleText()}") + val memberTag = + Regex("group-member-(\\d+)").find(visibleText())?.value + ?: throw AssertionError( + "nobody offered as a group member, so the contact did not stick. " + + "On screen: ${visibleText()}", + ) compose.onNodeWithTag(memberTag).performClick() compose.onNodeWithTag("create-group").performClick() // The group list carries a `group-` row once the relay answers. - awaitNodeMatching(Regex("group-\\d+"), 150_000, - "the group never appeared in the list after Create") + awaitNodeMatching( + Regex("group-\\d+"), + 150_000, + "the group never appeared in the list after Create", + ) } - // -- Stories ------------------------------------------------------------- + // -- Stories -------------------------------------------------------------. /** * The story surface opens and says what it is when there is nothing in it. - * - * Posting one needs an image from the system picker, which is a - * different process and not something a Compose test drives. So this covers - * the strip and the empty state, an unexplained empty strip is a feature - * nobody finds, and stops short of claiming a story was published. */ @Test fun theStorySurfaceOpensAndExplainsItself() { assumeTrue("the relay is unreachable - skipped, and a skip is not a pass", relayReachable()) signUp() - // The stories surface is its own tab. Asserting from Home found - // nothing and reported it as the strip being absent. + // The stories surface is its own tab. compose.onNodeWithTag("nav-stories").performClick() - // `new-story`, not `stories`. The `stories` tag belongs to the - // STRIP on Home; the Stories TAB is its own screen and carries the - // compose button instead. Asserting the strip's tag from the tab found - // nothing and reported it as the surface being absent, while the - // screen was right there, headed "Stories", saying "Nothing here yet". + // `new-story`, not `stories`. awaitTag("new-story", 20_000) compose.onNodeWithTag("new-story").assertExists() // The point of the test is the EXPLANATION, not the strip: an empty @@ -196,15 +152,11 @@ class FeatureUseTest { ) } - // -- Calls --------------------------------------------------------------- + // -- Calls ---------------------------------------------------------------. /** * Placing a call raises the privacy notice, then the in-call banner, and * hanging up clears it. - * - * Signalling only. Nobody answers on an emulator, so this proves the - * call is placed and can be ended, not that audio flows. Media needs two - * real devices and is honestly out of reach here. */ @Test fun placingACallRaisesTheBannerAndHangingUpClearsIt() { @@ -223,49 +175,42 @@ class FeatureUseTest { awaitTag("call-banner", 30_000) compose.onNodeWithTag("end-call").performClick() - val gone = (1..40).any { - Thread.sleep(250) - !nodeExists("call-banner") - } + val gone = + (1..40).any { + Thread.sleep(250) + !nodeExists("call-banner") + } assertTrue("hanging up did not clear the call banner", gone) } - // -- Helpers ------------------------------------------------------------- + // -- Helpers -------------------------------------------------------------. private fun signUpAndAddPeer() { signUp() - // The add-contact flow, as it exists. `new-contact` is the text - // FIELD, not a button that opens one, and `lookup-contact` is not in - // the app at all, the form is opened by `add-contact` and submitted - // with `confirm-contact`. + // The add-contact flow, as it exists. compose.onNodeWithTag("add-contact").performScrollTo() compose.onNodeWithTag("add-contact").performClick() awaitTag("new-contact", 20_000) compose.onNodeWithTag("new-contact").performTextInput(PEER) - // Look up, THEN add. `confirm-contact` only exists once the - // lookup has found somebody, the app refuses to turn a typed string - // straight into a contact, because a typo would silently become a - // stranger and the first thing sent to them is a message meant for - // someone else. + // Look up, THEN add. compose.onNodeWithTag("lookup-contact").performScrollTo() compose.onNodeWithTag("lookup-contact").performClick() awaitTag("confirm-contact", 30_000) compose.onNodeWithTag("confirm-contact").performScrollTo() compose.onNodeWithTag("confirm-contact").performClick() - // `buddy-circle-N` as well as `buddy-N`. Home shows the Recent - // circles row, whose tags are `buddy-circle-`; the plain - // `buddy-` belongs to the list further in. Matching only the - // latter reported "never became a contact" about a contact that was - // right there on screen, the roster had the person, under the other - // name for the same thing. - awaitNodeMatching(Regex("buddy-(circle-)?\\d+"), 150_000, - "'$PEER' never became a contact, does that account exist on $relay?") + // `buddy-circle-N` as well as `buddy-N`. + awaitNodeMatching( + Regex("buddy-(circle-)?\\d+"), + 150_000, + "'$PEER' never became a contact, does that account exist on $relay?", + ) } private fun signUpAndOpenAThread() { signUpAndAddPeer() - val buddyTag = Regex("buddy-(circle-)?\\d+").find(visibleText())?.value - ?: throw AssertionError("no contact row to open") + val buddyTag = + Regex("buddy-(circle-)?\\d+").find(visibleText())?.value + ?: throw AssertionError("no contact row to open") compose.onNodeWithTag(buddyTag).performClick() awaitTag("draft", 20_000) } @@ -277,61 +222,35 @@ class FeatureUseTest { compose.onNodeWithTag("email").performTextInput("$handle@example.invalid") compose.onNodeWithTag("handle").performTextInput(handle) compose.onNodeWithTag("passphrase").performTextInput("a-long-enough-passphrase") - // The age gate, which nothing here filled. `submit` is - // `enabled = … && (!registering || birthYear.length == 4)`, so without - // this the button is DISABLED, and `performClick()` on a disabled node - // does not throw, it does nothing. Every sign-up in this suite has been - // silently tapping a dead button since the gate landed, and the failure - // surfaced two minutes later as "nav-contacts did not appear" with no - // error anywhere on screen. + // The age gate, which nothing here filled. compose.onNodeWithTag("birth-year").performTextInput("1990") - // Scroll to it first. The sign-up form grew a "Year of birth" - // field and its explanation, and `submit` went below the fold on a - // 1080x2400 screen. An off-screen node still reports ENABLED, that is - // a state check, not a visibility one, and the gesture reached - // nothing. The app's own logging proved it: `signIn` was never called, - // through 450 seconds of waiting for a screen nobody had asked for. + // Scroll to it first. compose.onNodeWithTag("submit").performScrollTo() compose.onNodeWithTag("submit").performClick() val began = System.currentTimeMillis() // How long sign-up actually takes here, logged so the answer is a // number rather than a guess. awaitTag("nav-contacts", 420_000) - android.util.Log.e("FNTEST", "sign-up completed in " + - ((System.currentTimeMillis() - began) / 1000) + "s") + android.util.Log.e( + "FNTEST", + "sign-up completed in " + + ((System.currentTimeMillis() - began) / 1000) + "s", + ) } /** - * Get back to the signed-out screen, or establish that we are already there. - * - * Two things made this a no-op. `sign-out` sits at the bottom of the - * Account screen and needs scrolling to, an off-screen node accepts a - * click and does nothing. And the destructive rows confirm in place: - * the first tap changes the row to say what it is about to do, and a second - * tap performs it. One tap armed it and left the session intact, so every - * test after the first opened signed in and could not find the sign-up - * form, failing on `TestTag = 'relay'` as though the screen were missing. - * - * Not wrapped in `runCatching` any more. Swallowing a failure here made - * the next test's failure the visible one, three screens away from the - * cause. + * Get back to the signed-out screen, or establish that we are already + * there. */ private fun signOutIfSignedIn() { if (!awaitTagOrNull("nav-account", 8_000)) return compose.onNodeWithTag("nav-account").performClick() - // `sign-out-erase`, not `sign-out`. The two doors do different - // things on purpose: a plain sign-out KEEPS the device and the stored - // credential, so signing back in picks up where you left off, correct - // for a person, wrong for a suite whose next test wants a DIFFERENT - // account. The old session was still in the vault, the app kept trying - // to use it, and the next sign-up sat behind "Your session expired. - // Sign out and back in to keep going." + // `sign-out-erase`, not `sign-out`. if (!awaitTagOrNull("sign-out-erase", 10_000)) return compose.onNodeWithTag("sign-out-erase").performScrollTo() compose.onNodeWithTag("sign-out-erase").performClick() // Confirm in place: the first tap changes the row to "Really erase, - // this cannot be undone", and the second performs it. One tap only - // armed it. + // this cannot be undone", and the second performs it. if (awaitTagOrNull("sign-out-erase", 3_000)) { compose.onNodeWithTag("sign-out-erase").performClick() } @@ -341,20 +260,28 @@ class FeatureUseTest { ) } - private fun nodeExists(tag: String) = runCatching { - compose.onAllNodes(hasTestTag(tag)).fetchSemanticsNodes().isNotEmpty() - }.getOrDefault(false) + private fun nodeExists(tag: String) = + runCatching { + compose.onAllNodes(hasTestTag(tag)).fetchSemanticsNodes().isNotEmpty() + }.getOrDefault(false) /** See ChatFlowTest: the busy spinner never lets the test clock go idle. */ - private fun awaitTag(tag: String, timeoutMillis: Long) { + private fun awaitTag( + tag: String, + timeoutMillis: Long, + ) { if (!awaitTagOrNull(tag, timeoutMillis)) { throw AssertionError( "'$tag' did not appear within ${timeoutMillis}ms. " + - "On screen: ${visibleText()}") + "On screen: ${visibleText()}", + ) } } - private fun awaitTagOrNull(tag: String, timeoutMillis: Long): Boolean { + private fun awaitTagOrNull( + tag: String, + timeoutMillis: Long, + ): Boolean { compose.mainClock.autoAdvance = false try { val deadline = System.currentTimeMillis() + timeoutMillis @@ -370,7 +297,11 @@ class FeatureUseTest { } /** Wait for any tag matching a pattern, ids are assigned by the relay. */ - private fun awaitNodeMatching(pattern: Regex, timeoutMillis: Long, whenMissing: String) { + private fun awaitNodeMatching( + pattern: Regex, + timeoutMillis: Long, + whenMissing: String, + ) { compose.mainClock.autoAdvance = false try { val deadline = System.currentTimeMillis() + timeoutMillis @@ -385,15 +316,19 @@ class FeatureUseTest { } } - private fun visibleText(): String = runCatching { - compose.onRoot().printToString(maxDepth = 40) - }.getOrElse { "(could not read the screen: $it)" } + private fun visibleText(): String = + runCatching { + compose.onRoot().printToString(maxDepth = 40) + }.getOrElse { "(could not read the screen: $it)" } - private fun relayReachable(): Boolean = runCatching { - (java.net.URL("$relayScheme://$relay/e2e/log/head").openConnection() as java.net.HttpURLConnection) - .apply { connectTimeout = 8000; readTimeout = 8000 } - .responseCode in 200..299 - }.getOrDefault(false) + private fun relayReachable(): Boolean = + runCatching { + (java.net.URL("$relayScheme://$relay/e2e/log/head").openConnection() as java.net.HttpURLConnection) + .apply { + connectTimeout = 8000 + readTimeout = 8000 + }.responseCode in 200..299 + }.getOrDefault(false) private companion object { /** A standing account on relay1 that other on-device tests also use. */ diff --git a/app/src/androidTest/kotlin/io/flamenet/messenger/app/LiveSignUpFleetTest.kt b/app/src/androidTest/kotlin/io/flamenet/messenger/app/LiveSignUpFleetTest.kt index 88318b2..6e4c3b6 100644 --- a/app/src/androidTest/kotlin/io/flamenet/messenger/app/LiveSignUpFleetTest.kt +++ b/app/src/androidTest/kotlin/io/flamenet/messenger/app/LiveSignUpFleetTest.kt @@ -9,9 +9,9 @@ import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextClearance import androidx.compose.ui.test.performTextInput import androidx.compose.ui.test.printToString -import androidx.test.rule.GrantPermissionRule import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.rule.GrantPermissionRule import org.junit.Assume.assumeTrue import org.junit.Before import org.junit.Rule @@ -20,52 +20,17 @@ import org.junit.runner.RunWith /** * Sign-up on the two shapes that hold their accounts in the shared service. - * - * `SignUpFlowTest` covers the third shape, a relay with its own account table - * and is the one that can be run freely. This one is its opposite number, and - * the difference is not cosmetic: these two shapes send a passphrase-derived - * secret to `accounts.flamenet.io` rather than to the relay, and they are the - * two the parity table listed as built but never exercised end to end. - * - * These create real accounts on `accounts.flamenet.io`. There is no - * disposable copy of that service, so unlike the fleet these cannot be reset, - * every run leaves an account behind. Hence opt-in: - * - * adb shell am instrument -w -e live true \ - * -e class io.flamenet.messenger.app.LiveSignUpFleetTest \ - * io.flamenet.messenger.app.test/androidx.test.runner.AndroidJUnitRunner - * - * An instrumentation argument, not an environment variable. `FOO=1 - * ./gradlew connectedAndroidTest` does not reach the instrumentation process; - * the variable is simply absent, so an env-gated skip stays skipped no matter - * what is set and the suite reports success while having run nothing. - * `CrossPlatformTest` takes its parameters the same way for the same reason. - * - * The account service allows three sign-ups per hour per address - * (`ENCLAVE_SIGNUPS_PER_ADDRESS`, an hour-long window that survives restarts). - * That budget is shared with the iOS `LiveSignUpUITests` and with anything else - * signing up from this network, so a fourth run inside the hour is refused, - * which is the service behaving correctly, and reads as a broken sign-up. - * Two shapes per round means one round per half hour, at best. */ @RunWith(AndroidJUnit4::class) class LiveSignUpFleetTest { - - /** + /** * Granted BEFORE the activity launches, and that ordering is the point. - * - * `MainActivity.onCreate` asks for POST_NOTIFICATIONS before it calls - * `setContent`. The permission dialog is a separate activity and takes - * focus, so the Compose rule attaches to a window that has no hierarchy - * yet and every test dies with "No compose hierarchies found in the app". - * - * `order = 0` puts this outside the Compose rule, so the grant happens - * first and no dialog ever appears. */ @get:Rule(order = 0) - val permissions: GrantPermissionRule = GrantPermissionRule.grant( - android.Manifest.permission.POST_NOTIFICATIONS, - ) + val permissions: GrantPermissionRule = + GrantPermissionRule.grant( + android.Manifest.permission.POST_NOTIFICATIONS, + ) @get:Rule(order = 1) val compose = createAndroidComposeRule() @@ -77,10 +42,6 @@ class LiveSignUpFleetTest { /** * Somebody else's relay, keeping its accounts in the shared service. - * - * The shape that was unreachable before discovery existed: it can never be - * on a list compiled into the app, so reaching it at all depends on the - * relay being asked what it uses and believed. */ private val sharedSelfHosted get() = args.getString("shared") ?: "relay2.flamenet.io" @@ -94,16 +55,12 @@ class LiveSignUpFleetTest { /** * `assumeTrue`, never a bare `return`. - * - * A bare return leaves the test reporting PASSED having done nothing, and - * a suite of those is indistinguishable from a suite that works, which is - * the whole reason to run it. Assumed-out shows as skipped, so the count - * tells the truth about what was actually exercised. */ private fun requireLiveOptIn() = assumeTrue( "pass -e live true to run these, they create real accounts on accounts.flamenet.io", - args.getString("live") == "true") + args.getString("live") == "true", + ) @Test fun signUpOnTheManagedRelay() { @@ -119,16 +76,11 @@ class LiveSignUpFleetTest { /** * One whole sign-up, driven the way a person drives it. - * - * The assertion that carries the weight is the absence of the email - * field. A relay keeping its own accounts asks for an address so it can - * send a reset code; the shared service never does. So an email field still - * on screen means discovery did not land and the app is about to stretch - * the passphrase with the wrong salt, which does not error. It derives a - * different account from the same passphrase, and the person finds out - * later, on another device, when their credential does not work. */ - private fun signUpLive(relay: String, tag: String) { + private fun signUpLive( + relay: String, + tag: String, + ) { if (!reachable(relay)) return val name = "$handle$tag" @@ -136,10 +88,7 @@ class LiveSignUpFleetTest { compose.onNodeWithTag("relay").performTextInput(relay) compose.onNodeWithTag("toggle-register").performClick() - // Wait for discovery BEFORE deciding anything about the form. The - // screen renders from the default. LOCAL, the safe direction, and the - // descriptor lands a moment later. Asserting immediately tests the - // placeholder rather than the answer. + // Wait for discovery BEFORE deciding anything about the form. awaitTag("identity-mode", 30_000) awaitNoTag("email", 30_000) @@ -158,12 +107,6 @@ class LiveSignUpFleetTest { /** * Get to a signed-out app. - * - * The process and the vault survive between tests even though the rule - * gives each one a fresh Activity, so the app opens on a spinner while it - * reads the vault. Deciding "already signed out" before it settles lets the - * session restore underneath, and the next assertion fails looking for a - * field that was never going to be there. */ private fun signOutIfSignedIn() { awaitAny(listOf("relay", "nav-contacts", "nav-account", "back"), 30_000) @@ -178,43 +121,48 @@ class LiveSignUpFleetTest { awaitTag("relay", 30_000) } - private fun nodesWithTag(tag: String): List = runCatching { - compose.onAllNodes(hasTestTag(tag)).fetchSemanticsNodes() - }.getOrDefault(emptyList()) + private fun nodesWithTag(tag: String): List = + runCatching { + compose.onAllNodes(hasTestTag(tag)).fetchSemanticsNodes() + }.getOrDefault(emptyList()) - private fun awaitAny(tags: List, timeoutMillis: Long) { + private fun awaitAny( + tags: List, + timeoutMillis: Long, + ) { pumping(timeoutMillis) { tags.any { nodesWithTag(it).isNotEmpty() } } } - private fun awaitTag(tag: String, timeoutMillis: Long) { + private fun awaitTag( + tag: String, + timeoutMillis: Long, + ) { if (!pumping(timeoutMillis) { nodesWithTag(tag).isNotEmpty() }) { - throw AssertionError( - "'$tag' never appeared within ${timeoutMillis}ms. On screen: ${visibleText()}") + throw AssertionError("'$tag' never appeared within ${timeoutMillis}ms. On screen: ${visibleText()}") } } /** The inverse, and the one this test actually turns on. */ - private fun awaitNoTag(tag: String, timeoutMillis: Long) { + private fun awaitNoTag( + tag: String, + timeoutMillis: Long, + ) { if (!pumping(timeoutMillis) { nodesWithTag(tag).isEmpty() }) { throw AssertionError( "'$tag' was still on screen after ${timeoutMillis}ms, discovery did not land, " + "so this relay is being treated as if it kept its own accounts. " + - "On screen: ${visibleText()}") + "On screen: ${visibleText()}", + ) } } /** * Poll a condition, advancing the clock by hand. - * - * Auto-advance is turned off only for the wait. Compose's test clock - * synchronises on idleness and the busy state shows an indeterminate - * spinner, which animates for ever, so the composition is never idle and - * `waitUntil` never evaluates its condition, failing on the full timeout in - * a way that reads as "sign-up is broken" when sign-up works. Turning it - * off for the whole test is the next mistake: the app opens on a spinner - * while it reads the vault, so nothing would ever compose. */ - private fun pumping(timeoutMillis: Long, condition: () -> Boolean): Boolean { + private fun pumping( + timeoutMillis: Long, + condition: () -> Boolean, + ): Boolean { compose.mainClock.autoAdvance = false try { val deadline = System.currentTimeMillis() + timeoutMillis @@ -229,13 +177,17 @@ class LiveSignUpFleetTest { } } - private fun visibleText(): String = runCatching { - compose.onRoot().printToString(maxDepth = 30) - }.getOrElse { "(could not read the screen: $it)" } + private fun visibleText(): String = + runCatching { + compose.onRoot().printToString(maxDepth = 30) + }.getOrElse { "(could not read the screen: $it)" } - private fun reachable(relay: String): Boolean = runCatching { - (java.net.URL("https://$relay/e2e/log/head").openConnection() as java.net.HttpURLConnection) - .apply { connectTimeout = 8000; readTimeout = 8000 } - .responseCode in 200..299 - }.getOrDefault(false) + private fun reachable(relay: String): Boolean = + runCatching { + (java.net.URL("https://$relay/e2e/log/head").openConnection() as java.net.HttpURLConnection) + .apply { + connectTimeout = 8000 + readTimeout = 8000 + }.responseCode in 200..299 + }.getOrDefault(false) } diff --git a/app/src/androidTest/kotlin/io/flamenet/messenger/app/ProviderProbeTest.kt b/app/src/androidTest/kotlin/io/flamenet/messenger/app/ProviderProbeTest.kt index 678bdb7..331cbbf 100644 --- a/app/src/androidTest/kotlin/io/flamenet/messenger/app/ProviderProbeTest.kt +++ b/app/src/androidTest/kotlin/io/flamenet/messenger/app/ProviderProbeTest.kt @@ -7,26 +7,30 @@ import java.security.Security /** * What this Android build actually offers, algorithm by algorithm. - * - * Not an assertion, a measurement. Guessing which provider serves `Ed25519` - * or `XDH` is how an engine ends up calling into `AndroidKeyStore` by accident - * and failing with "Not initialized", which names nothing useful. */ @RunWith(AndroidJUnit4::class) class ProviderProbeTest { - @Test fun whoServesWhat() { - val interesting = listOf( - "KeyPairGenerator.XDH", "KeyFactory.XDH", "KeyAgreement.XDH", - "KeyPairGenerator.Ed25519", "KeyFactory.Ed25519", "Signature.Ed25519", - "KeyPairGenerator.EdDSA", "KeyFactory.EdDSA", "Signature.EdDSA", - "SecretKeyFactory.PBKDF2WithHmacSHA256", - ) + val interesting = + listOf( + "KeyPairGenerator.XDH", + "KeyFactory.XDH", + "KeyAgreement.XDH", + "KeyPairGenerator.Ed25519", + "KeyFactory.Ed25519", + "Signature.Ed25519", + "KeyPairGenerator.EdDSA", + "KeyFactory.EdDSA", + "Signature.EdDSA", + "SecretKeyFactory.PBKDF2WithHmacSHA256", + ) for (service in interesting) { - val providers = Security.getProviders() - .filter { p -> p.services.any { "${it.type}.${it.algorithm}".equals(service, true) } } - .map { it.name } + val providers = + Security + .getProviders() + .filter { p -> p.services.any { "${it.type}.${it.algorithm}".equals(service, true) } } + .map { it.name } android.util.Log.i("fnprobe", "$service -> ${providers.ifEmpty { listOf("NONE") }}") } android.util.Log.i("fnprobe", "sdk=${android.os.Build.VERSION.SDK_INT}") diff --git a/app/src/androidTest/kotlin/io/flamenet/messenger/app/SignUpFlowTest.kt b/app/src/androidTest/kotlin/io/flamenet/messenger/app/SignUpFlowTest.kt index 761dad1..cf81d87 100644 --- a/app/src/androidTest/kotlin/io/flamenet/messenger/app/SignUpFlowTest.kt +++ b/app/src/androidTest/kotlin/io/flamenet/messenger/app/SignUpFlowTest.kt @@ -5,13 +5,13 @@ import androidx.compose.ui.test.hasTestTag import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onRoot -import androidx.compose.ui.test.printToString import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextClearance import androidx.compose.ui.test.performTextInput -import androidx.test.rule.GrantPermissionRule -import androidx.test.platform.app.InstrumentationRegistry +import androidx.compose.ui.test.printToString import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.rule.GrantPermissionRule import org.junit.Assume.assumeTrue import org.junit.Before import org.junit.Rule @@ -20,61 +20,38 @@ import org.junit.runner.RunWith /** * Sign-up, through the actual screens, against an actual relay. - * - * Points at the throwaway fleet, never at production. `relay1` is a - * disposable relay that exists to be written to; `relay.flamenet.io` carries - * real accounts, and a test suite that creates accounts there leaves litter - * nobody asked for. - * - * Every run uses a fresh handle. Reusing one means the second run fails - * with "that username is taken", a real relay answering correctly, which reads - * as a product bug. The handle is derived from the clock, so a repeat run is a - * repeat test rather than a different one. - * - * Skipped, not failed, when the relay is unreachable: a laptop with no network - * is not a broken app. */ @RunWith(AndroidJUnit4::class) class SignUpFlowTest { - - /** + /** * Granted BEFORE the activity launches, and that ordering is the point. - * - * `MainActivity.onCreate` asks for POST_NOTIFICATIONS before it calls - * `setContent`. The permission dialog is a separate activity and takes - * focus, so the Compose rule attaches to a window that has no hierarchy - * yet and every test dies with "No compose hierarchies found in the app". - * - * `order = 0` puts this outside the Compose rule, so the grant happens - * first and no dialog ever appears. */ @get:Rule(order = 0) - val permissions: GrantPermissionRule = GrantPermissionRule.grant( - android.Manifest.permission.POST_NOTIFICATIONS, - ) + val permissions: GrantPermissionRule = + GrantPermissionRule.grant( + android.Manifest.permission.POST_NOTIFICATIONS, + ) @get:Rule(order = 1) val compose = createAndroidComposeRule() /** * Which relay to drive. - * - * Defaults to the production relay, which is what this has always used, - * so an unqualified run still does what it did. Pass `-e relay ` to - * point it somewhere else, and a LOCAL one is what you want while - * developing: these tests create real accounts, and doing that on - * production for every run is a cost nobody chose. - * - * ./gradlew :app:connectedDebugAndroidTest \ - * -Pandroid.testInstrumentationRunnerArguments.relay=10.0.2.2:18810 */ private val relay: String - get() = InstrumentationRegistry.getArguments().getString("relay") - ?: "relay1.flamenet.io" + get() = + InstrumentationRegistry.getArguments().getString("relay") + ?: "relay1.flamenet.io" /** A local relay is plain HTTP; anything else is not. */ - private val relayScheme: String get() = if (relay.startsWith("10.0.2.2") || - relay.startsWith("127.0.0.1") || relay.startsWith("localhost")) "http" else "https" + private val relayScheme: String get() = + if (relay.startsWith("10.0.2.2") || + relay.startsWith("127.0.0.1") || relay.startsWith("localhost") + ) { + "http" + } else { + "https" + } private lateinit var handle: String @Before @@ -82,39 +59,17 @@ class SignUpFlowTest { handle = "andt" + (System.currentTimeMillis() % 100_000_000) // Do NOT wipe the Keystore here. - // - // The compose rule launches the activity before `@Before` runs, so the - // view model has already opened the vault and is holding a `SecretKey` - // handle. Deleting the Keystore entry underneath it leaves every later - // write failing against a key that no longer exists, and the symptom - // is a sign-up that never completes, with no error naming the cause. - // - // Signing out through the UI is both safe and a truer test: it is what - // a person does, and the view model reopens the vault afterwards. signOutIfSignedIn() } /** * Get to a signed-out app. - * - * Sign-out lives on the Account screen, so a test that only looks for it - * on whatever screen the previous test left behind silently does nothing, - * and the next assertion fails looking for a sign-in field that was never - * going to be there. */ private fun signOutIfSignedIn() { - // Wait for the app to settle before deciding anything. The - // Compose rule launches a fresh Activity per test but the process, and - // the vault, survive, so the app spends its first moments reading the - // vault and showing a spinner. Checking for a signed-in control right - // away finds nothing, concludes "already signed out", and then the - // session restores underneath: every later test fails looking for a - // sign-in field. This race is why the suite passed one test at a time - // and failed as a set. + // Wait for the app to settle before deciding anything. awaitAny(listOf("relay", "nav-contacts", "nav-account", "back"), 30_000) - // Close any open thread. The chat screen replaces the bottom nav, so a - // test inheriting an open conversation cannot reach Account at all. + // Close any open thread. if (nodesWithTag("back").isNotEmpty()) { compose.onNodeWithTag("back").performClick() awaitTag("nav-contacts", 20_000) @@ -126,9 +81,11 @@ class SignUpFlowTest { awaitTag("relay", 30_000) } - /** Wait until any one of these tags exists. */ - private fun awaitAny(tags: List, timeoutMillis: Long) { + private fun awaitAny( + tags: List, + timeoutMillis: Long, + ) { compose.mainClock.autoAdvance = false try { val deadline = System.currentTimeMillis() + timeoutMillis @@ -142,61 +99,52 @@ class SignUpFlowTest { } } - private fun nodesWithTag(tag: String): List = runCatching { - compose.onAllNodes(hasTestTag(tag)).fetchSemanticsNodes() - }.getOrDefault(emptyList()) + private fun nodesWithTag(tag: String): List = + runCatching { + compose.onAllNodes(hasTestTag(tag)).fetchSemanticsNodes() + }.getOrDefault(emptyList()) /** * Poll for a tag, advancing the clock ourselves. - * - * The work being waited on happens on a background thread, so the frames - * are only needed to let recomposition land, which is why this advances - * the clock a little on every turn rather than waiting for idle. */ - private fun awaitTag(tag: String, timeoutMillis: Long) { + private fun awaitTag( + tag: String, + timeoutMillis: Long, + ) { // Auto-advance is turned off HERE, not for the whole test. - // - // Compose's test clock synchronises on idleness, and the busy state - // shows an indeterminate `CircularProgressIndicator`, which animates - // for ever, so the composition is never idle and `waitUntil` never - // evaluates its condition. It fails on the full timeout with - // `ComposeTimeoutException`, which reads as "sign-up is broken" when - // sign-up works; driving the app by hand is what settled that. - // - // Turning it off for the whole test is the obvious next mistake: the - // app opens on a spinner while it reads the vault, so nothing would - // ever compose and even the first field would be missing. compose.mainClock.autoAdvance = false try { val deadline = System.currentTimeMillis() + timeoutMillis while (System.currentTimeMillis() < deadline) { compose.mainClock.advanceTimeBy(100) - val found = runCatching { - compose.onAllNodes(hasTestTag(tag)).fetchSemanticsNodes().isNotEmpty() - }.getOrDefault(false) + val found = + runCatching { + compose.onAllNodes(hasTestTag(tag)).fetchSemanticsNodes().isNotEmpty() + }.getOrDefault(false) if (found) return Thread.sleep(100) } - // Report what the screen actually says. A bare timeout sends - // you to the network, the relay and the crypto in turn, when the - // app has been displaying the reason the whole time. - throw AssertionError( - "'$tag' never appeared within ${timeoutMillis}ms. On screen: ${visibleText()}") + // Report what the screen actually says. + throw AssertionError("'$tag' never appeared within ${timeoutMillis}ms. On screen: ${visibleText()}") } finally { compose.mainClock.autoAdvance = true } } /** Everything the screen is showing, for a failure message worth reading. */ - private fun visibleText(): String = runCatching { - compose.onRoot().printToString(maxDepth = 30) - }.getOrElse { "(could not read the screen: $it)" } + private fun visibleText(): String = + runCatching { + compose.onRoot().printToString(maxDepth = 30) + }.getOrElse { "(could not read the screen: $it)" } - private fun relayReachable(): Boolean = runCatching { - (java.net.URL("$relayScheme://$relay/e2e/log/head").openConnection() as java.net.HttpURLConnection) - .apply { connectTimeout = 8000; readTimeout = 8000 } - .responseCode in 200..299 - }.getOrDefault(false) + private fun relayReachable(): Boolean = + runCatching { + (java.net.URL("$relayScheme://$relay/e2e/log/head").openConnection() as java.net.HttpURLConnection) + .apply { + connectTimeout = 8000 + readTimeout = 8000 + }.responseCode in 200..299 + }.getOrDefault(false) @Test fun aPersonCanCreateAnAccountOnASelfHostedRelay() { @@ -207,8 +155,7 @@ class SignUpFlowTest { compose.onNodeWithTag("toggle-register").performClick() // A relay with its own account table asks for an email; the shared - // service does not. If this node is missing, the hostname routing that - // decides which provider is in use has gone wrong. + // service does not. compose.onNodeWithTag("email").assertIsDisplayed() compose.onNodeWithTag("email").performTextInput("$handle@example.invalid") compose.onNodeWithTag("handle").performTextInput(handle) @@ -223,9 +170,6 @@ class SignUpFlowTest { /** * Signing out erases the vault, and the sign-in screen comes back. - * - * This is the promise the sign-out copy makes, "erases this phone's - * keys and stored messages", so it is worth a test rather than a comment. */ @Test fun signingOutReturnsToSignIn() { diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/AddressCard.kt b/app/src/main/kotlin/io/flamenet/messenger/app/AddressCard.kt index 5e62a52..d0f6d0a 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/AddressCard.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/AddressCard.kt @@ -4,7 +4,6 @@ import android.content.ClipData import android.content.ClipboardManager import android.content.Context import android.graphics.Bitmap -import android.graphics.Color as AndroidColor import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -14,38 +13,33 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.size import androidx.compose.material3.MaterialTheme -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.unit.sp import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.FilterQuality +import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import io.flamenet.e2e.QrCode +import android.graphics.Color as AndroidColor /** * Your address, as a code somebody can scan and a string they can copy. - * - * Android had no way to show this at all, which made the cross-relay - * work iOS-only in practice: an address you cannot display is an address - * nobody can send to. The engine could encode an fnid and had no way to derive - * one, so there was nothing to put on a screen even if a screen had existed. - * - * The string stays visible under the code. An fnid is a derived key, not - * a name, an address that is nearly right addresses somebody else, and a - * scanner is not always to hand. Copy is there because retyping 52 characters - * off a screen is how the wrong person gets messaged. */ @Composable -fun AddressCard(fnid: String?, relay: String, handle: String? = null) { +fun AddressCard( + fnid: String?, + relay: String, + handle: String? = null, +) { val context = LocalContext.current Column(Modifier.fillMaxWidth()) { @@ -53,12 +47,10 @@ fun AddressCard(fnid: String?, relay: String, handle: String? = null) { Spacer(Modifier.height(Space.sm)) if (fnid == null) { - // Said plainly rather than hidden. Without an address this phone - // cannot be reached from another relay, and a blank space would - // read as the feature not existing rather than as it not working. + // Said plainly rather than hidden. Text( - "This device could not derive your address. You can still message " - + "people on ${relay}, but nobody on another relay can reach you.", + "This device could not derive your address. You can still message " + + "people on $relay, but nobody on another relay can reach you.", style = MaterialTheme.typography.bodySmall, modifier = Modifier.testTag("address-missing"), ) @@ -66,12 +58,12 @@ fun AddressCard(fnid: String?, relay: String, handle: String? = null) { } val address = "$fnid@$relay" - // The handle rides in the CODE only, never in the copyable string, - // it lets a same-relay scanner resolve the account through the - // ordinary username lookup. Foreign scanners ignore the parameter - // (iOS's decoder reads only `k=`); handles are slugs, so no escaping. - val encoded = "flamenet:$address" + - (handle?.takeIf { it.isNotBlank() }?.let { "?u=$it" } ?: "") + // The handle rides in the CODE only, never in the copyable string, it + // lets a same-relay scanner resolve the account through the ordinary + // username lookup. + val encoded = + "flamenet:$address" + + (handle?.takeIf { it.isNotBlank() }?.let { "?u=$it" } ?: "") val bitmap = remember(encoded) { qrBitmap(encoded) } Column( @@ -86,19 +78,12 @@ fun AddressCard(fnid: String?, relay: String, handle: String? = null) { // stops scanning, the default filtering is smooth. filterQuality = FilterQuality.None, contentScale = ContentScale.FillBounds, - // 140, not 180. At 180 the code was the largest thing on - // the screen and pushed everything else below the fold, - // and a QR only has to be big enough for a phone camera a - // foot away, which 140dp at this density is. + // 140, not 180. modifier = Modifier.size(140.dp).testTag("address-qr"), ) Spacer(Modifier.height(Space.sm)) } - // Broken at the `@`, not wherever the line runs out. As one - // string it wrapped mid-hostname, "…@rela / y.flamenet.io", and a - // relay name split across a line break is the kind of thing - // somebody retypes wrong. The fnid is a derived key and wraps - // anywhere without harm; the host must not. + // Broken at the `@`, not wherever the line runs out. Text( fnid, fontSize = 12.sp, @@ -125,27 +110,30 @@ fun AddressCard(fnid: String?, relay: String, handle: String? = null) { Spacer(Modifier.height(Space.sm)) Text( - "Derived from your passphrase, not issued by a server. Sign in anywhere " - + "with the same passphrase and this stays the same, which is what " - + "lets it outlive any one relay.", + "Derived from your passphrase, not issued by a server. Sign in anywhere " + + "with the same passphrase and this stays the same, which is what " + + "lets it outlive any one relay.", style = MaterialTheme.typography.bodySmall, ) } } -private fun copy(context: Context, label: String, text: String) { +private fun copy( + context: Context, + label: String, + text: String, +) { val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager clipboard.setPrimaryClip(ClipData.newPlainText(label, text)) } /** * One module per pixel plus a quiet zone; Compose scales it up. - * - * The four-module quiet zone is not decoration. A QR printed hard against - * other content does not scan, and the margin has to be part of the bitmap - * because the surrounding card has a background of its own. */ -private fun qrBitmap(text: String, quiet: Int = 4): Bitmap? { +private fun qrBitmap( + text: String, + quiet: Int = 4, +): Bitmap? { val matrix = runCatching { QrCode.encode(text) }.getOrNull() ?: return null val side = matrix.size + quiet * 2 val pixels = IntArray(side * side) { AndroidColor.WHITE } diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/AppLock.kt b/app/src/main/kotlin/io/flamenet/messenger/app/AppLock.kt index 917dc04..5dac920 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/AppLock.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/AppLock.kt @@ -3,8 +3,6 @@ package io.flamenet.messenger.app import android.content.Context import androidx.biometric.BiometricManager import androidx.biometric.BiometricPrompt -import androidx.fragment.app.FragmentActivity -import androidx.core.content.ContextCompat import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -19,36 +17,15 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp - +import androidx.core.content.ContextCompat +import androidx.fragment.app.FragmentActivity /** * A fingerprint, a face, or the device PIN in front of the app. - * - * Android had none of this and iOS has had it for a while. The sealed - * history file protects a phone that is off or locked; it does nothing about - * the phone somebody just handed to a friend. On iOS the app asks for Face ID; - * on Android anyone holding an unlocked handset read every conversation. Two - * platforms disagreeing about a security guarantee is how one of them ends up - * being the one that leaks. - * - * What this defends against, stated plainly, because the alternative is - * implying more: somebody who picks up your unlocked phone. That is the - * threat people actually have, and it is worth something. - * - * What it does not defend against: anyone with the device and the tools to - * read its storage. This is a check in the app's own process. The vault and the - * ratchet keys are protected by the Android keystore, not by this switch, and - * turning it on re-encrypts nothing. An app lock that claimed otherwise would - * be selling a screen as if it were a key. - * - * `DEVICE_CREDENTIAL` alongside the biometrics on purpose. A phone with a - * PIN and no fingerprint reader should still be able to use this, the same - * reason iOS asks for `deviceOwnerAuthentication` rather than biometrics - * specifically. Locking people out of their own messages because their hardware - * is older is not a security win. */ -class AppLock(private val context: Context) { - +class AppLock( + private val context: Context, +) { companion object { private const val PREFS = "flamenet-app-lock" private const val ENABLED = "enabled" @@ -61,33 +38,16 @@ class AppLock(private val context: Context) { /** * Failed attempts before the first wait. - * - * Three, not one. A mistyped PIN or a fingerprint that did not read - * is ordinary, and making the first slip cost a wait would punish the - * owner far more often than anybody else. */ const val ATTEMPTS_BEFORE_DELAY = 3 /** * The longest wait this will ever impose. - * - * Capped, and it must be. An unbounded back-off locks somebody - * out of their own messages permanently, which is a worse outcome than - * the one it defends against, there is no reset, no support desk, and - * the data is on their own phone. */ const val MAXIMUM_DELAY_MS = 300_000L /** * How long to wait after [failures] consecutive failed unlocks. - * - * Doubles from 30 seconds and stops at five minutes. The shape matters - * less than the two ends: nothing for an ordinary slip, and a ceiling. - * - * Doubling is done by shifting a Long rather than `Math.pow`, so a - * large failure count cannot overflow a Double into infinity on the way - * to being capped. Twenty consecutive failures is not a realistic - * number and it should still return five minutes. */ fun delayMillis(failures: Int): Long { if (failures < ATTEMPTS_BEFORE_DELAY) return 0L @@ -104,10 +64,7 @@ class AppLock(private val context: Context) { private val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) /** - * Ordinary SharedPreferences, NOT the vault. This records a preference, - * not a secret, and putting it in the encrypted store would mean the - * setting could not be read until after sign-in, which is exactly when the - * lock needs to already be up. + * Ordinary SharedPreferences, NOT the vault. */ var enabled: Boolean get() = prefs.getBoolean(ENABLED, false) @@ -125,25 +82,19 @@ class AppLock(private val context: Context) { /** * Whether this device can do it at all. - * - * Checked before the setting is offered. A toggle that turns on and then - * cannot authenticate locks somebody out of their own messages with no way - * back, which is worse than not having the feature. */ fun available(): Boolean = BiometricManager.from(context).canAuthenticate(AUTHENTICATORS) == BiometricManager.BIOMETRIC_SUCCESS /** - * When the app was last sent to the background. Null means it has not been. - * - * The grace period exists so that picking a photo, answering a call, or - * being bounced out by the system camera does not demand a fingerprint on - * the way back. A lock with no grace is one people switch off. + * When the app was last sent to the background. */ private var leftAt: Long? = null - fun noteLeft(now: Long = System.currentTimeMillis()) { leftAt = now } + fun noteLeft(now: Long = System.currentTimeMillis()) { + leftAt = now + } fun shouldLock(now: Long = System.currentTimeMillis()): Boolean { if (!enabled) return false @@ -151,28 +102,21 @@ class AppLock(private val context: Context) { return (now - away) >= graceSeconds * 1000L } - fun noteUnlocked() { leftAt = null } + fun noteUnlocked() { + leftAt = null + } // ---- Backing off (ios#41) ------------------------------------------- - // - // Android had the lock and none of the back-off. iOS has counted - // failed unlocks and imposed a growing wait since ios#41; here every - // attempt was free, so somebody holding an unlocked phone could work - // through PINs as fast as the prompt would reappear. Two platforms - // disagreeing about a security guarantee is how one of them ends up being - // the one that leaks, the same argument that put the lock here at all. + // Android had the lock and none of the back-off. iOS has counted failed + // unlocks and imposed a growing wait since ios#41; here every attempt was + // free, so somebody holding an unlocked phone could work through PINs as + // fast as the prompt would reappear. /** Consecutive failures so far. */ val failures: Int get() = prefs.getInt(FAILURES, 0) /** * When the next attempt is allowed, or null if one is allowed now. - * - * Wall-clock, and somebody who can change the device clock can skip - * the wait. Said rather than hidden: this slows a person holding the - * phone, and it is not a defence against somebody with the device and the - * patience to work at it. What actually protects the messages is the - * keystore, exactly as the note at the top of this file says. */ fun waitingUntil(now: Long = System.currentTimeMillis()): Long? { val until = prefs.getLong(LOCKED_UNTIL, 0L) @@ -186,27 +130,28 @@ class AppLock(private val context: Context) { } /** - * Only a wrong answer counts. Dismissing the prompt is not an - * attempt, counting a cancel would let the owner back themselves into a - * wait by changing their mind twice. See the error handling in - * [authenticate], which is where that distinction is actually made. + * Only a wrong answer counts. */ fun recordFailure(now: Long = System.currentTimeMillis()) { val next = failures + 1 val wait = delayMillis(next) - prefs.edit().apply { - putInt(FAILURES, next) - if (wait > 0L) putLong(LOCKED_UNTIL, now + wait) - }.apply() + prefs + .edit() + .apply { + putInt(FAILURES, next) + if (wait > 0L) putLong(LOCKED_UNTIL, now + wait) + }.apply() } /** - * Forget the failures. On a successful unlock, and when the feature is - * switched off, leaving a wait behind on a disabled lock would strand - * somebody behind a screen that is no longer supposed to exist. + * Forget the failures. */ fun clearFailures() { - prefs.edit().remove(FAILURES).remove(LOCKED_UNTIL).apply() + prefs + .edit() + .remove(FAILURES) + .remove(LOCKED_UNTIL) + .apply() } /** How to say the wait, in whichever unit reads naturally. */ @@ -223,76 +168,65 @@ class AppLock(private val context: Context) { /** * Ask. - * - * Requires a `FragmentActivity`. `BiometricPrompt` hosts a fragment, and - * a plain `ComponentActivity` silently cannot show it, the callback never - * fires and the lock screen sits there looking broken. */ fun authenticate( activity: FragmentActivity, onSuccess: () -> Unit, onFailure: (String) -> Unit, ) { - // Refuse BEFORE showing the prompt. Letting the system sheet appear - // and then discarding the answer would teach somebody that attempts are - // free, which is the opposite of the point. - waitMessage()?.let { onFailure(it); return } + // Refuse BEFORE showing the prompt. + waitMessage()?.let { + onFailure(it) + return + } - val prompt = BiometricPrompt( - activity, - ContextCompat.getMainExecutor(activity), - object : BiometricPrompt.AuthenticationCallback() { - override fun onAuthenticationSucceeded( - result: BiometricPrompt.AuthenticationResult, - ) { - clearFailures() - onSuccess() - } + val prompt = + BiometricPrompt( + activity, + ContextCompat.getMainExecutor(activity), + object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + clearFailures() + onSuccess() + } - /** - * The one callback that means a wrong answer. - * `onAuthenticationFailed` fires for a fingerprint that did not - * match; `onAuthenticationError` fires for cancels, lockouts and - * hardware problems. Only this moves the counter, which is why - * dismissing the prompt cannot back the owner into a wait. - */ - override fun onAuthenticationFailed() { - recordFailure() - } + /** + * The one callback that means a wrong answer. + */ + override fun onAuthenticationFailed() { + recordFailure() + } - override fun onAuthenticationError(code: Int, message: CharSequence) { - // A cancel is not a failure. Reporting "authentication - // failed" when somebody tapped away is how a lock screen - // teaches people it is broken. - if (code == BiometricPrompt.ERROR_USER_CANCELED || - code == BiometricPrompt.ERROR_NEGATIVE_BUTTON || - code == BiometricPrompt.ERROR_CANCELED - ) return - // The platform's own lockout is not counted on top of - // ours. Two back-offs stacking would produce a wait nobody - // can predict from either rule. - onFailure(message.toString()) - } - }, - ) + override fun onAuthenticationError( + code: Int, + message: CharSequence, + ) { + // A cancel is not a failure. + if (code == BiometricPrompt.ERROR_USER_CANCELED || + code == BiometricPrompt.ERROR_NEGATIVE_BUTTON || + code == BiometricPrompt.ERROR_CANCELED + ) { + return + } + // The platform's own lockout is not counted on top of ours. + onFailure(message.toString()) + } + }, + ) prompt.authenticate( - BiometricPrompt.PromptInfo.Builder() + BiometricPrompt.PromptInfo + .Builder() .setTitle("Unlock Flamenet") .setSubtitle("Your messages are locked on this device.") .setAllowedAuthenticators(AUTHENTICATORS) - .build() + .build(), ) } } /** * What is on screen while the app is locked. - * - * Deliberately says nothing. No sender, no preview, no unread count, - * the point of the lock is that whoever is holding the phone learns nothing, - * and a lock screen reporting "3 new messages from Dana" has given away the two - * facts that usually matter most. */ @Composable fun LockScreen(onUnlock: () -> Unit) { diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/AppUpdate.kt b/app/src/main/kotlin/io/flamenet/messenger/app/AppUpdate.kt index c785f89..4d77e9f 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/AppUpdate.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/AppUpdate.kt @@ -24,75 +24,56 @@ import kotlinx.coroutines.withContext /** * Telling a hand-installed app that a newer one exists. - * - * This app is not in a store, so nothing else will ever say this. A - * person who installs the APK and never visits the site again keeps running - * that build forever, including through every fix that came after it. The - * network check lives in [UpdateCheck] and explains why it asks the user's own - * relay rather than an address of ours; this file is the part that decides how - * often to ask and how loudly to say so. - * - * Nobody already installed will see this. Every build before this one - * has no update check in it, so the first prompt anybody gets is for the build - * *after* the one that introduces this. That is not a bug to fix later, it is - * inherent, and it means the existing install base still has to be reached the - * way it always was, by telling them. */ object AppUpdate { - private const val PREFS = "app-update" private const val LAST_CHECKED = "last-checked-at" private const val DISMISSED_CODE = "dismissed-version-code" /** - * Six hours. Long enough that opening the app repeatedly costs one request - * a quarter of a day, short enough that a fix published in the morning is - * offered the same day. + * Six hours. */ private const val CHECK_INTERVAL_MS = 6L * 60 * 60 * 1000 - private fun prefs(context: Context) = - context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + private fun prefs(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) /** * What is actually installed, asked of the package manager. - * - * Not `BuildConfig.VERSION_CODE`. It would need a build feature turned - * on, and more to the point it is a constant compiled into the APK: it - * describes what was *built*, and this needs to describe what is *running*. - * They are the same today and the package manager cannot drift. */ - fun installedVersionCode(context: Context): Int = runCatching { - val info = context.packageManager.getPackageInfo(context.packageName, 0) - info.longVersionCode.toInt() - }.getOrDefault(0) + fun installedVersionCode(context: Context): Int = + runCatching { + val info = context.packageManager.getPackageInfo(context.packageName, 0) + info.longVersionCode.toInt() + }.getOrDefault(0) - fun installedVersionName(context: Context): String = runCatching { - context.packageManager.getPackageInfo(context.packageName, 0).versionName - }.getOrNull() ?: "unknown" + fun installedVersionName(context: Context): String = + runCatching { + context.packageManager.getPackageInfo(context.packageName, 0).versionName + }.getOrNull() ?: "unknown" private fun dueForCheck(context: Context): Boolean = System.currentTimeMillis() - prefs(context).getLong(LAST_CHECKED, 0) > CHECK_INTERVAL_MS - private fun recordCheck(context: Context) = - prefs(context).edit().putLong(LAST_CHECKED, System.currentTimeMillis()).apply() + private fun recordCheck(context: Context) = prefs(context).edit().putLong(LAST_CHECKED, System.currentTimeMillis()).apply() /** * Stop offering this particular build. - * - * Recorded per version, not as a "don't ask me again" flag. Somebody who - * declines 0.7 today should still be told about 0.8 next month, a single - * boolean would silence the feature permanently on the first dismissal, - * which is how update prompts end up being useless. */ - fun dismiss(context: Context, versionCode: Int) = - prefs(context).edit().putInt(DISMISSED_CODE, versionCode).apply() + fun dismiss( + context: Context, + versionCode: Int, + ) = prefs(context).edit().putInt(DISMISSED_CODE, versionCode).apply() - fun isDismissed(context: Context, versionCode: Int): Boolean = - prefs(context).getInt(DISMISSED_CODE, 0) >= versionCode + fun isDismissed( + context: Context, + versionCode: Int, + ): Boolean = prefs(context).getInt(DISMISSED_CODE, 0) >= versionCode /** Hand the download to the browser, which is where an APK install starts. */ - fun openDownload(context: Context, url: String) { + fun openDownload( + context: Context, + url: String, + ) { runCatching { context.startActivity( Intent(Intent.ACTION_VIEW, Uri.parse(url)) @@ -103,34 +84,31 @@ object AppUpdate { /** * Where releases are published when the relay does not publish them. - * - * Only ever reached because somebody pressed the button. The - * automatic check asks the user's own relay and stops there, so an app that - * is signed in to somebody else's server never contacts us on a timer. - * Pressing "Check for updates" is a different thing: it is a question the - * user asked, and answering "your relay does not publish updates, so I - * cannot tell you" when we plainly could is unhelpful. The screen says - * which host answered, so this is never a silent phone-home. */ const val OFFICIAL_SITE = "https://flamenetmessenger.com" /** What an explicit check found, including which host answered. */ sealed interface CheckResult { - data class Available(val release: UpdateCheck.Release, val source: String) : CheckResult - data class UpToDate(val source: String) : CheckResult + data class Available( + val release: UpdateCheck.Release, + val source: String, + ) : CheckResult + + data class UpToDate( + val source: String, + ) : CheckResult + /** Neither host published a manifest, or neither could be reached. */ data object Unknown : CheckResult } /** * The check behind the button: this relay first, then the official site. - * - * Ordered, not raced. The relay is asked first so somebody running their - * own gets their own answer, and the site is only reached for if that - * produced nothing at all, a relay that publishes an OLDER build than we - * do is still the answer for the person who chose that relay. */ - suspend fun checkNow(context: Context, baseUrl: String?): CheckResult = + suspend fun checkNow( + context: Context, + baseUrl: String?, + ): CheckResult = withContext(Dispatchers.IO) { val installed = installedVersionCode(context) val hosts = listOfNotNull(baseUrl?.takeIf { it.isNotBlank() }, OFFICIAL_SITE) @@ -147,22 +125,16 @@ object AppUpdate { } /** Just the hostname, a full URL in a sentence reads as an error. */ - private fun hostLabel(url: String): String = - runCatching { java.net.URI(url).host }.getOrNull() ?: url + private fun hostLabel(url: String): String = runCatching { java.net.URI(url).host }.getOrNull() ?: url /** * Ask the relay, off the main thread, no more often than the interval. - * - * The relay ONLY. The fallback to our site belongs to [checkNow], which - * runs because somebody asked; putting it here would make every install - * check in with us on a timer, which is the thing this product says it does - * not do. - * - * Returns null when there is nothing to say, which is the overwhelmingly - * common answer and must stay silent. */ - suspend fun check(context: Context, baseUrl: String?, force: Boolean = false): - UpdateCheck.Release? { + suspend fun check( + context: Context, + baseUrl: String?, + force: Boolean = false, + ): UpdateCheck.Release? { if (baseUrl.isNullOrBlank()) return null if (!force && !dueForCheck(context)) return null val installed = installedVersionCode(context) @@ -173,9 +145,6 @@ object AppUpdate { /** * The check the whole screen hangs off, run once per composition. - * - * Keyed on the relay so that signing into a different one re-asks. The - * answer is that relay's, not a global fact. */ @Composable fun rememberAvailable(baseUrl: String?): UpdateCheck.Release? { @@ -188,11 +157,6 @@ object AppUpdate { /** * The prompt itself. - * - * A dialog, and only for a version not yet declined. Everything else about - * updates lives quietly in Account, an app that interrupts you on every launch - * to talk about itself trains people to dismiss it without reading, and then - * the one update that mattered gets dismissed too. */ @Composable fun UpdateAvailableDialog(baseUrl: String?) { @@ -233,8 +197,7 @@ fun UpdateAvailableDialog(baseUrl: String?) { dismissButton = { TextButton( onClick = { - // Declining means declining THIS version, permanently. The - // next one asks again. + // Declining means declining THIS version, permanently. AppUpdate.dismiss(context, release.versionCode) showing = false }, diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/CallActionReceiver.kt b/app/src/main/kotlin/io/flamenet/messenger/app/CallActionReceiver.kt index 6ff2cfb..b8f6c1c 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/CallActionReceiver.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/CallActionReceiver.kt @@ -7,18 +7,12 @@ import androidx.core.app.NotificationManagerCompat /** * The Decline button on a ringing call notification. - * - * A broadcast, not an activity. Declining must not open the app, the whole - * point of answering from the lock screen is that the phone stays locked for - * the choice the user did NOT make. - * - * Clears the parked offer as well as the notification. The notification is - * `ongoing`, so it does not dismiss itself, and an offer left parked would be - * answered the next time the app opened, ringing for a call declined an hour - * ago. */ class CallActionReceiver : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { + override fun onReceive( + context: Context, + intent: Intent, + ) { if (intent.action != MessengerService.ACTION_DECLINE) return PendingIncomingCall.clearAll() runCatching { NotificationManagerCompat.from(context).cancel(3) } diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/CallManager.kt b/app/src/main/kotlin/io/flamenet/messenger/app/CallManager.kt index 0947486..c0b4883 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/CallManager.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/CallManager.kt @@ -6,14 +6,10 @@ import org.json.JSONObject import org.webrtc.AudioSource import org.webrtc.AudioTrack import org.webrtc.Camera2Enumerator +import org.webrtc.DataChannel import org.webrtc.DefaultVideoDecoderFactory import org.webrtc.DefaultVideoEncoderFactory import org.webrtc.EglBase -import org.webrtc.SurfaceTextureHelper -import org.webrtc.VideoCapturer -import org.webrtc.VideoSource -import org.webrtc.VideoTrack -import org.webrtc.DataChannel import org.webrtc.IceCandidate import org.webrtc.MediaConstraints import org.webrtc.MediaStream @@ -22,28 +18,14 @@ import org.webrtc.PeerConnectionFactory import org.webrtc.RtpReceiver import org.webrtc.SdpObserver import org.webrtc.SessionDescription +import org.webrtc.SurfaceTextureHelper +import org.webrtc.VideoCapturer +import org.webrtc.VideoSource +import org.webrtc.VideoTrack import java.util.UUID /** * Voice calls over WebRTC (spec §8.10). - * - * Peer to peer whenever the network allows it. ICE tries a direct path - * first, same network, then each side's address as seen through NAT, and - * only falls back to relaying through TURN when both ends sit behind NAT that - * refuses a direct path. The relay mints TURN credentials and never carries - * media; when it carries anything it carries DTLS-SRTP ciphertext between two - * peers who authenticated each other through the ratchet. - * - * A direct call shows the other person your IP address. That is what - * "peer to peer" means and it is not a bug, but it is a disclosure, and the - * app says so before the first call rather than after. Someone who does not - * want it needs a relay, and relaying requires a TURN server to exist. - * - * Signalling rides the ratchet, not the relay. The offer and answer are - * ordinary content envelopes, so the SDP, which names codecs, and every - * candidate address, is end-to-end encrypted like any other message. A relay - * that could read signalling would learn the addresses of both parties even on - * a call it never carries. */ class CallManager( private val context: Context, @@ -51,11 +33,14 @@ class CallManager( private val signal: (Long, Content.CallSignal) -> Unit, private val onStateChanged: (State) -> Unit, ) { - enum class State { IDLE, DIALLING, RINGING, CONNECTING, ACTIVE, ENDED } - data class Active(val callId: String, val peerId: Long, val incoming: Boolean, - val video: Boolean = false) + data class Active( + val callId: String, + val peerId: Long, + val incoming: Boolean, + val video: Boolean = false, + ) var current: Active? = null private set @@ -73,7 +58,7 @@ class CallManager( // -- Video (§6.8v) ------------------------------------------------------- // One EGL context for the factory's codecs, the capturer and every - // renderer. WebRTC textures only travel between things that share it. + // renderer. val eglBase: EglBase by lazy { EglBase.create() } private var videoCapturer: VideoCapturer? = null private var videoHelper: SurfaceTextureHelper? = null @@ -82,6 +67,7 @@ class CallManager( private set var remoteVideoTrack: VideoTrack? = null private set + /** Fired on the main thread when a remote video track appears or goes. */ var onVideoTracksChanged: (() -> Unit)? = null @@ -90,23 +76,11 @@ class CallManager( /** * Runs once, on the main thread, when ICE gathering completes, this is * where the offer or answer is actually sent. - * - * The SDP from `onCreateSuccess` has ZERO candidates in it. With - * trickle off, gathering starts at `setLocalDescription` and the - * candidates land in the *local description*, not in callbacks, so - * sending `sdp.description` the moment the offer was created shipped an - * SDP that named no address at all. The other end had nothing to connect - * to, ICE had no pairs to check, and every call sat in "Connecting…" - * until someone gave up. The complete SDP exists only after - * [PeerConnection.IceGatheringState.COMPLETE], read back from - * `localDescription`. */ private var onGathered: ((String) -> Unit)? = null /** - * Sends whatever was gathered if COMPLETE never fires. A TURN server that - * is configured but unreachable stalls gathering for tens of seconds; - * host + STUN candidates collected by then usually still connect. + * Sends whatever was gathered if COMPLETE never fires. */ private var gatherGuard: Runnable? = null @@ -115,17 +89,15 @@ class CallManager( /** * Gives up a call that was answered but whose media never connected. - * Without it, an answer whose ICE cannot find a path, no TURN, hostile - * NAT, left BOTH screens sitting in "Connecting…" until someone forced - * the app closed. */ private var connectGuard: Runnable? = null private fun armConnectGuard() { connectGuard?.let { main.removeCallbacks(it) } - val guard = Runnable { - if (state == State.CONNECTING) hangUp(notifyPeer = true, reason = "timeout") - } + val guard = + Runnable { + if (state == State.CONNECTING) hangUp(notifyPeer = true, reason = "timeout") + } connectGuard = guard main.postDelayed(guard, CONNECT_TIMEOUT_MS) } @@ -136,8 +108,8 @@ class CallManager( val guard = Runnable { deliverGatheredSdp() } gatherGuard = guard main.postDelayed(guard, GATHER_TIMEOUT_MS) - // Gathering can already be done, tiny SDPs on a fast network, and - // then COMPLETE fired before this registration and nothing else will. + // Gathering can already be done, tiny SDPs on a fast network, and then + // COMPLETE fired before this registration and nothing else will. if (connection?.iceGatheringState() == PeerConnection.IceGatheringState.COMPLETE) { deliverGatheredSdp() } @@ -149,45 +121,42 @@ class CallManager( onGathered = null gatherGuard?.let { main.removeCallbacks(it) } gatherGuard = null - // This `?: return` sent nobody anything, silently. After the - // 8s gather timeout the local description can still be null, because - // setLocalDescription had not finished, or had FAILED and said nothing - // (see SimpleSdpObserver). The call then dies here: no offer, no - // envelope on the relay, no ring on the other phone, and no error. - // Verified against the database, zero envelopes had ever been created - // between two accounts that had "placed" several calls. - val sdp = connection?.localDescription?.description ?: run { - android.util.Log.w("fnmsg-call", "NOT SENDING the offer/answer: no localDescription after " + - "gathering (state=${connection?.iceGatheringState()}). " + - "The other side will never see this call.") - return - } - android.util.Log.i("fnmsg-call", "sending signal after gathering, sdp=${sdp.length} bytes, " + - "state=${connection?.iceGatheringState()}") + // This `?: return` sent nobody anything, silently. + val sdp = + connection?.localDescription?.description ?: run { + android.util.Log.w( + "fnmsg-call", + "NOT SENDING the offer/answer: no localDescription after " + + "gathering (state=${connection?.iceGatheringState()}). " + + "The other side will never see this call.", + ) + return + } + android.util.Log.i( + "fnmsg-call", + "sending signal after gathering, sdp=${sdp.length} bytes, " + + "state=${connection?.iceGatheringState()}", + ) send(sdp) } /** * ICE servers, P2P-first. - * - * A relay with no TURN configured answers 503, and that is a supported - * deployment rather than an error: STUN alone still gets a direct call - * through most NATs. What it cannot do is rescue the minority of networks - * that refuse a direct path, those calls simply fail to connect, and - * saying so is better than pretending a relay exists. */ private fun iceServers(turn: JSONObject?): List { if (turn == null) { return listOf( - PeerConnection.IceServer.builder("stun:stun.l.google.com:19302") - .createIceServer() + PeerConnection.IceServer + .builder("stun:stun.l.google.com:19302") + .createIceServer(), ) } val urls = turn.optJSONArray("urls") ?: return emptyList() val username = turn.optString("username") val credential = turn.optString("credential") return (0 until urls.length()).map { i -> - PeerConnection.IceServer.builder(urls.getString(i)) + PeerConnection.IceServer + .builder(urls.getString(i)) .setUsername(username) .setPassword(credential) .createIceServer() @@ -197,95 +166,105 @@ class CallManager( private fun ensureFactory(): PeerConnectionFactory { factory?.let { return it } PeerConnectionFactory.initialize( - PeerConnectionFactory.InitializationOptions.builder(context) - .createInitializationOptions() + PeerConnectionFactory.InitializationOptions + .builder(context) + .createInitializationOptions(), ) - return PeerConnectionFactory.builder() - // Video codecs even for a voice call: the factory is built once - // per process, and a voice-only factory would make the first - // video call render nothing while looking connected. - .setVideoEncoderFactory( - DefaultVideoEncoderFactory(eglBase.eglBaseContext, true, true)) + return PeerConnectionFactory + .builder() + // Video codecs even for a voice call: the factory is built once per + // process, and a voice-only factory would make the first video call + // render nothing while looking connected. + .setVideoEncoderFactory(DefaultVideoEncoderFactory(eglBase.eglBaseContext, true, true)) .setVideoDecoderFactory(DefaultVideoDecoderFactory(eglBase.eglBaseContext)) .createPeerConnectionFactory() .also { factory = it } } - private fun open(turn: JSONObject?, peerId: Long, callId: String, - video: Boolean): PeerConnection? { - val config = PeerConnection.RTCConfiguration(iceServers(turn)).apply { - // ALL rather than RELAY: direct paths are tried, and TURN is the - // fallback. RELAY would force every call through the server, which - // is the opposite of the default this product chose. - iceTransportsType = PeerConnection.IceTransportsType.ALL - sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN - // Trickle is off: candidates are gathered before the offer is sent, - // so signalling is ONE encrypted envelope each way rather than a - // stream of them. Slower to connect by a second or two, and far - // simpler to reason about over a message channel that is polled - // rather than pushed. - continualGatheringPolicy = PeerConnection.ContinualGatheringPolicy.GATHER_ONCE - } - val observer = object : PeerConnection.Observer { - override fun onIceCandidate(candidate: IceCandidate?) { - // The candidate TYPE is the whole diagnosis. `host` only - // means we never reached STUN/TURN and cannot cross a NAT; - // `relay` means TURN answered and the call can connect even - // when a direct path is impossible. - val sdp = candidate?.sdp ?: return - val kind = Regex("typ (\\w+)").find(sdp)?.groupValues?.get(1) ?: "?" - android.util.Log.i("fnmsg-call", "ice candidate: typ=$kind") + private fun open( + turn: JSONObject?, + peerId: Long, + callId: String, + video: Boolean, + ): PeerConnection? { + val config = + PeerConnection.RTCConfiguration(iceServers(turn)).apply { + // ALL rather than RELAY: direct paths are tried, and TURN is the + // fallback. + iceTransportsType = PeerConnection.IceTransportsType.ALL + sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN + // Trickle is off: candidates are gathered before the offer is sent, + // so signalling is ONE encrypted envelope each way rather than a + // stream of them. + continualGatheringPolicy = PeerConnection.ContinualGatheringPolicy.GATHER_ONCE } - override fun onIceCandidatesRemoved(candidates: Array?) = Unit - override fun onSignalingChange(state: PeerConnection.SignalingState?) = Unit - // Nothing may throw out of here, and nothing may call back - // into WebRTC from here. This runs on WebRTC's signaling thread, - // and its JNI asserts RTC_CHECK(!env->ExceptionCheck()) on the way - // out, so ANY escaping Kotlin exception aborts the process: - // SIGABRT, no AndroidRuntime trace, nothing to catch. - override fun onIceConnectionChange(newState: PeerConnection.IceConnectionState?) { - android.util.Log.i("fnmsg-call", "ice connection -> $newState") - runCatching { - when (newState) { - PeerConnection.IceConnectionState.CONNECTED, - PeerConnection.IceConnectionState.COMPLETED -> state = State.ACTIVE - // CLOSED is what our OWN close() produces. Treating it as - // a reason to hang up re-entered hangUp from inside this - // callback, which then did a blocking network send AND - // disposed the connection we are currently inside, and - // killed the app every single time somebody pressed Hang up. - PeerConnection.IceConnectionState.CLOSED -> Unit - PeerConnection.IceConnectionState.FAILED -> hangUp(notifyPeer = true) - else -> Unit + val observer = + object : PeerConnection.Observer { + override fun onIceCandidate(candidate: IceCandidate?) { + // The candidate TYPE is the whole diagnosis. + val sdp = candidate?.sdp ?: return + val kind = Regex("typ (\\w+)").find(sdp)?.groupValues?.get(1) ?: "?" + android.util.Log.i("fnmsg-call", "ice candidate: typ=$kind") } + + override fun onIceCandidatesRemoved(candidates: Array?) = Unit + + override fun onSignalingChange(state: PeerConnection.SignalingState?) = Unit + + // Nothing may throw out of here, and nothing may call back into + // WebRTC from here. + override fun onIceConnectionChange(newState: PeerConnection.IceConnectionState?) { + android.util.Log.i("fnmsg-call", "ice connection -> $newState") + runCatching { + when (newState) { + PeerConnection.IceConnectionState.CONNECTED, + PeerConnection.IceConnectionState.COMPLETED, + -> state = State.ACTIVE + + // CLOSED is what our OWN close() produces. + PeerConnection.IceConnectionState.CLOSED -> Unit + + PeerConnection.IceConnectionState.FAILED -> hangUp(notifyPeer = true) + + else -> Unit + } + } } - } - override fun onIceConnectionReceivingChange(receiving: Boolean) = Unit - override fun onIceGatheringChange(newState: PeerConnection.IceGatheringState?) { - android.util.Log.i("fnmsg-call", "ice gathering -> $newState") - // Hop to the main thread rather than touching the connection - // from here, same JNI rule as above: no WebRTC calls, and no - // exceptions, from inside a WebRTC callback. - if (newState == PeerConnection.IceGatheringState.COMPLETE) { - main.post { runCatching { deliverGatheredSdp() } } + + override fun onIceConnectionReceivingChange(receiving: Boolean) = Unit + + override fun onIceGatheringChange(newState: PeerConnection.IceGatheringState?) { + android.util.Log.i("fnmsg-call", "ice gathering -> $newState") + // Hop to the main thread rather than touching the connection + // from here, same JNI rule as above: no WebRTC calls, and no + // exceptions, from inside a WebRTC callback. + if (newState == PeerConnection.IceGatheringState.COMPLETE) { + main.post { runCatching { deliverGatheredSdp() } } + } } - } - override fun onAddStream(stream: MediaStream?) = Unit - override fun onRemoveStream(stream: MediaStream?) = Unit - override fun onDataChannel(channel: DataChannel?) = Unit - override fun onRenegotiationNeeded() = Unit - override fun onAddTrack(receiver: RtpReceiver?, streams: Array?) { - // The other side's camera. Same JNI rules as above: store the - // reference, hop to main, and let the UI attach a renderer. - runCatching { - val track = receiver?.track() - if (track is VideoTrack) { - remoteVideoTrack = track - main.post { runCatching { onVideoTracksChanged?.invoke() } } + + override fun onAddStream(stream: MediaStream?) = Unit + + override fun onRemoveStream(stream: MediaStream?) = Unit + + override fun onDataChannel(channel: DataChannel?) = Unit + + override fun onRenegotiationNeeded() = Unit + + override fun onAddTrack( + receiver: RtpReceiver?, + streams: Array?, + ) { + // The other side's camera. + runCatching { + val track = receiver?.track() + if (track is VideoTrack) { + remoteVideoTrack = track + main.post { runCatching { onVideoTracksChanged?.invoke() } } + } } } } - } val pc = ensureFactory().createPeerConnection(config, observer) ?: return null val source = ensureFactory().createAudioSource(MediaConstraints()) @@ -294,13 +273,14 @@ class CallManager( audioSource = source audioTrack = track if (video) { - // Failure here downgrades to voice rather than killing the - // call, an emulator without a camera, or a denied permission - // mid-flow, should still connect and say something. + // Failure here downgrades to voice rather than killing the call, an + // emulator without a camera, or a denied permission mid-flow, + // should still connect and say something. runCatching { val cams = Camera2Enumerator(context) - val name = cams.deviceNames.firstOrNull { cams.isFrontFacing(it) } - ?: cams.deviceNames.firstOrNull() + val name = + cams.deviceNames.firstOrNull { cams.isFrontFacing(it) } + ?: cams.deviceNames.firstOrNull() if (name != null) { val cap = cams.createCapturer(name, null) val helper = SurfaceTextureHelper.create("fncap", eglBase.eglBaseContext) @@ -330,45 +310,48 @@ class CallManager( } /** Place a call. The offer is gathered, then sent as one encrypted envelope. */ - fun call(peerId: Long, turn: JSONObject?, video: Boolean = false) { + fun call( + peerId: Long, + turn: JSONObject?, + video: Boolean = false, + ) { // Supervision: enforced where the call would start, not where the // button is drawn, every entry point hits the same wall. - // Backstop only, the view model refuses first, with the message. if (ParentalControls.callsDisabled(context)) return if (state != State.IDLE && state != State.ENDED) return val callId = UUID.randomUUID().toString() - // `current` BEFORE the state change. The state setter notifies the - // observer, and the observer reads `current` to decide whether there - // is a call to draw. DIALLING with a null `current` cleared the - // banner the UI had just put up, and the caller's screen showed - // nothing until a second tap redrew it (while this guard ate the tap). + // `current` BEFORE the state change. current = Active(callId, peerId, incoming = false, video = video) state = State.DIALLING - val pc = open(turn, peerId, callId, video) ?: run { - current = null - state = State.IDLE - return - } - pc.createOffer(object : SimpleSdpObserver() { - override fun onCreateSuccess(sdp: SessionDescription) { - pc.setLocalDescription(SimpleSdpObserver("offer:setLocal"), sdp) - // The complete SDP, with candidates, exists only after - // gathering; see onGathered. DIALLING holds until the answer - // arrives, so the caller's screen says what is actually true. - main.post { - sendWhenGathered { full -> - signal(peerId, Content.CallSignal( - Content.CallSignal.OFFER, callId, full, video = video)) + val pc = + open(turn, peerId, callId, video) ?: run { + current = null + state = State.IDLE + return + } + pc.createOffer( + object : SimpleSdpObserver() { + override fun onCreateSuccess(sdp: SessionDescription) { + pc.setLocalDescription(SimpleSdpObserver("offer:setLocal"), sdp) + // The complete SDP, with candidates, exists only after + // gathering; see onGathered. + main.post { + sendWhenGathered { full -> + signal( + peerId, + Content.CallSignal(Content.CallSignal.OFFER, callId, full, video = video), + ) + } } } + }, + MediaConstraints(), + ) + // Give up out loud. + val guard = + Runnable { + if (state == State.DIALLING) hangUp(notifyPeer = true, reason = "timeout") } - }, MediaConstraints()) - // Give up out loud. Without this an unanswered call sat in - // "Calling…" forever, and the offer waiting in the callee's mailbox - // would ring long after this end had stopped listening. - val guard = Runnable { - if (state == State.DIALLING) hangUp(notifyPeer = true, reason = "timeout") - } dialGuard = guard main.postDelayed(guard, DIAL_TIMEOUT_MS) } @@ -377,47 +360,59 @@ class CallManager( private var pendingOffer: Pair? = null /** - * A signal arrived down the ratchet. [sentAt] is when the relay accepted - * it (epoch millis, 0 = unknown). + * A signal arrived down the ratchet. */ - fun onSignal(fromUser: Long, incoming: Content.CallSignal, turn: JSONObject?, - sentAt: Long = 0) { - android.util.Log.i("fnmsg-call", - "call signal in: ${incoming.action} from $fromUser state=$state") + fun onSignal( + fromUser: Long, + incoming: Content.CallSignal, + turn: JSONObject?, + sentAt: Long = 0, + ) { + android.util.Log.i( + "fnmsg-call", + "call signal in: ${incoming.action} from $fromUser state=$state", + ) when (incoming.action) { Content.CallSignal.OFFER -> { // Supervision: a device with calls off does not ring, the // caller is answered busy, like any other unavailable state. if (ParentalControls.callsDisabled(context)) { runCatching { - signal(fromUser, Content.CallSignal( - Content.CallSignal.END, incoming.callId, reason = "busy")) + signal( + fromUser, + Content.CallSignal(Content.CallSignal.END, incoming.callId, reason = "busy"), + ) } return } - // An offer older than the caller's own patience is not a - // call. The caller gives up after DIAL_TIMEOUT_MS; one that - // aged in the mailbox while this app was closed would ring for - // a call nobody is on the other end of, and answering it - // connects to nothing. The view model files it as missed. + // An offer older than the caller's own patience is not a call. if (sentAt > 0 && System.currentTimeMillis() - sentAt > DIAL_TIMEOUT_MS) { - android.util.Log.i("fnmsg-call", - "offer from $fromUser dropped: ${System.currentTimeMillis() - sentAt}ms old") + android.util.Log.i( + "fnmsg-call", + "offer from $fromUser dropped: ${System.currentTimeMillis() - sentAt}ms old", + ) onStaleOffer?.invoke(fromUser, sentAt) return } - // One call at a time. A second offer while busy is declined - // rather than silently dropped, so the caller hears something. + // One call at a time. if (state != State.IDLE && state != State.ENDED) { - signal(fromUser, Content.CallSignal( - Content.CallSignal.END, incoming.callId, reason = "busy")) + signal( + fromUser, + Content.CallSignal(Content.CallSignal.END, incoming.callId, reason = "busy"), + ) return } pendingOffer = fromUser to incoming - current = Active(incoming.callId, fromUser, incoming = true, - video = incoming.video) + current = + Active( + incoming.callId, + fromUser, + incoming = true, + video = incoming.video, + ) state = State.RINGING } + Content.CallSignal.ANSWER -> { val sdp = incoming.sdp ?: return // An answer for some earlier call, a retry raced a hang-up, @@ -432,7 +427,10 @@ class CallManager( SessionDescription(SessionDescription.Type.ANSWER, sdp), ) } - Content.CallSignal.END -> hangUp(notifyPeer = false) + + Content.CallSignal.END -> { + hangUp(notifyPeer = false) + } } } @@ -440,29 +438,38 @@ class CallManager( var onStaleOffer: ((Long, Long) -> Unit)? = null /** Accept the call that is ringing. */ + /** - * Build and send the answer. Split out so it can only be reached from - * `setRemoteDescription`'s success callback, the ordering IS the fix. + * Build and send the answer. */ private fun answerNow( pc: PeerConnection, fromUser: Long, offer: Content.CallSignal, ) { - pc.createAnswer(object : SimpleSdpObserver("answer:create") { - override fun onCreateSuccess(answer: SessionDescription) { - pc.setLocalDescription(SimpleSdpObserver("answer:setLocal"), answer) - // Same as the offer: the answer is complete only after - // gathering, and one sent early names no address to reach. - main.post { - sendWhenGathered { full -> - signal(fromUser, Content.CallSignal( - Content.CallSignal.ANSWER, offer.callId, full, - video = offer.video)) + pc.createAnswer( + object : SimpleSdpObserver("answer:create") { + override fun onCreateSuccess(answer: SessionDescription) { + pc.setLocalDescription(SimpleSdpObserver("answer:setLocal"), answer) + // Same as the offer: the answer is complete only after + // gathering, and one sent early names no address to reach. + main.post { + sendWhenGathered { full -> + signal( + fromUser, + Content.CallSignal( + Content.CallSignal.ANSWER, + offer.callId, + full, + video = offer.video, + ), + ) + } } } - } - }, MediaConstraints()) + }, + MediaConstraints(), + ) } fun accept(turn: JSONObject?) { @@ -471,23 +478,13 @@ class CallManager( val sdp = offer.sdp ?: return state = State.CONNECTING armConnectGuard() - val pc = open(turn, fromUser, offer.callId, offer.video) ?: run { - state = State.IDLE - return - } + val pc = + open(turn, fromUser, offer.callId, offer.video) ?: run { + state = State.IDLE + return + } current = Active(offer.callId, fromUser, incoming = true, video = offer.video) // `createAnswer` waits for the remote description to be APPLIED. - // These two were fired back to back: `setRemoteDescription` is - // asynchronous, and WebRTC cannot answer an offer it has not finished - // reading. When it lost that race `createAnswer` failed, - // `setLocalDescription` never ran, and the send path found no local - // description and gave up, "NOT SENDING the offer/answer ... the other - // side will never see this call". Accepting a call did nothing, the - // caller rang on until it timed out, and neither end said why. - // - // It is a RACE, which is what made it look intermittent: on a quiet - // device the description often lands first and the call connects, so - // the same build both works and fails depending on load. pc.setRemoteDescription( object : SimpleSdpObserver("answer:setRemote") { override fun onSetSuccess() { @@ -502,8 +499,10 @@ class CallManager( fun decline() { val (fromUser, offer) = pendingOffer ?: return pendingOffer = null - signal(fromUser, Content.CallSignal( - Content.CallSignal.END, offer.callId, reason = "declined")) + signal( + fromUser, + Content.CallSignal(Content.CallSignal.END, offer.callId, reason = "declined"), + ) // ENDED while `current` still names the caller, so the observer can // file the declined call, see the ordering note in hangUpInner. state = State.ENDED @@ -513,19 +512,17 @@ class CallManager( /** * End the call and release everything. - * - * The microphone is held by the audio source, not by the connection. - * Closing one without disposing the other leaves the mic live with no call - * attached, the worst possible failure for this particular feature. */ + /** - * Re-entrancy-safe on purpose. Tearing a connection down makes - * WebRTC fire state callbacks, and a callback that calls back in here - * would dispose the very object it is running inside. + * Re-entrancy-safe on purpose. */ private var tearingDown = false - fun hangUp(notifyPeer: Boolean, reason: String? = null) { + fun hangUp( + notifyPeer: Boolean, + reason: String? = null, + ) { if (tearingDown) return tearingDown = true try { @@ -535,7 +532,10 @@ class CallManager( } } - private fun hangUpInner(notifyPeer: Boolean, reason: String?) { + private fun hangUpInner( + notifyPeer: Boolean, + reason: String?, + ) { onGathered = null gatherGuard?.let { main.removeCallbacks(it) } gatherGuard = null @@ -545,24 +545,28 @@ class CallManager( connectGuard = null val active = current if (notifyPeer && active != null) { - // Best effort. Telling the other end is worth doing and is not - // worth failing the teardown over, the relay can be rate limiting - // us, or simply unreachable. + // Best effort. runCatching { - signal(active.peerId, - Content.CallSignal(Content.CallSignal.END, active.callId, reason = reason)) + signal( + active.peerId, + Content.CallSignal(Content.CallSignal.END, active.callId, reason = reason), + ) } } - connection?.let { runCatching { it.close() }; runCatching { it.dispose() } } + connection?.let { + runCatching { it.close() } + runCatching { it.dispose() } + } connection = null audioTrack?.let { runCatching { it.dispose() } } audioTrack = null audioSource?.let { runCatching { it.dispose() } } audioSource = null - // The camera, released in capture order. Same rule as the mic: the - // capturer holds the hardware, not the connection, and a missed - // dispose leaves the camera indicator on with no call attached. - videoCapturer?.let { runCatching { it.stopCapture() }; runCatching { it.dispose() } } + // The camera, released in capture order. + videoCapturer?.let { + runCatching { it.stopCapture() } + runCatching { it.dispose() } + } videoCapturer = null videoHelper?.let { runCatching { it.dispose() } } videoHelper = null @@ -572,11 +576,10 @@ class CallManager( remoteVideoTrack = null main.post { runCatching { onVideoTracksChanged?.invoke() } } pendingOffer = null - // ENDED fires while `current` still says who the call was with, - // the observer files the call log entry from it, and nulling `current` + // ENDED fires while `current` still says who the call was with, the + // observer files the call log entry from it, and nulling `current` // first (as this used to) meant NO call, answered or missed, was ever // recorded: by the time the state changed there was nobody to file. - // IDLE follows with `current` cleared so the UI banner comes down. state = State.ENDED current = null state = State.IDLE @@ -599,9 +602,7 @@ class CallManager( const val CONNECT_TIMEOUT_MS = 30_000L /** - * How long to wait for ICE gathering before sending what we have. An - * unreachable TURN server stalls COMPLETE for tens of seconds; the - * host and STUN candidates in hand by now usually still connect. + * How long to wait for ICE gathering before sending what we have. */ const val GATHER_TIMEOUT_MS = 8_000L } @@ -609,24 +610,27 @@ class CallManager( /** * The offer the background service received, parked for the UI to answer. - * - * The service could ring but nobody could pick up. The service and the - * ViewModel each poll the same mailbox with their OWN session, and whichever - * polls first consumes the envelope, so an offer that arrived while the app - * was backgrounded was decrypted by the service, turned into a notification, - * and its SDP thrown away. Tapping the notification opened an app that had - * never seen the offer and had nothing to accept. Both run in the same - * process, so the handoff is this holder: the service parks the offer, the - * ViewModel collects it on its next poll tick and rings properly. */ object PendingIncomingCall { - data class Parked(val fromUser: Long, val signal: Content.CallSignal, val at: Long) + data class Parked( + val fromUser: Long, + val signal: Content.CallSignal, + val at: Long, + ) @Volatile private var parked: Parked? = null - fun park(fromUser: Long, signal: Content.CallSignal, sentAt: Long) { - parked = Parked(fromUser, signal, - if (sentAt > 0) sentAt else System.currentTimeMillis()) + fun park( + fromUser: Long, + signal: Content.CallSignal, + sentAt: Long, + ) { + parked = + Parked( + fromUser, + signal, + if (sentAt > 0) sentAt else System.currentTimeMillis(), + ) } /** The caller hung up or the call was answered elsewhere, stop offering it. */ @@ -636,19 +640,13 @@ object PendingIncomingCall { /** * Drop whatever is parked, whichever call it is. - * - * For Decline from the lock screen, where the id is not to hand. An - * offer left parked would be answered the next time the app opened, - * ringing for a call the user turned down an hour ago. */ - fun clearAll() { parked = null } + fun clearAll() { + parked = null + } /** * Whether something is parked that is already too old to answer. - * - * Distinct from [take] on purpose: `take` consumes, and the ring has to be - * able to ASK without consuming, a stale check that swallowed a live offer - * would drop a call that was still ringing. */ fun isStale(now: Long = System.currentTimeMillis()): Boolean { val current = parked ?: return false @@ -665,23 +663,21 @@ object PendingIncomingCall { } /** Most SDP callbacks have nothing to say; this stops four stubs per call site. */ + /** * This discarded every SDP failure, and that is why calls were undebuggable. - * `createOffer` or `setLocalDescription` could fail for any reason and nothing - * anywhere recorded it, the offer then simply never went out, the callee never - * rang, and the database showed no envelope had ever been created. Four hours of - * "the call failed" with no cause, because the two callbacks that knew returned - * `Unit`. - * - * A `label` so the log says WHICH step failed. "setLocalDescription failed" - * and "createAnswer failed" send you to different places. */ -private open class SimpleSdpObserver(private val label: String = "sdp") : SdpObserver { +private open class SimpleSdpObserver( + private val label: String = "sdp", +) : SdpObserver { override fun onCreateSuccess(sdp: SessionDescription) = Unit + override fun onSetSuccess() = Unit + override fun onCreateFailure(error: String?) { android.util.Log.w("fnmsg-call", "$label: createOffer/createAnswer FAILED: $error") } + override fun onSetFailure(error: String?) { android.util.Log.w("fnmsg-call", "$label: setLocal/setRemoteDescription FAILED: $error") } diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/CallScreen.kt b/app/src/main/kotlin/io/flamenet/messenger/app/CallScreen.kt index 0067db1..955acab 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/CallScreen.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/CallScreen.kt @@ -32,21 +32,6 @@ import androidx.compose.ui.unit.sp /** * A call, as a screen. - * - * Android had no call screen at all, it had a banner. A line of text - * and two `TextButton`s stacked above whatever screen you happened to be on, - * so the most attention-demanding thing the app ever does looked like a - * notice. iOS has had a full screen throughout; the kit draws one; this is the - * parity gap that mattered most, and the one a person is most likely to be - * fumbling with under time pressure. - * - * Plain values, no `CallManager`. Same split iOS uses: presentation that - * takes a name and a status can be put in front of the screenshot harness; a - * state machine cannot, and a call screen nobody can look at is a call screen - * nobody has checked. - * - * The kit animates a 400pt ring. A static ring pair stands in and says so, - * an animation that never resolves is worse than none. */ @Composable fun CallScreenBody( @@ -56,26 +41,32 @@ fun CallScreenBody( /** Shown under the status when this relay has no TURN server. */ warning: String? = null, ) { - val initials = peerName.trim().split(" ").filter { it.isNotBlank() } - .take(2).mapNotNull { it.firstOrNull() }.joinToString("").uppercase().ifEmpty { "?" } + val initials = + peerName + .trim() + .split(" ") + .filter { it.isNotBlank() } + .take(2) + .mapNotNull { it.firstOrNull() } + .joinToString("") + .uppercase() + .ifEmpty { "?" } Column( - modifier = Modifier - .fillMaxSize() - // Back to the theme's own background: the gradient this used to - // avoid painting over went with the purple theme (reverted - // 2026-09-08), so there is nothing left to clash with. - .background(MaterialTheme.colorScheme.background) - .padding(horizontal = Kit.margin) - .testTag("call-screen"), + modifier = + Modifier + .fillMaxSize() + // Back to the theme's own background: the gradient this used to + // avoid painting over went with the purple theme (reverted + // 2026-09-08), so there is nothing left to clash with. + .background(MaterialTheme.colorScheme.background) + .padding(horizontal = Kit.margin) + .testTag("call-screen"), horizontalAlignment = Alignment.CenterHorizontally, ) { Spacer(Modifier.height(Space.xxl)) - // No rings. A pair of translucent `primary` circles sat behind the - // avatar as a stand-in for the kit's animation, and against the - // gradient they read as banding, two hard edges around the one thing - // on screen. The avatar carries the centre on its own. + // No rings. Box(contentAlignment = Alignment.Center) { Box( modifier = Modifier.size(128.dp).clip(CircleShape).background(Palette.AvatarFill), @@ -113,9 +104,7 @@ fun CallScreenBody( Spacer(Modifier.weight(1f)) - // The kit puts the secondaries on an arc around a 72dp primary. A row - // above it reproduces the reading order without pretending to - // trigonometry the kit did by hand. + // The kit puts the secondaries on an arc around a 72dp primary. val primary = actions.firstOrNull { it.isPrimary } val others = actions.filterNot { it.isPrimary } if (others.isNotEmpty()) { @@ -142,16 +131,20 @@ data class CallAction( ) @Composable -private fun CallButton(action: CallAction, diameter: androidx.compose.ui.unit.Dp) { +private fun CallButton( + action: CallAction, + diameter: androidx.compose.ui.unit.Dp, +) { Column(horizontalAlignment = Alignment.CenterHorizontally) { Box( - modifier = Modifier - .size(diameter) - .clip(CircleShape) - .background(action.tint ?: MaterialTheme.colorScheme.surfaceVariant) - .clickable(onClick = action.run) - .semantics { contentDescription = action.label } - .testTag("call-${action.id}"), + modifier = + Modifier + .size(diameter) + .clip(CircleShape) + .background(action.tint ?: MaterialTheme.colorScheme.surfaceVariant) + .clickable(onClick = action.run) + .semantics { contentDescription = action.label } + .testTag("call-${action.id}"), contentAlignment = Alignment.Center, ) { Icon( @@ -168,55 +161,101 @@ private fun CallButton(action: CallAction, diameter: androidx.compose.ui.unit.Dp /** * Map the live call onto the screen above. - * - * Returns null when there is no call, so the caller can fall through to the - * ordinary tabs. The privacy prompt stays a banner deliberately, it is a - * question asked BEFORE a call exists, and a full screen for it would look - * like a call that is already happening. */ @Composable -fun LiveCallScreen(model: MessengerViewModel, phase: MessengerViewModel.Phase.SignedIn): Boolean { +fun LiveCallScreen( + model: MessengerViewModel, + phase: MessengerViewModel.Phase.SignedIn, +): Boolean { val call = phase.call ?: return false if (call.state == CallManager.State.IDLE) return false - val status = when (call.state) { - CallManager.State.RINGING -> - if (call.incoming) "Incoming call" else "Ringing…" - CallManager.State.DIALLING -> "Calling…" - CallManager.State.CONNECTING -> "Connecting…" - CallManager.State.ACTIVE -> "On a call" - else -> "Call ended" - } + val status = + when (call.state) { + CallManager.State.RINGING -> { + if (call.incoming) "Incoming call" else "Ringing…" + } - val actions = when { - call.state == CallManager.State.RINGING && call.incoming -> listOf( - CallAction("decline", KitIcon.CallEnd, "Decline", - tint = MaterialTheme.colorScheme.error, iconColor = Color.White, - run = model::declineCall), - CallAction("accept", KitIcon.Call, "Answer", isPrimary = true, - tint = Palette.Teal, iconColor = Color.White, - run = model::acceptCall), - ) - call.state == CallManager.State.ACTIVE -> listOf( - CallAction("mute", if (call.muted) KitIcon.MicOff else KitIcon.Mic, - if (call.muted) "Unmute" else "Mute", - tint = if (call.muted) MaterialTheme.colorScheme.primary else null, - iconColor = if (call.muted) Color.White else null, - run = { model.setCallMuted(!call.muted) }), - CallAction("end", KitIcon.CallEnd, "End", isPrimary = true, - tint = MaterialTheme.colorScheme.error, iconColor = Color.White, - run = model::hangUp), - ) - else -> listOf( - CallAction("end", KitIcon.CallEnd, "Cancel", isPrimary = true, - tint = MaterialTheme.colorScheme.error, iconColor = Color.White, - run = model::hangUp), - ) - } + CallManager.State.DIALLING -> { + "Calling…" + } - // A video call with anything to show renders surfaces instead of the - // ring; the same action row overlays the bottom. Voice calls and video - // calls still connecting keep the kit's ring screen. + CallManager.State.CONNECTING -> { + "Connecting…" + } + + CallManager.State.ACTIVE -> { + "On a call" + } + + else -> { + "Call ended" + } + } + + val actions = + when { + call.state == CallManager.State.RINGING && call.incoming -> { + listOf( + CallAction( + "decline", + KitIcon.CallEnd, + "Decline", + tint = MaterialTheme.colorScheme.error, + iconColor = Color.White, + run = model::declineCall, + ), + CallAction( + "accept", + KitIcon.Call, + "Answer", + isPrimary = true, + tint = Palette.Teal, + iconColor = Color.White, + run = model::acceptCall, + ), + ) + } + + call.state == CallManager.State.ACTIVE -> { + listOf( + CallAction( + "mute", + if (call.muted) KitIcon.MicOff else KitIcon.Mic, + if (call.muted) "Unmute" else "Mute", + tint = if (call.muted) MaterialTheme.colorScheme.primary else null, + iconColor = if (call.muted) Color.White else null, + run = { model.setCallMuted(!call.muted) }, + ), + CallAction( + "end", + KitIcon.CallEnd, + "End", + isPrimary = true, + tint = MaterialTheme.colorScheme.error, + iconColor = Color.White, + run = model::hangUp, + ), + ) + } + + else -> { + listOf( + CallAction( + "end", + KitIcon.CallEnd, + "Cancel", + isPrimary = true, + tint = MaterialTheme.colorScheme.error, + iconColor = Color.White, + run = model::hangUp, + ), + ) + } + } + + // A video call with anything to show renders surfaces instead of the ring; + // the same action row overlays the bottom. val manager = model.activeCallManager() val showVideo = call.video && call.state == CallManager.State.ACTIVE && manager != null if (showVideo) { @@ -231,21 +270,21 @@ fun LiveCallScreen(model: MessengerViewModel, phase: MessengerViewModel.Phase.Si peerName = call.peerName, status = status, actions = actions, - warning = if (call.relayless && call.state != CallManager.State.ACTIVE) { - "This server has no call relay, so the call only connects if a " + - "direct path exists between the two networks." - } else null, + warning = + if (call.relayless && call.state != CallManager.State.ACTIVE) { + "This server has no call relay, so the call only connects if a " + + "direct path exists between the two networks." + } else { + null + }, ) } return true } /** - * The video layout: their camera full-screen, yours in a corner card, the - * usual actions along the bottom, the kit's 08 Video Call artboard. - * - * `videoTick` exists to re-run this when tracks appear: the track objects - * live on the manager, not in Compose state, so nothing else would recompose. + * The video layout: their camera full-screen, yours in a corner card, the usual + * actions along the bottom, the kit's 08 Video Call artboard. */ @Composable private fun VideoCallBody( @@ -259,8 +298,12 @@ private fun VideoCallBody( val local = manager.localVideoTrack if (remote != null) { key(remote, videoTick) { - VideoSurface(manager, remote, mirror = false, - modifier = Modifier.fillMaxSize()) + VideoSurface( + manager, + remote, + mirror = false, + modifier = Modifier.fillMaxSize(), + ) } } else { Text( @@ -272,12 +315,18 @@ private fun VideoCallBody( if (local != null) { key(local, videoTick) { VideoSurface( - manager, local, mirror = true, - modifier = Modifier - .align(Alignment.TopEnd) - .padding(Kit.margin) - .size(width = 108.dp, height = 144.dp) - .clip(androidx.compose.foundation.shape.RoundedCornerShape(12.dp)), + manager, + local, + mirror = true, + modifier = + Modifier + .align(Alignment.TopEnd) + .padding(Kit.margin) + .size(width = 108.dp, height = 144.dp) + .clip( + androidx.compose.foundation.shape + .RoundedCornerShape(12.dp), + ), ) } } @@ -285,8 +334,12 @@ private fun VideoCallBody( modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = Space.xl), horizontalAlignment = Alignment.CenterHorizontally, ) { - Text(peerName, color = Color.White, fontSize = 16.sp, - fontWeight = FontWeight.Medium) + Text( + peerName, + color = Color.White, + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + ) Spacer(Modifier.height(Space.md)) Row(horizontalArrangement = Arrangement.spacedBy(Space.lg)) { actions.forEach { CallButton(it, diameter = if (it.isPrimary) 72.dp else 48.dp) } @@ -296,9 +349,7 @@ private fun VideoCallBody( } /** - * One WebRTC renderer. Init and release travel with the composable, a - * SurfaceViewRenderer that outlives its sink leaks the EGL surface, and one - * released while still sinked crashes in native code. + * One WebRTC renderer. */ @Composable private fun VideoSurface( @@ -324,34 +375,33 @@ private fun VideoSurface( ) } - /** * A "start a video call" action that asks for the camera first. - * - * Returns a function of the peer id. The permission launcher must be created - * during composition, so this cannot be a plain method on the model, and - * asking at the moment of the tap is the only time the question makes sense - * to the person being asked. */ @Composable fun rememberVideoCall(model: MessengerViewModel): (Long) -> Unit { val context = androidx.compose.ui.platform.LocalContext.current - val pending = androidx.compose.runtime.remember { - androidx.compose.runtime.mutableStateOf(null) - } - val ask = androidx.activity.compose.rememberLauncherForActivityResult( - androidx.activity.result.contract.ActivityResultContracts.RequestPermission() - ) { granted -> - val peer = pending.value - pending.value = null - // Denied still calls, as voice-grade video (their picture, no ours is - // wrong; the manager simply has no camera to add, so it degrades). - if (peer != null && granted) model.requestCall(peer, video = true) - } + val pending = + androidx.compose.runtime.remember { + androidx.compose.runtime.mutableStateOf(null) + } + val ask = + androidx.activity.compose.rememberLauncherForActivityResult( + androidx.activity.result.contract.ActivityResultContracts + .RequestPermission(), + ) { granted -> + val peer = pending.value + pending.value = null + // Denied still calls, as voice-grade video (their picture, no ours is + // wrong; the manager simply has no camera to add, so it degrades). + if (peer != null && granted) model.requestCall(peer, video = true) + } return { peer -> - val has = androidx.core.content.ContextCompat.checkSelfPermission( - context, android.Manifest.permission.CAMERA - ) == android.content.pm.PackageManager.PERMISSION_GRANTED + val has = + androidx.core.content.ContextCompat.checkSelfPermission( + context, + android.Manifest.permission.CAMERA, + ) == android.content.pm.PackageManager.PERMISSION_GRANTED if (has) { model.requestCall(peer, video = true) } else { diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/CallsScreen.kt b/app/src/main/kotlin/io/flamenet/messenger/app/CallsScreen.kt index 3bb96fb..7fa770b 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/CallsScreen.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/CallsScreen.kt @@ -21,28 +21,14 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp /** * Calls placed and received, the kit's `08 Calls-Listing`. - * - * Measured out of `item/call/White`: a 48dp avatar, the name at 16pt, and a - * second line that is a 12dp direction icon followed by a 12pt time. The kit - * puts a video icon at x=255 and a call icon at x=303; only the call icon is - * here, because there is no video path in either CallManager and a control - * that does nothing is worse than a missing one. - * - * This screen needed a call log to exist first. Nothing recorded calls - * anywhere, there was a live call UI and a "missed call" notification from the - * relay, and no history at all, so "who rang me yesterday" had no answer. The - * log lives in the vault with everything else: who somebody called and when is - * precisely the metadata this app exists to keep off a server, and encrypting - * the words while leaving the call log in the clear would be an odd place to - * stop. */ @Composable fun ColumnScope.CallsScreen( @@ -50,9 +36,10 @@ fun ColumnScope.CallsScreen( phase: MessengerViewModel.Phase.SignedIn, ) { val videoCall = rememberVideoCall(model) - val showNewCall = androidx.compose.runtime.remember { - androidx.compose.runtime.mutableStateOf(false) - } + val showNewCall = + androidx.compose.runtime.remember { + androidx.compose.runtime.mutableStateOf(false) + } Row( modifier = Modifier.fillMaxWidth(), @@ -64,16 +51,16 @@ fun ColumnScope.CallsScreen( fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f), ) - // The way IN. Every other path to a call runs through an open - // conversation, this tab's own empty state used to admit as much. + // The way IN. Icon( KitIcon.Call, contentDescription = "New call", tint = Palette.Accent, - modifier = Modifier - .size(28.dp) - .clickable { showNewCall.value = true } - .testTag("new-call"), + modifier = + Modifier + .size(28.dp) + .clickable { showNewCall.value = true } + .testTag("new-call"), ) } @@ -88,14 +75,14 @@ fun ColumnScope.CallsScreen( Column { phase.buddies.forEach { buddy -> Row( - modifier = Modifier - .fillMaxWidth() - .clickable { - showNewCall.value = false - model.requestCall(buddy.userId) - } - .padding(vertical = 10.dp) - .testTag("new-call-${buddy.userId}"), + modifier = + Modifier + .fillMaxWidth() + .clickable { + showNewCall.value = false + model.requestCall(buddy.userId) + }.padding(vertical = 10.dp) + .testTag("new-call-${buddy.userId}"), verticalAlignment = Alignment.CenterVertically, ) { Text( @@ -104,20 +91,24 @@ fun ColumnScope.CallsScreen( maxLines = 1, overflow = TextOverflow.Ellipsis, ) - Icon(KitIcon.Call, contentDescription = null, - tint = Palette.Accent, modifier = Modifier.size(20.dp)) + Icon( + KitIcon.Call, + contentDescription = null, + tint = Palette.Accent, + modifier = Modifier.size(20.dp), + ) Spacer(Modifier.width(Space.md)) Icon( KitIcon.Video, contentDescription = "Video call", tint = Palette.Accent, - modifier = Modifier - .size(22.dp) - .clickable { - showNewCall.value = false - videoCall(buddy.userId) - } - .testTag("new-video-call-${buddy.userId}"), + modifier = + Modifier + .size(22.dp) + .clickable { + showNewCall.value = false + videoCall(buddy.userId) + }.testTag("new-video-call-${buddy.userId}"), ) } } @@ -133,9 +124,7 @@ fun ColumnScope.CallsScreen( } if (phase.calls.isEmpty()) { - // Says what would appear here. An empty screen with no explanation - // is a feature nobody discovers, and this one has no other entry point - // calls start from a conversation. + // Says what would appear here. Column( modifier = Modifier.fillMaxWidth().padding(vertical = Space.xxl), horizontalAlignment = Alignment.CenterHorizontally, @@ -149,10 +138,10 @@ fun ColumnScope.CallsScreen( ) Text("No calls yet", fontSize = 16.sp, fontWeight = FontWeight.Medium) Text( - "Calls you place and receive appear here. Start one from a " - + "conversation, they go directly between the two phones, " - + "so this list is the only record of them and it is on " - + "this device.", + "Calls you place and receive appear here. Start one from a " + + "conversation, they go directly between the two phones, " + + "so this list is the only record of them and it is on " + + "this device.", fontSize = 14.sp, color = Palette.SecondaryText, ) @@ -162,15 +151,18 @@ fun ColumnScope.CallsScreen( Column(modifier = Modifier.fillMaxWidth().testTag("calls")) { phase.calls.forEach { entry -> - val name = phase.buddies.firstOrNull { it.userId == entry.peerId } - ?.let { it.displayName.ifBlank { it.handle } } - ?: "user ${entry.peerId}" + val name = + phase.buddies + .firstOrNull { it.userId == entry.peerId } + ?.let { it.displayName.ifBlank { it.handle } } + ?: "user ${entry.peerId}" Row( - modifier = Modifier - .fillMaxWidth() - .clickable { model.requestCall(entry.peerId) } - .padding(vertical = Kit.rowGap / 2) - .testTag("call-${entry.peerId}-${entry.at}"), + modifier = + Modifier + .fillMaxWidth() + .clickable { model.requestCall(entry.peerId) } + .padding(vertical = Kit.rowGap / 2) + .testTag("call-${entry.peerId}-${entry.at}"), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Kit.gutter), ) { @@ -189,9 +181,9 @@ fun ColumnScope.CallsScreen( overflow = TextOverflow.Ellipsis, ) Row(verticalAlignment = Alignment.CenterVertically) { - // The direction icon the kit puts before the time, - // and the one thing that makes a missed call findable: - // an incoming call that never connected is the row + // The direction icon the kit puts before the time, and + // the one thing that makes a missed call findable: an + // incoming call that never connected is the row // somebody is actually looking for. Icon( if (entry.connected) KitIcon.Call else KitIcon.CallEnd, @@ -215,12 +207,13 @@ fun ColumnScope.CallsScreen( } // Ring them back, voice, or camera-first. Box( - modifier = Modifier - .size(Kit.icon + Space.md) - .clip(CircleShape) - .background(Palette.AccentSoft) - .clickable { videoCall(entry.peerId) } - .testTag("video-back-${entry.peerId}"), + modifier = + Modifier + .size(Kit.icon + Space.md) + .clip(CircleShape) + .background(Palette.AccentSoft) + .clickable { videoCall(entry.peerId) } + .testTag("video-back-${entry.peerId}"), contentAlignment = Alignment.Center, ) { Icon( @@ -232,12 +225,13 @@ fun ColumnScope.CallsScreen( } Spacer(Modifier.width(Space.sm)) Box( - modifier = Modifier - .size(Kit.icon + Space.md) - .clip(CircleShape) - .background(Palette.AccentSoft) - .clickable { model.requestCall(entry.peerId) } - .testTag("call-back-${entry.peerId}"), + modifier = + Modifier + .size(Kit.icon + Space.md) + .clip(CircleShape) + .background(Palette.AccentSoft) + .clickable { model.requestCall(entry.peerId) } + .testTag("call-back-${entry.peerId}"), contentAlignment = Alignment.Center, ) { Icon( diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt index 50ce6da..69e1e9c 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt @@ -4,17 +4,12 @@ import android.Manifest import android.content.pm.PackageManager import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.background -import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.ui.platform.LocalClipboardManager -import androidx.compose.ui.text.AnnotatedString +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -35,13 +30,17 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Checkbox import androidx.compose.material3.Divider +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.LocalTextStyle @@ -65,13 +64,14 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor -import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.PasswordVisualTransformation @@ -80,21 +80,17 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.content.ContextCompat import io.flamenet.client.IdentityTrust -import io.flamenet.e2e.Content import io.flamenet.e2e.ContactCard +import io.flamenet.e2e.Content import io.flamenet.e2e.SafetyNumber import io.flamenet.e2e.Vault +import kotlinx.coroutines.launch import java.text.SimpleDateFormat import java.util.Date import java.util.Locale -import kotlinx.coroutines.launch /** * The people you talk to. - * - * Presence is shown only where the relay actually reports it. An "online" - * dot that is really "we have not heard otherwise" is a claim about someone - * else's whereabouts, and this product should not make one it cannot support. */ @Composable fun ColumnScope.BuddyList( @@ -103,23 +99,13 @@ fun ColumnScope.BuddyList( query: String = "", /** * Whether the add-a-contact form is open. - * - * Hoisted out of this section on purpose. It was a heading row with - * its own button, "Contacts" beside "Add", and so was Groups, and the - * two of them ate the top third of a screen whose whole content was two - * rows. The kit puts actions in the app bar as icons and gives its list no - * headings at all. The state has to live up there with the button now. */ adding: Boolean = false, onAddingChange: (Boolean) -> Unit = {}, /** Opens the contact screen; the list itself owns no navigation. */ onOpenProfile: (Long) -> Unit = {}, ) { - // Only when there is something to tell it apart from. The kit's - // messages list has no section headings at all; ours holds two kinds of - // thing, so a label earns its place exactly when both kinds are on screen. - // With no groups, the common case, and every new account, "CONTACTS" was - // a heading sitting over the only list there is. + // Only when there is something to tell it apart from. if (shownGroupsExist(phase, query)) SectionLabel("Contacts") if (adding) { @@ -127,13 +113,7 @@ fun ColumnScope.BuddyList( } if (phase.buddies.isEmpty()) { - // Only claim the list is empty if it was actually FETCHED. A failed - // load also produces an empty list, and "Nobody here yet" is then a - // statement about the person's account rather than about the request, - // which is how somebody concludes their contacts are gone. - // - // Says what to do, not merely that there is nothing. An empty state - // that only reports emptiness leaves the person stuck. + // Only claim the list is empty if it was actually FETCHED. Text( if (phase.buddiesLoaded) { "Nobody here yet. Add someone by the username they signed up with." @@ -145,53 +125,34 @@ fun ColumnScope.BuddyList( return } - - // heightIn, not weight(1f). Inside its card `weight` always expands to - // the full height it is offered, so a single contact still reserved a - // fixed slab of empty white. A max lets the card size to its content - // and start scrolling only once the list is actually long. - // Plain rows in the page's own scroll, not a bounded lazy list. - // The list lived in a card with `heightIn(max = 320.dp)` because a lazy - // list inside a scrolling Column is measured with infinite height and - // crashes. Rendering the rows directly sidesteps that entirely, and the - // 320 cap was itself a compromise that made the list a small scrolling - // window inside a scrolling page, which is exactly what the kit's flat - // list is not. - // - // No recycling. Fine for a contact list of tens; if this ever holds - // hundreds it wants a single lazy list owning the whole screen's scroll, - // not a nested one. - val shown = phase.buddies.filter { - query.isBlank() || - it.displayName.contains(query, true) || it.handle.contains(query, true) - } - // Conversations on other relays get their own section, because they - // are not contacts and cannot be made into any. A remote peer has no - // account here, so nothing adds them to a roster, and without a row, a - // reply that arrives sits in a thread with no way back to it short of - // retyping a 52-character derived key. - val remote = phase.remoteAddresses.reversed().filter { - query.isBlank() || it.contains(query, true) - } + // heightIn, not weight(1f). + val shown = + phase.buddies.filter { + query.isBlank() || + it.displayName.contains(query, true) || it.handle.contains(query, true) + } + // Conversations on other relays get their own section, because they are not + // contacts and cannot be made into any. + val remote = + phase.remoteAddresses.reversed().filter { + query.isBlank() || it.contains(query, true) + } if (remote.isNotEmpty()) { SectionLabel("Other relays") Column(modifier = Modifier.fillMaxWidth().testTag("remote-threads")) { remote.forEach { address -> val unread = Vault.threadForAddress(address) in phase.unread Row( - modifier = Modifier - .fillMaxWidth() - .clickable { model.openAddressChat(address) } - .padding(vertical = Kit.rowGap / 2) - .testTag("remote-$address"), + modifier = + Modifier + .fillMaxWidth() + .clickable { model.openAddressChat(address) } + .padding(vertical = Kit.rowGap / 2) + .testTag("remote-$address"), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Kit.gutter), ) { - // No presence dot and no initials. We know nothing about - // this person beyond an address, their relay does not tell - // ours whether they are online, and initials taken off a - // base32 key would be two meaningless characters dressed up - // as a name. + // No presence dot and no initials. Avatar(name = "", online = false, picture = null, model = model) Column(modifier = Modifier.weight(1f)) { Text( @@ -201,10 +162,7 @@ fun ColumnScope.BuddyList( maxLines = 1, overflow = TextOverflow.Ellipsis, ) - // The address in full underneath, monospaced. An fnid - // is a derived key, not a name, one that is nearly - // right belongs to somebody else, so it is never - // abbreviated to something that merely looks familiar. + // The address in full underneath, monospaced. Text( address.substringBefore('@'), fontSize = 11.sp, @@ -221,33 +179,32 @@ fun ColumnScope.BuddyList( } Column(modifier = Modifier.fillMaxWidth().testTag("buddies")) { shown.forEach { buddy -> - val name = buddy.displayName.ifBlank { buddy.handle } - .ifBlank { "user ${buddy.userId}" } + val name = + buddy.displayName + .ifBlank { buddy.handle } + .ifBlank { "user ${buddy.userId}" } Row( - modifier = Modifier - .fillMaxWidth() - .clickable { model.openChat(buddy.userId, buddy.displayName) } - // Half the kit's 24dp row gap on each side, which makes the - // 72dp pitch it draws between 48dp rows. - .padding(vertical = Kit.rowGap / 2) - .testTag("buddy-${buddy.userId}"), + modifier = + Modifier + .fillMaxWidth() + .clickable { model.openChat(buddy.userId, buddy.displayName) } + // Half the kit's 24dp row gap on each side, which makes the + // 72dp pitch it draws between 48dp rows. + .padding(vertical = Kit.rowGap / 2) + .testTag("buddy-${buddy.userId}"), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Kit.gutter), ) { - // An avatar, which this row did not have. The kit's list - // is built around a 48dp avatar and a 16dp gutter; without it - // the row was a coloured dot beside two lines of text, and no - // amount of adjusting the type would have made it read like - // the design. Initials rather than a photo, because the relay - // stores no avatars and inventing a placeholder face would be - // worse than the letters. + // An avatar, which this row did not have. Avatar( name = name, online = buddy.presence.equals("online", ignoreCase = true), picture = phase.avatars[buddy.userId], model = model, ) - val unread = io.flamenet.e2e.Vault.threadForUser(buddy.userId) in phase.unread + val unread = + io.flamenet.e2e.Vault + .threadForUser(buddy.userId) in phase.unread val preview = phase.previews[buddy.userId] Column(modifier = Modifier.weight(1f)) { Text( @@ -258,20 +215,11 @@ fun ColumnScope.BuddyList( overflow = TextOverflow.Ellipsis, ) // The last message, which the kit's row leads with. - // This used to be presence, "online", because the relay - // sends no preview. It does not have to: the history is - // already decrypted on this device. Presence has not gone, - // it moved to the dot on the avatar, which is where the kit - // draws it and where it costs no line of its own. - // - // The handle only when it differs from the name, and only - // when there is nothing better, it rendered as - // "fnreviewbeta · online" under a row already titled - // "fnreviewbeta". Text( - preview?.let { - if (it.mine) "You: ${it.text}" else it.text - }?.takeIf { it.isNotBlank() } + preview + ?.let { + if (it.mine) "You: ${it.text}" else it.text + }?.takeIf { it.isNotBlank() } ?: listOfNotNull( buddy.handle.takeIf { h -> h.isNotBlank() && !h.equals(name, ignoreCase = true) @@ -284,10 +232,7 @@ fun ColumnScope.BuddyList( overflow = TextOverflow.Ellipsis, ) } - // A right-hand column. The kit puts the time at the top - // of it and the unread count under, and that column is most of - // what stops a row looking like a heading with nothing beside - // it. Ours had a bare 10dp dot and then an overflow button. + // A right-hand column. Column( horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(Space.xs), @@ -300,12 +245,13 @@ fun ColumnScope.BuddyList( ) if (unread) { Box( - modifier = Modifier - .heightIn(min = 16.dp) - .widthIn(min = 16.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.primary) - .testTag("unread-${buddy.userId}"), + modifier = + Modifier + .heightIn(min = 16.dp) + .widthIn(min = 16.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary) + .testTag("unread-${buddy.userId}"), contentAlignment = Alignment.Center, ) { Text( @@ -317,12 +263,8 @@ fun ColumnScope.BuddyList( } } } - // Everything you can do to a contact, reachable without a - // long-press nobody discovers. - // Opens the contact screen. This used to unfold a row of - // three text buttons under the entry. Block, Clear chat, - // Remove, with nowhere to explain what any of them did and - // nowhere to put a safety number. + // Everything you can do to a contact, reachable without a long- + // press nobody discovers. IconAction( icon = KitIcon.More, label = "About $name", @@ -335,84 +277,110 @@ fun ColumnScope.BuddyList( } } - /** * The kit's row time: "05:15 PM" today, "Yesterday", a weekday inside a week, - * then a date. Not "3 hours ago", the design shows a clock, and a relative - * phrase is wider than the column it has to sit in. + * then a date. */ internal fun relativeTime(epochMillis: Long): String { if (epochMillis <= 0L) return "" val now = java.util.Calendar.getInstance() - val then = java.util.Calendar.getInstance().apply { timeInMillis = epochMillis } - val sameDay = now.get(java.util.Calendar.YEAR) == then.get(java.util.Calendar.YEAR) && - now.get(java.util.Calendar.DAY_OF_YEAR) == then.get(java.util.Calendar.DAY_OF_YEAR) + val then = + java.util.Calendar + .getInstance() + .apply { timeInMillis = epochMillis } + val sameDay = + now.get(java.util.Calendar.YEAR) == then.get(java.util.Calendar.YEAR) && + now.get(java.util.Calendar.DAY_OF_YEAR) == then.get(java.util.Calendar.DAY_OF_YEAR) val days = ((now.timeInMillis - then.timeInMillis) / 86_400_000L).toInt() return when { - sameDay -> android.text.format.DateFormat.format("hh:mm a", then).toString() - days < 2 -> "Yesterday" - days < 7 -> android.text.format.DateFormat.format("EEE", then).toString() - else -> android.text.format.DateFormat.format("MMM d", then).toString() + sameDay -> { + android.text.format.DateFormat + .format("hh:mm a", then) + .toString() + } + + days < 2 -> { + "Yesterday" + } + + days < 7 -> { + android.text.format.DateFormat + .format("EEE", then) + .toString() + } + + else -> { + android.text.format.DateFormat + .format("MMM d", then) + .toString() + } } } /** * An attachment, fetched and decrypted on first view. - * - * Space is reserved from the reference's dimensions before the bytes - * arrive, so the thread does not jump as images land. The reference carries - * width and height for exactly this reason. */ @Composable /** Internal, not private: the stories feed draws the same blob. */ -internal fun AttachedImage(ref: io.flamenet.e2e.Attachment.Ref, model: MessengerViewModel) { +internal fun AttachedImage( + ref: io.flamenet.e2e.Attachment.Ref, + model: MessengerViewModel, +) { var bitmap by remember(ref.id) { mutableStateOf(null) } var failed by remember(ref.id) { mutableStateOf(false) } LaunchedEffect(ref.id) { val bytes = model.attachmentBytes(ref) - if (bytes == null) { failed = true; return@LaunchedEffect } - // Bounded: these bytes came from a peer. See ImagePrep.decodeBounded. + if (bytes == null) { + failed = true + return@LaunchedEffect + } + // Bounded: these bytes came from a peer. bitmap = ImagePrep.decodeBounded(bytes) if (bitmap == null) failed = true } - val ratio = if (ref.width > 0 && ref.height > 0) { - ref.width.toFloat() / ref.height.toFloat() - } else 1f + val ratio = + if (ref.width > 0 && ref.height > 0) { + ref.width.toFloat() / ref.height.toFloat() + } else { + 1f + } Box( - modifier = Modifier - .widthIn(max = 260.dp) - .aspectRatio(ratio) - .clip(RoundedCornerShape(10.dp)) - .background(MaterialTheme.colorScheme.surfaceVariant), + modifier = + Modifier + .widthIn(max = 260.dp) + .aspectRatio(ratio) + .clip(RoundedCornerShape(10.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant), contentAlignment = Alignment.Center, ) { val loaded = bitmap when { - loaded != null -> androidx.compose.foundation.Image( - bitmap = loaded.asImageBitmap(), - contentDescription = "Attached image", - modifier = Modifier.fillMaxWidth(), - contentScale = androidx.compose.ui.layout.ContentScale.Crop, - ) - // Says which half failed. "Could not load" covers a network error, - // a wrong key and a relay serving different bytes, and those are - // very different things to be told. - failed -> Text("Image unavailable", style = MaterialTheme.typography.bodySmall) - else -> Text("Loading…", style = MaterialTheme.typography.bodySmall) + loaded != null -> { + androidx.compose.foundation.Image( + bitmap = loaded.asImageBitmap(), + contentDescription = "Attached image", + modifier = Modifier.fillMaxWidth(), + contentScale = androidx.compose.ui.layout.ContentScale.Crop, + ) + } + + // Says which half failed. + failed -> { + Text("Image unavailable", style = MaterialTheme.typography.bodySmall) + } + + else -> { + Text("Loading…", style = MaterialTheme.typography.bodySmall) + } } } } /** * Groups: list them, make one, open one. - * - * Member names are resolved here, from the contact list. The relay stores - * ids and nothing else on purpose, it has no user table and should not grow - * one, because a relay that caches display names is a relay that leaks them. - * A member you have never added shows as an id, which is honest. */ @Composable fun ColumnScope.GroupsSection( @@ -427,9 +395,9 @@ fun ColumnScope.GroupsSection( var name by remember { mutableStateOf("") } var chosen by remember { mutableStateOf(setOf()) } - // The heading always renders now, because it CARRIES the way in, the - // New-group toggle sits beside the label, as it does on iOS, instead of - // in the screen's top bar two lists away from the list it feeds. + // The heading always renders now, because it CARRIES the way in, the New- + // group toggle sits beside the label, as it does on iOS, instead of in the + // screen's top bar two lists away from the list it feeds. Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, @@ -439,10 +407,11 @@ fun ColumnScope.GroupsSection( if (creating) KitIcon.Close else KitIcon.NewGroup, contentDescription = "New group", tint = Palette.Accent, - modifier = Modifier - .size(22.dp) - .clickable { onCreatingChange(!creating) } - .testTag("new-group"), + modifier = + Modifier + .size(22.dp) + .clickable { onCreatingChange(!creating) } + .testTag("new-group"), ) } @@ -455,19 +424,25 @@ fun ColumnScope.GroupsSection( modifier = Modifier.fillMaxWidth().testTag("group-name"), ) if (phase.buddies.isEmpty()) { - Text("Add a contact first, a group needs somebody in it.", - style = MaterialTheme.typography.bodySmall) + Text( + "Add a contact first, a group needs somebody in it.", + style = MaterialTheme.typography.bodySmall, + ) } phase.buddies.forEach { buddy -> Row( - modifier = Modifier - .fillMaxWidth() - .clickable { - chosen = if (buddy.userId in chosen) chosen - buddy.userId - else chosen + buddy.userId - } - .padding(vertical = 8.dp) - .testTag("group-member-${buddy.userId}"), + modifier = + Modifier + .fillMaxWidth() + .clickable { + chosen = + if (buddy.userId in chosen) { + chosen - buddy.userId + } else { + chosen + buddy.userId + } + }.padding(vertical = 8.dp) + .testTag("group-member-${buddy.userId}"), horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically, ) { @@ -479,7 +454,9 @@ fun ColumnScope.GroupsSection( text = "Create group", onClick = { model.createGroup(name.trim(), chosen.toList()) - name = ""; chosen = emptySet(); onCreatingChange(false) + name = "" + chosen = emptySet() + onCreatingChange(false) }, enabled = name.isNotBlank() && chosen.isNotEmpty() && !phase.busy, modifier = Modifier.fillMaxWidth(), @@ -487,13 +464,7 @@ fun ColumnScope.GroupsSection( ) } - // Loaded-and-empty says nothing; failed still speaks. The two used - // to share one sentence, and the reason is worth keeping: an empty list - // rendering NOTHING gave no way to tell "you have no groups" from "the - // list did not load". But a line of prose in the middle of a list, for the - // ordinary state of a new account, is chrome, and the section label above - // it made two lines of furniture for zero rows. A failure is still worth a - // sentence, because that one IS news. + // Loaded-and-empty says nothing; failed still speaks. if (phase.groups.isEmpty() && !phase.groupsLoaded) { Text( "Couldn't load your groups. Check the connection and sign in again.", @@ -503,26 +474,27 @@ fun ColumnScope.GroupsSection( ) } - val shownGroups = phase.groups.filter { - query.isBlank() || it.name.contains(query, true) - } + val shownGroups = + phase.groups.filter { + query.isBlank() || it.name.contains(query, true) + } shownGroups.forEach { group -> - val thread = io.flamenet.e2e.Vault.threadForGroup(group.id) + val thread = + io.flamenet.e2e.Vault + .threadForGroup(group.id) Row( - modifier = Modifier - .fillMaxWidth() - .clickable { model.openGroup(group) } - // Same 72dp pitch as a contact row, from the same tokens. A - // group row measured differently sat at a different rhythm in - // the same list, which reads as two lists that happen to - // follow each other. - .padding(vertical = Kit.rowGap / 2) - .testTag("group-${group.id}"), + modifier = + Modifier + .fillMaxWidth() + .clickable { model.openGroup(group) } + // Same 72dp pitch as a contact row, from the same tokens. + .padding(vertical = Kit.rowGap / 2) + .testTag("group-${group.id}"), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Kit.gutter), ) { // The same avatar shape as a contact, so the two kinds of row read - // as one list. A group has no presence, so no dot. + // as one list. Avatar(name = group.name.ifBlank { "Group" }, online = false) Column(modifier = Modifier.weight(1f)) { Text( @@ -532,13 +504,17 @@ fun ColumnScope.GroupsSection( ) Text( // "You" first: you are a member of your own group and are - // not in your own buddy list, so this used to read - // "user 31, fnreviewbeta". + // not in your own buddy list, so this used to read "user + // 31, fnreviewbeta". group.memberIds.joinToString(", ") { id -> - if (id == phase.userId) "You" - else phase.buddies.firstOrNull { it.userId == id } - ?.let { b -> b.displayName.ifBlank { b.handle } } - ?: "user $id" + if (id == phase.userId) { + "You" + } else { + phase.buddies + .firstOrNull { it.userId == id } + ?.let { b -> b.displayName.ifBlank { b.handle } } + ?: "user $id" + } }, fontSize = 14.sp, color = Palette.SecondaryText, @@ -546,9 +522,14 @@ fun ColumnScope.GroupsSection( ) } if (thread in phase.unread) { - Box(modifier = Modifier.size(10.dp).clip(CircleShape) - .background(MaterialTheme.colorScheme.primary) - .testTag("unread-group-${group.id}")) + Box( + modifier = + Modifier + .size(10.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary) + .testTag("unread-group-${group.id}"), + ) } } Divider() @@ -557,20 +538,18 @@ fun ColumnScope.GroupsSection( /** Presence, which is a claim about you and therefore yours to set. */ @Composable -fun PresencePicker(model: MessengerViewModel, current: String) { +fun PresencePicker( + model: MessengerViewModel, + current: String, +) { // A heading, because without one this was four unlabelled chips. - // They sat at the bottom of Account under the address card with nothing - // saying what they were, and the person who asked for the feature could not - // find it, which is the same as it not being there. Text("Your status", style = MaterialTheme.typography.titleMedium) Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically, ) { - // Chips, not four orange words in a row. As text buttons these read - // as four links and gave no sign that they were a single choice with - // one answer, the selected one was merely bold. + // Chips, not four orange words in a row. MessengerViewModel.PRESENCE_CHOICES.forEach { status -> KitChip( text = status, @@ -606,10 +585,7 @@ fun ColumnScope.NotificationsScreen( verticalAlignment = Alignment.CenterVertically, ) { Text("Alerts", style = MaterialTheme.typography.headlineMedium) - // Only when there is something to mark. It was offered - // unconditionally, so an empty screen showed a control whose only - // possible effect was nothing, and it was the most prominent thing on - // that screen. + // Only when there is something to mark. if (unread > 0) { KitChip( text = "Mark all read", @@ -622,10 +598,6 @@ fun ColumnScope.NotificationsScreen( if (phase.notifications.isEmpty()) { // An empty state, not a stray sentence under the title. - // "Nothing new." tucked against the heading read as a subtitle of the - // heading rather than as the content of the screen, and left the other - // nine tenths of it blank with no explanation of what would ever - // appear there. Column( modifier = Modifier.fillMaxWidth().padding(vertical = Space.xl * 2), horizontalAlignment = Alignment.CenterHorizontally, @@ -639,8 +611,8 @@ fun ColumnScope.NotificationsScreen( ) Text("Nothing new", fontSize = 16.sp, fontWeight = FontWeight.Medium) Text( - "Contact requests and things that happened while you were away " - + "show up here.", + "Contact requests and things that happened while you were away " + + "show up here.", fontSize = 14.sp, color = Palette.SecondaryText, textAlign = androidx.compose.ui.text.style.TextAlign.Center, @@ -649,29 +621,22 @@ fun ColumnScope.NotificationsScreen( return } - // Rows on the kit's 72dp pitch with an avatar, like every other list - // in the app. This was a lazy list of two stacked text lines with an 8dp - // dot beside the unread ones, a different rhythm, a different type scale - // and a different unread marker from the contact list two taps away. One - // app, one row grammar. - // - // Plain rows in the page's own scroll, for the same reason the buddy - // list is: a lazy list inside a scrolling Column is measured with infinite - // height and crashes. + // Rows on the kit's 72dp pitch with an avatar, like every other list in the + // app. Column(modifier = Modifier.fillMaxWidth().testTag("notifications")) { phase.notifications.forEach { note -> Row( - modifier = Modifier - .fillMaxWidth() - .clickable { model.markNotificationRead(note.id) } - .padding(vertical = Kit.rowGap / 2), + modifier = + Modifier + .fillMaxWidth() + .clickable { model.markNotificationRead(note.id) } + .padding(vertical = Kit.rowGap / 2), horizontalArrangement = Arrangement.spacedBy(Kit.gutter), verticalAlignment = Alignment.CenterVertically, ) { - // A glyph for the kind, not initials OF the kind, that - // rendered "contact request" as "CR" and "missed call" as - // "MC", two letters that look like a person's initials and - // name nobody. + // A glyph for the kind, not initials OF the kind, that rendered + // "contact request" as "CR" and "missed call" as "MC", two + // letters that look like a person's initials and name nobody. AlertGlyph(note.kind) Column(modifier = Modifier.weight(1f)) { Text( @@ -686,10 +651,11 @@ fun ColumnScope.NotificationsScreen( // size, on the same edge. if (!note.read) { Box( - modifier = Modifier - .size(Kit.presenceDot) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.primary) + modifier = + Modifier + .size(Kit.presenceDot) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary), ) } } @@ -703,10 +669,6 @@ fun ColumnScope.NotificationsScreen( /** * The account. - * - * The two destructive actions confirm in place rather than in a dialog. A - * dialog is dismissed reflexively; a button that changes to say what it is - * about to do is read. */ @Composable fun ColumnScope.AccountScreen( @@ -743,14 +705,8 @@ fun ColumnScope.AccountScreen( SectionBand() SettingsSection { StoryAudienceSection(model, phase) } - // Wallet and Files are NOT here any more, they are bar destinations as - // of 2026-09-07, in the same place iOS puts them. They lived here because - // the kit's bar is four icons and a wallet "does not earn the slot"; what - // that produced was two features reachable only by scrolling most of the - // way down this screen, which somebody testing the app reasonably read as - // them not being built. Second entry points are not kept alongside: two - // routes to one screen is two things to keep in step, and the bar is the - // one a person looks at. + // Wallet and Files are NOT here any more, they are bar destinations as of + // 2026-09-07, in the same place iOS puts them. SectionBand() SettingsSection { AppLockSection() } @@ -765,9 +721,6 @@ fun ColumnScope.AccountScreen( SettingsSection { HistoryBackupSection(model) } // Sits with the other settings and not under anything privacy-shaped. - // A bridge is about REACHING the relay on a hostile network, not hiding - // from it, filing it as privacy would make the claim the section itself - // is careful not to make. SectionBand() SettingsSection { DisplaySection() } @@ -791,9 +744,7 @@ fun ColumnScope.AccountScreen( SectionBand() SettingsSection { - // The row is the label; the field must not repeat it. "Display - // name" over a box whose placeholder also said "Display name" is the - // same duplication the contact rows had. + // The row is the label; the field must not repeat it. SettingsRow(label = "Display name") {} OutlinedTextField( value = displayName, @@ -805,7 +756,10 @@ fun ColumnScope.AccountScreen( Spacer(Modifier.height(Space.sm)) KitPrimaryButton( text = "Save display name", - onClick = { model.updateDisplayName(displayName.trim()); displayName = "" }, + onClick = { + model.updateDisplayName(displayName.trim()) + displayName = "" + }, enabled = displayName.isNotBlank() && !phase.busy, modifier = Modifier.fillMaxWidth(), tag = "save-display-name", @@ -827,14 +781,17 @@ fun ColumnScope.AccountScreen( Spacer(Modifier.height(Space.sm)) KitPrimaryButton( text = "Change password", - onClick = { model.updatePassword(password); password = "" }, + onClick = { + model.updatePassword(password) + password = "" + }, enabled = password.length >= 8 && !phase.busy, modifier = Modifier.fillMaxWidth(), tag = "save-password", ) } - // -- Security (§19) ------------------------------------------------------ + // -- Security (§19) ------------------------------------------------------. SectionBand() SettingsSection { @@ -849,10 +806,10 @@ fun ColumnScope.AccountScreen( modifier = Modifier.testTag("fork-detected"), ) Text( - "Two devices compared what this server told each of them about its key log, and " - + "the answers cannot both be true. A server doing that can hand different " - + "people different keys for the same person. Treat conversations here as " - + "compromised until you have verified safety numbers in person.", + "Two devices compared what this server told each of them about its key log, and " + + "the answers cannot both be true. A server doing that can hand different " + + "people different keys for the same person. Treat conversations here as " + + "compromised until you have verified safety numbers in person.", fontSize = 12.sp, color = MaterialTheme.colorScheme.error, ) @@ -861,16 +818,14 @@ fun ColumnScope.AccountScreen( // This switch used to be what turned checking ON, and it was off by // default, so the protection shipped and almost nobody had it. - // Checking is now automatic on any server that keeps a log; what is - // left here is the narrower question of what to do where there is - // nothing to check against. SettingsRow( label = "Also refuse servers with no log", - description = "Your app already checks that a contact's keys appear in the public " - + "log this server publishes, on its own, before the first message. A server " - + "old enough to keep no log has nothing to show, and those chats go ahead " - + "unchecked, turn this on to refuse them instead. Only if you know your " - + "server publishes a log, or nobody on it will be reachable.", + description = + "Your app already checks that a contact's keys appear in the public " + + "log this server publishes, on its own, before the first message. A server " + + "old enough to keep no log has nothing to show, and those chats go ahead " + + "unchecked, turn this on to refuse them instead. Only if you know your " + + "server publishes a log, or nobody on it will be reachable.", ) { Switch( checked = phase.requireKeyLog, @@ -881,56 +836,67 @@ fun ColumnScope.AccountScreen( SettingsRow( label = "Sealed sender", - description = "Messages you send are addressed to the recipient without naming you. " - + "Because nothing names the sender, the server cannot block one, so blocking " - + "somebody now also cancels the one-use passes you gave them, which is the part " - + "that can be aimed at a single person. This replaces the shared key instead, " - + "which everybody holds: it is the way to cut off somebody you cannot identify, " - + "and it costs every contact until you next message them.", + description = + "Messages you send are addressed to the recipient without naming you. " + + "Because nothing names the sender, the server cannot block one, so blocking " + + "somebody now also cancels the one-use passes you gave them, which is the part " + + "that can be aimed at a single person. This replaces the shared key instead, " + + "which everybody holds: it is the way to cut off somebody you cannot identify, " + + "and it costs every contact until you next message them.", ) { - KitChip(text = "New key", selected = false, - onClick = model::rotateDeliveryKey, tag = "rotate-delivery-key") + KitChip( + text = "New key", + selected = false, + onClick = model::rotateDeliveryKey, + tag = "rotate-delivery-key", + ) } } SectionBand() SettingsSection { AppVersionSection(model) } - // The destructive pair, in their own section. They confirm in place - // rather than in a dialog: a dialog is dismissed reflexively; a button that - // changes to say what it is about to do is read. + // The destructive pair, in their own section. SectionBand() SettingsSection { SettingsRow( label = "Sign out", - description = "Ends this session and keeps your messages on this phone, " - + "so signing back in picks up where you left off.", + description = + "Ends this session and keeps your messages on this phone, " + + "so signing back in picks up where you left off.", tag = "sign-out", onClick = { model.signOut() }, ) // The one that erases is its own row, and is not the default. - // Both used to be the same button: signing out for an afternoon - // destroyed this phone's identity, so coming back made every contact - // accept a safety-number change first. Erasing is a real thing people - // need, handing a phone on, but it is the rarer thing, and it must be - // chosen rather than stumbled into. Confirms in place, like the pair - // below it, because a dialog is dismissed reflexively. SettingsRow( - label = if (confirmingErase) "Really erase, this cannot be undone" - else "Sign out and erase this device", - description = "Also removes this phone's keys and stored messages. " - + "Choose this if you are handing the phone on.", + label = + if (confirmingErase) { + "Really erase, this cannot be undone" + } else { + "Sign out and erase this device" + }, + description = + "Also removes this phone's keys and stored messages. " + + "Choose this if you are handing the phone on.", tag = "sign-out-erase", onClick = { - if (confirmingErase) model.signOut(erasingDevice = true) - else confirmingErase = true + if (confirmingErase) { + model.signOut(erasingDevice = true) + } else { + confirmingErase = true + } }, ) SettingsRow( - label = if (confirmingDelete) "Really delete, this cannot be undone" - else "Delete account", - description = "Deletes the account on ${phase.relay} and erases this phone's keys " - + "and messages.", + label = + if (confirmingDelete) { + "Really delete, this cannot be undone" + } else { + "Delete account" + }, + description = + "Deletes the account on ${phase.relay} and erases this phone's keys " + + "and messages.", tag = "delete-account", onClick = { if (confirmingDelete) model.deleteAccount() else confirmingDelete = true }, ) { @@ -945,29 +911,12 @@ fun ColumnScope.AccountScreen( Spacer(Modifier.height(Space.lg)) } - /** * Your profile picture: show it, change it, remove it. - * - * It is sent to contacts, not published. The relay stores an encrypted - * blob it cannot open, and the key reaches each contact inside the ratchet, - * so somebody who has never messaged you cannot see your face, and changing it - * means telling every contact again. That trade is stated on the screen rather - * than left for somebody to discover when a new contact sees initials. */ + /** * Which build this is, and a button to go and find out if it is current. - * - * A sideloaded app has to answer this on demand. The dialog in - * [UpdateAvailableDialog] can be declined, and should be, but then there has - * to be a place that still knows. This is also the answer to "which version am - * I running", which is the first question any support conversation opens with. - * - * A button, not an automatic check on entering the screen. Opening - * Account is not a request to talk to the network, and the background check - * already runs on its own schedule. Pressing this is the user asking, which is - * also the only reason it is allowed to fall back to our site, see - * [AppUpdate.checkNow]. */ @Composable private fun ColumnScope.AppVersionSection(model: MessengerViewModel) { @@ -982,8 +931,9 @@ private fun ColumnScope.AppVersionSection(model: MessengerViewModel) { SettingsRow( label = "Version $installed", - description = "This app installs by hand rather than through a store, so it " - + "cannot update itself.", + description = + "This app installs by hand rather than through a store, so it " + + "cannot update itself.", tag = "app-version", ) @@ -1001,31 +951,31 @@ private fun ColumnScope.AppVersionSection(model: MessengerViewModel) { tag = "check-for-updates", ) - // Every branch names the host that answered. "Up to date" without saying - // according to whom is a claim the app cannot actually make, a relay can - // be behind, and on a self-hosted one the answer may have come from our - // site instead. + // Every branch names the host that answered. when (val outcome = result) { - null -> Unit + null -> { + Unit + } - is AppUpdate.CheckResult.UpToDate -> Text( - "Version $installed is the newest ${outcome.source} publishes.", - fontSize = 12.sp, - color = Palette.SecondaryText, - modifier = Modifier.testTag("update-none"), - ) + is AppUpdate.CheckResult.UpToDate -> { + Text( + "Version $installed is the newest ${outcome.source} publishes.", + fontSize = 12.sp, + color = Palette.SecondaryText, + modifier = Modifier.testTag("update-none"), + ) + } // Says plainly that it could not tell, rather than "up to date". - // A relay that publishes no manifest and a site that could not be - // reached both produce silence, and reporting silence as good news is - // how somebody sits on a build with a fix in it believing otherwise. - is AppUpdate.CheckResult.Unknown -> Text( - "Could not check. Neither this relay nor flamenetmessenger.com said " - + "which build is current, that is not the same as being up to date.", - fontSize = 12.sp, - color = Palette.SecondaryText, - modifier = Modifier.testTag("update-unknown"), - ) + is AppUpdate.CheckResult.Unknown -> { + Text( + "Could not check. Neither this relay nor flamenetmessenger.com said " + + "which build is current, that is not the same as being up to date.", + fontSize = 12.sp, + color = Palette.SecondaryText, + modifier = Modifier.testTag("update-unknown"), + ) + } is AppUpdate.CheckResult.Available -> { val release = outcome.release @@ -1047,15 +997,14 @@ private fun ColumnScope.AppVersionSection(model: MessengerViewModel) { tag = "download-update", ) Text( - "Opens your browser. Installing over the top keeps your messages and your " - + "account. Android asks you to allow installs from the browser the " - + "first time.", + "Opens your browser. Installing over the top keeps your messages and your " + + "account. Android asks you to allow installs from the browser the " + + "first time.", fontSize = 12.sp, color = Palette.SecondaryText, ) // For anyone who wants to check the file they got is the file we - // published. Small, because most people will never use it and it - // should not compete with the button. + // published. release.sha256?.let { Text("SHA-256 $it", fontSize = 10.sp, color = Palette.SecondaryText) } @@ -1065,10 +1014,6 @@ private fun ColumnScope.AppVersionSection(model: MessengerViewModel) { /** * The app lock toggle. - * - * Offered only when the device can actually authenticate. A switch that - * turns on and then cannot show a prompt locks somebody out of their own - * messages with no way back, which is worse than not having the feature. */ @Composable private fun ColumnScope.AppLockSection() { @@ -1096,16 +1041,15 @@ private fun ColumnScope.AppLockSection() { Text("Require unlock to open", fontSize = 16.sp, modifier = Modifier.weight(1f)) Switch( checked = enabled, - onCheckedChange = { on -> lock.enabled = on; enabled = on }, + onCheckedChange = { on -> + lock.enabled = on + enabled = on + }, modifier = Modifier.testTag("app-lock"), ) } - // Says what it does NOT do. The vault and the ratchet keys are protected - // by the Android keystore, not by this switch, and turning it on - // re-encrypts nothing, this stops somebody who picks up your unlocked - // phone, which is the threat people actually have. Claiming more would be - // selling a screen as if it were a key. + // Says what it does NOT do. Text( "Asks for your fingerprint, face or device PIN before showing your " + "messages, and again after a minute away. It stops someone who " + @@ -1118,23 +1062,15 @@ private fun ColumnScope.AppLockSection() { /** * Who may see this account's stories. - * - * Enforced by who is SENT the bytes. A story is sealed to each - * recipient's devices through the ratchet, so somebody outside this list is - * never sent it, there is no copy on the relay for them to be denied. Every - * mainstream product holds one copy server-side and decides who is shown it; - * this cannot work that way even if somebody wanted it to, and that is the - * feature. - * - * There is no "public" option and cannot be one. A story only ever goes to - * contacts, because it has to be encrypted to somebody in particular. */ + /** Plain words: "168 hours" would make the reader do the division. */ -private fun storyLifetimeLabel(hours: Int): String = when { - hours < 24 -> if (hours == 1) "1 hour" else "$hours hours" - hours == 24 -> "24 hours" - else -> (hours / 24).let { if (it == 1) "1 day" else "$it days" } -} +private fun storyLifetimeLabel(hours: Int): String = + when { + hours < 24 -> if (hours == 1) "1 hour" else "$hours hours" + hours == 24 -> "24 hours" + else -> (hours / 24).let { if (it == 1) "1 day" else "$it days" } + } @Composable private fun ColumnScope.StoryAudienceSection( @@ -1152,8 +1088,7 @@ private fun ColumnScope.StoryAudienceSection( text = "All contacts", selected = audience.mode == Session.AudienceMode.EVERYONE, onClick = { - model.setStoryAudience( - Session.StoryAudience(Session.AudienceMode.EVERYONE, audience.userIds)) + model.setStoryAudience(Session.StoryAudience(Session.AudienceMode.EVERYONE, audience.userIds)) }, tag = "audience-everyone", ) @@ -1161,8 +1096,7 @@ private fun ColumnScope.StoryAudienceSection( text = "Only chosen", selected = audience.mode == Session.AudienceMode.ONLY, onClick = { - model.setStoryAudience( - Session.StoryAudience(Session.AudienceMode.ONLY, audience.userIds)) + model.setStoryAudience(Session.StoryAudience(Session.AudienceMode.ONLY, audience.userIds)) }, tag = "audience-only", ) @@ -1170,8 +1104,7 @@ private fun ColumnScope.StoryAudienceSection( text = "All except", selected = audience.mode == Session.AudienceMode.EXCEPT, onClick = { - model.setStoryAudience( - Session.StoryAudience(Session.AudienceMode.EXCEPT, audience.userIds)) + model.setStoryAudience(Session.StoryAudience(Session.AudienceMode.EXCEPT, audience.userIds)) }, tag = "audience-except", ) @@ -1179,14 +1112,19 @@ private fun ColumnScope.StoryAudienceSection( Text( when (audience.mode) { - Session.AudienceMode.EVERYONE -> + Session.AudienceMode.EVERYONE -> { "Every contact receives your stories. Nobody else can, a story " + "is encrypted to the people it goes to, so there is no copy " + "for a stranger to find." - Session.AudienceMode.ONLY -> + } + + Session.AudienceMode.ONLY -> { "Only the people ticked below are sent your stories." - Session.AudienceMode.EXCEPT -> + } + + Session.AudienceMode.EXCEPT -> { "Every contact except the people ticked below." + } }, fontSize = 14.sp, color = Palette.SecondaryText, @@ -1195,9 +1133,7 @@ private fun ColumnScope.StoryAudienceSection( Spacer(Modifier.height(Space.lg)) Text("How long your stories last", style = MaterialTheme.typography.titleMedium) - // A day used to be the only answer. The expiry rides inside the sealed - // story, so this is the poster's choice travelling with every copy, not a - // rule the relay is being asked to keep. + // A day used to be the only answer. val storyContext = LocalContext.current val mediaSettings = remember { MediaSettings(storyContext) } var storyHours by remember { mutableStateOf(mediaSettings.storyLifetimeHours) } @@ -1218,12 +1154,10 @@ private fun ColumnScope.StoryAudienceSection( } } Text( - // Says what it does NOT promise. "Disappears after" alone reads as a - // guarantee about other people's phones, and a viewer who screenshots a - // story has it for good. - "Stories you post from now on disappear after this long, on your device " - + "and your contacts'. Ones already posted keep the time they were " - + "sent with.", + // Says what it does NOT promise. + "Stories you post from now on disappear after this long, on your device " + + "and your contacts'. Ones already posted keep the time they were " + + "sent with.", fontSize = 14.sp, color = Palette.SecondaryText, ) @@ -1237,15 +1171,19 @@ private fun ColumnScope.StoryAudienceSection( val name = buddy.displayName.ifBlank { buddy.handle } val ticked = buddy.userId in audience.userIds Row( - modifier = Modifier - .fillMaxWidth() - .clickable { - val next = if (ticked) audience.userIds - buddy.userId - else audience.userIds + buddy.userId - model.setStoryAudience(audience.copy(userIds = next)) - } - .padding(vertical = Space.sm) - .testTag("audience-${buddy.userId}"), + modifier = + Modifier + .fillMaxWidth() + .clickable { + val next = + if (ticked) { + audience.userIds - buddy.userId + } else { + audience.userIds + buddy.userId + } + model.setStoryAudience(audience.copy(userIds = next)) + }.padding(vertical = Space.sm) + .testTag("audience-${buddy.userId}"), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Kit.gutter), ) { @@ -1278,20 +1216,25 @@ private fun ColumnScope.ProfilePicture( phase: MessengerViewModel.Phase.SignedIn, ) { val context = LocalContext.current - val pick = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> - uri ?: return@rememberLauncherForActivityResult - val bytes = runCatching { - context.contentResolver.openInputStream(uri)?.use { it.readBytes() } - }.getOrNull() ?: return@rememberLauncherForActivityResult - val bounds = android.graphics.BitmapFactory.Options().apply { inJustDecodeBounds = true } - android.graphics.BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds) - model.setAvatar( - bytes, - context.contentResolver.getType(uri) ?: "image/jpeg", - bounds.outWidth, - bounds.outHeight, - ) - } + val pick = + rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> + uri ?: return@rememberLauncherForActivityResult + val bytes = + runCatching { + context.contentResolver.openInputStream(uri)?.use { it.readBytes() } + }.getOrNull() ?: return@rememberLauncherForActivityResult + val bounds = + android.graphics.BitmapFactory + .Options() + .apply { inJustDecodeBounds = true } + android.graphics.BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds) + model.setAvatar( + bytes, + context.contentResolver.getType(uri) ?: "image/jpeg", + bounds.outWidth, + bounds.outHeight, + ) + } Text("Profile picture", style = MaterialTheme.typography.titleMedium) Spacer(Modifier.height(Space.md)) @@ -1347,11 +1290,8 @@ private fun SettingsSection(content: @Composable ColumnScope.() -> Unit) { /** * The 16pt band the kit puts between sections. - * - * Full bleed, which is why it takes a negative inset: the screen frame pads - * everything by 20 and this has to reach the edges to read as a band rather - * than as a wide grey row. */ + /** Internal, not private: the contact detail uses the same band. */ @Composable internal fun SectionBandPublic() = SectionBand() @@ -1359,22 +1299,18 @@ internal fun SectionBandPublic() = SectionBand() @Composable private fun SectionBand() { Box( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 0.dp) - .height(16.dp) - .background(MaterialTheme.colorScheme.surfaceVariant) + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 0.dp) + .height(16.dp) + .background(MaterialTheme.colorScheme.surfaceVariant), ) } /** * One settings row: label, an optional sentence under it, and one control on * the trailing edge. - * - * The description belongs to the row, at 12pt, under its label, not as a - * paragraph floating between controls, which is what this screen had. A - * setting whose explanation runs longer than the section it lives in needs a - * shorter explanation, not more room. */ @Composable private fun SettingsRow( @@ -1385,11 +1321,12 @@ private fun SettingsRow( trailing: @Composable (() -> Unit)? = null, ) { Row( - modifier = Modifier - .fillMaxWidth() - .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier) - .then(if (tag != null) Modifier.testTag(tag) else Modifier) - .padding(vertical = Kit.bubblePadding), + modifier = + Modifier + .fillMaxWidth() + .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier) + .then(if (tag != null) Modifier.testTag(tag) else Modifier) + .padding(vertical = Kit.bubblePadding), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Space.md), ) { @@ -1405,10 +1342,6 @@ private fun SettingsRow( /** * A 48dp initials avatar with the presence dot on its corner. - * - * Two letters at most. Three-word display names produced a smear at this - * size, and the point of initials is that they are recognisable at a glance - * rather than complete. */ @Composable internal fun Avatar( @@ -1419,72 +1352,71 @@ internal fun Avatar( model: MessengerViewModel? = null, size: androidx.compose.ui.unit.Dp = Kit.avatar, ) { - val initials = remember(name) { - name.trim().split(" ").filter { it.isNotBlank() } - .take(2).mapNotNull { it.firstOrNull() } - .joinToString("").uppercase().ifEmpty { "?" } - } + val initials = + remember(name) { + name + .trim() + .split(" ") + .filter { it.isNotBlank() } + .take(2) + .mapNotNull { it.firstOrNull() } + .joinToString("") + .uppercase() + .ifEmpty { "?" } + } Box(contentAlignment = Alignment.Center) { Box( - modifier = Modifier - .size(size) - .clip(CircleShape) - .background(Palette.AvatarFill), + modifier = + Modifier + .size(size) + .clip(CircleShape) + .background(Palette.AvatarFill), contentAlignment = Alignment.Center, ) { // Initials are not a placeholder to be replaced when the bytes // arrive, they are what most people will have, because a picture // only exists once somebody has sent one and only reaches contacts - // they have messaged. They are the normal case, not the fallback. + // they have messaged. if (picture != null && model != null) { AttachedImage(picture, model) } else { - Text( - initials, - fontSize = (size.value * 0.33f).sp, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.primary, - ) + Text( + initials, + fontSize = (size.value * 0.33f).sp, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary, + ) } } - // Hides THEIR dot from this phone, not this phone from them. The - // relay still tells contacts when this account is online whichever way - // the setting is set. iOS shipped the same control named "Show online - // status" on a Privacy screen, where it read as the reciprocal thing; - // it was renamed on 2026-09-06 and this one is named honestly from the - // start. - val showDot = LocalContext.current.let { ctx -> - remember { DisplaySettings(ctx).showOnlineStatus } - } + // Hides THEIR dot from this phone, not this phone from them. + val showDot = + LocalContext.current.let { ctx -> + remember { DisplaySettings(ctx).showOnlineStatus } + } if (online && showDot) { Box( - modifier = Modifier - .size(Kit.presenceDot) - .align(Alignment.BottomEnd) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.surface) - .padding(2.dp) - .clip(CircleShape) - .background(Palette.Teal), + modifier = + Modifier + .size(Kit.presenceDot) + .align(Alignment.BottomEnd) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surface) + .padding(2.dp) + .clip(CircleShape) + .background(Palette.Teal), ) } } } /** - * A section label at the smallest size that still separates two lists. - * - * 12sp muted, not a heading. A heading at 16 or 20 with a button beside it - * is a bar, and two bars for two short lists is most of a phone screen spent - * on saying what the next three rows are. + * A section label at the smallest size that still separates two lists. 12sp + * muted, not a heading. */ + /** - * A bar action: one glyph in a tappable circle, with the real label for - * anyone who cannot see it. - * - * Glyphs rather than a vector icon set. This app ships none, and adding a - * dependency for eight buttons is a supply chain for decoration. They are - * sized like the kit's 24dp icons. + * A bar action: one glyph in a tappable circle, with the real label for anyone + * who cannot see it. */ @Composable private fun IconAction( @@ -1495,19 +1427,24 @@ private fun IconAction( onClick: () -> Unit, ) { Box( - modifier = Modifier - .size(Kit.icon + Space.md) - .clip(CircleShape) - .clickable(onClick = onClick) - .semantics { contentDescription = label } - .testTag(tag), + modifier = + Modifier + .size(Kit.icon + Space.md) + .clip(CircleShape) + .clickable(onClick = onClick) + .semantics { contentDescription = label } + .testTag(tag), contentAlignment = Alignment.Center, ) { Icon( icon, contentDescription = null, - tint = if (danger) MaterialTheme.colorScheme.error - else MaterialTheme.colorScheme.primary, + tint = + if (danger) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.primary + }, modifier = Modifier.size(Kit.icon), ) } @@ -1521,13 +1458,14 @@ private fun shownGroupsExist( /** One circle per kind of alert, in the same 48dp footprint as an avatar. */ @Composable private fun AlertGlyph(kind: String) { - val symbol = when { - kind.contains("call", true) -> KitIcon.Call - kind.contains("contact", true) || kind.contains("request", true) -> KitIcon.Add - kind.contains("group", true) -> KitIcon.NewGroup - kind.contains("story", true) -> KitIcon.Stories - else -> KitIcon.Dot - } + val symbol = + when { + kind.contains("call", true) -> KitIcon.Call + kind.contains("contact", true) || kind.contains("request", true) -> KitIcon.Add + kind.contains("group", true) -> KitIcon.NewGroup + kind.contains("story", true) -> KitIcon.Stories + else -> KitIcon.Dot + } Box( modifier = Modifier.size(Kit.avatar).clip(CircleShape).background(Palette.AvatarFill), contentAlignment = Alignment.Center, @@ -1555,38 +1493,20 @@ private fun SectionLabel(text: String) { @Composable private fun PresenceDot(status: String) { - // These were an olive, a mustard and a rust, the previous brand's - // palette, left behind when the accent was changed. Nothing else on the - // screen is those colours any more, which is exactly how a dot ends up - // looking like it belongs to a different app. - val colour = when (status.lowercase()) { - "online" -> Palette.Teal - "away" -> Palette.AccentBright - "busy" -> Palette.Pink - else -> Palette.SecondaryText - } + // These were an olive, a mustard and a rust, the previous brand's palette, + // left behind when the accent was changed. + val colour = + when (status.lowercase()) { + "online" -> Palette.Teal + "away" -> Palette.AccentBright + "busy" -> Palette.Pink + else -> Palette.SecondaryText + } Box(modifier = Modifier.size(10.dp).clip(CircleShape).background(colour)) } /** * "This person is not in your contacts", with the two things worth offering. - * - * The message has already arrived, and that is on purpose. Hiding a - * first message behind an accept/ignore gate is the obvious design and it is - * the one that shipped as a bug here: for a week, a first message from anyone - * not already in your contacts was completely invisible, no row, no name, - * just an unread badge over "Nobody here yet". The app now adopts a stranger so - * their message can be seen at all. - * - * But adoption makes a decision for the user: somebody is in your contacts - * because they wrote to you, which is not the same as you choosing them. This - * is the seam, delivery unchanged, the difference made visible, and the choice - * handed back. - * - * Absent for anybody accepted, which after the relay's backfill is every - * contact that existed before this shipped. A banner on an established - * conversation would be noise, and noise is how a safety label stops being - * read. */ @Composable private fun ColumnScope.StrangerBanner( @@ -1612,11 +1532,9 @@ private fun ColumnScope.StrangerBanner( fontWeight = FontWeight.Medium, ) Text( - // Says what happened rather than warning about it. Most first - // messages are somebody you gave your address to, and opening - // with an alarm would be wrong far more often than right. - "They messaged you, so they were added automatically. You can keep them " - + "or stop them reaching you.", + // Says what happened rather than warning about it. + "They messaged you, so they were added automatically. You can keep them " + + "or stop them reaching you.", fontSize = 12.sp, color = Palette.SecondaryText, ) @@ -1639,17 +1557,7 @@ private fun ColumnScope.StrangerBanner( tag = "block-stranger", ) } - // The case android#20 is really about. A Report on each - // message satisfies the rule; this is where it helps. Somebody who - // has just been written to by a stranger is looking at exactly this - // banner, and their only options were to keep them or block them, - // and blocking tells whoever runs the server nothing, so the - // account moves on to the next person. - // - // Below the row and quieter than both, deliberately. Most first - // messages are somebody you gave your address to; giving Report - // equal weight would frame every one of them as a threat, which is - // wrong far more often than it is right. + // The case android#20 is really about. androidx.compose.material3.TextButton( onClick = onReport, modifier = Modifier.testTag("report-stranger"), @@ -1677,19 +1585,13 @@ fun ColumnScope.ChatScreen( // Keyed on the thread, so switching conversations re-reads rather than // carrying one person's half-written message into somebody else's composer. var draft by remember(chat.threadId) { mutableStateOf(model.openDraft()) } - // Every write to `draft` goes through here. Sending clears the - // composer by assigning to the state directly in three places, and each one - // that forgot to also clear the stored copy would bring the message back the - // next time the conversation was opened, as a draft of something already - // sent, which is worse than not having drafts at all. + // Every write to `draft` goes through here. val setDraft: (String) -> Unit = { text -> draft = text model.rememberDraft(text) } - // Recorded by ID at the moment of picking, never re-derived from the - // text when sending. Deriving it would mean typing somebody's name reached - // them whether or not they were chosen, and renaming a contact would - // silently unmention them. Same rule as iOS. + // Recorded by ID at the moment of picking, never re-derived from the text + // when sending. var pendingMentions by remember { mutableStateOf(emptySet()) } val listState = rememberLazyListState() @@ -1697,17 +1599,12 @@ fun ColumnScope.ChatScreen( LaunchedEffect(Unit) { if (chat.lines.isNotEmpty()) listState.scrollToItem(chat.lines.lastIndex) } - // Follow the conversation as it grows. Without this a reply arrives - // off-screen and the app looks like it did nothing. + // Follow the conversation as it grows. LaunchedEffect(chat.lines.size) { if (chat.lines.isNotEmpty()) listState.animateScrollToItem(chat.lines.lastIndex) } - // One bar, not three stacked rows. Back and the name sat on one - // line, Call on a line of its own beneath it, and the identity panel - // under that, so a third of the conversation was chrome before a single - // message. The kit's conversation bar is 88 tall and holds all of it: - // back, the name over its subtitle, and the actions on the trailing edge. + // One bar, not three stacked rows. Row( modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp), verticalAlignment = Alignment.CenterVertically, @@ -1721,13 +1618,13 @@ fun ColumnScope.ChatScreen( fontWeight = FontWeight.Medium, ) // The kit's second line: a presence dot, then a 12sp fact. - // A group says how many people are in it and shows no dot, a - // group has many presences and one dot cannot speak for them. - val presence = chat.peerId?.let { id -> - buddies.firstOrNull { it.userId == id }?.presence - } - val subtitle = chat.group?.let { "${it.memberIds.size} members" } - ?: presence?.takeIf { it.isNotBlank() } + val presence = + chat.peerId?.let { id -> + buddies.firstOrNull { it.userId == id }?.presence + } + val subtitle = + chat.group?.let { "${it.memberIds.size} members" } + ?: presence?.takeIf { it.isNotBlank() } subtitle?.let { line -> Row(verticalAlignment = Alignment.CenterVertically) { if (chat.group == null && presence.equals("online", true)) { @@ -1738,9 +1635,7 @@ fun ColumnScope.ChatScreen( } } } - // Calling is one-to-one. A group call is a different feature with - // different key handling, and an enabled-looking button that cannot - // work is worse than an absent one. + // Calling is one-to-one. if (chat.peerId != null) { val videoCall = rememberVideoCall(model) IconAction( @@ -1749,12 +1644,8 @@ fun ColumnScope.ChatScreen( tag = "start-video-call", onClick = { videoCall(chat.peerId) }, ) - // The kit's bar has a video icon here and this does not, and - // that is deliberate. Neither CallManager has any video path at - // all, no capturer, no video track, nothing, so the button would - // be a control that does nothing. This app has already shipped one - // of those on the call screen once. A mockup can draw a feature; - // the app cannot draw one it does not have. + // The kit's bar has a video icon here and this does not, and that + // is deliberate. IconAction( icon = KitIcon.Call, label = "Call", @@ -1777,9 +1668,9 @@ fun ColumnScope.ChatScreen( /** The message being reported to whoever runs the relay (android#20). */ var reporting by remember(chat.threadId) { mutableStateOf(null) } - // Above everything else in the thread, because it is a statement - // about who you are talking to and it has to be read before the messages - // are, not after scrolling past them. + // Above everything else in the thread, because it is a statement about who + // you are talking to and it has to be read before the messages are, not + // after scrolling past them. if (chat.peerId != null) { StrangerBanner(model, chat.peerId, buddies) { reporting = chat.lines.lastOrNull { !it.mine } @@ -1788,39 +1679,31 @@ fun ColumnScope.ChatScreen( chat.group?.let { group -> GroupMembers(model, group, buddies) } - // Identity, directly under the name it belongs to. Groups have many - // identities and no single answer, so this is for one-to-one threads only. + // Identity, directly under the name it belongs to. if (chat.peerId != null) IdentityPanel(model, chat) - // One line, not three panels. These were three full-width blocks of - // chips, disappearing, snooze, remind, send later, stacked above the - // messages, and together they took half the screen in a conversation. The - // controls moved into the ⋯ menu; what stays on the thread is the STATE, - // because a timer nobody can see is a timer nobody trusts. + // One line, not three panels. if (chat.peerId != null) ConversationStatusLine(model, chat) - // §6.15/§6.19, which message a sheet is currently about. Hoisted above the - // list because a row's own `remember` dies when it scrolls out, and an edit - // box that vanished mid-typing because the message scrolled away would be - // the worst possible way to lose a correction. + // §6.15/§6.19, which message a sheet is currently about. var editing by remember(chat.threadId) { mutableStateOf(null) } var forwarding by remember(chat.threadId) { mutableStateOf(null) } + /** The message the next send answers (android#16). */ var replying by remember(chat.threadId) { mutableStateOf(null) } // The pinned banner, above the messages: a pin nobody can see is a pin - // nobody uses. Resolved from the id against the lines on screen, so a - // pinned message that was since deleted shows nothing rather than a stale - // copy of words that are gone. + // nobody uses. chat.pinnedMessageId?.let { pinnedId -> chat.lines.firstOrNull { it.id == pinnedId }?.let { pinned -> Row( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(Radius.small)) - .background(Palette.Accent.copy(alpha = 0.10f)) - .padding(horizontal = 12.dp, vertical = 8.dp) - .testTag("pinned-banner"), + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(Radius.small)) + .background(Palette.Accent.copy(alpha = 0.10f)) + .padding(horizontal = 12.dp, vertical = 8.dp) + .testTag("pinned-banner"), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Space.xs), ) { @@ -1840,7 +1723,10 @@ fun ColumnScope.ChatScreen( EditMessageSheet( line = line, onDismiss = { editing = null }, - onSave = { text -> model.editMessage(line, chat, text); editing = null }, + onSave = { text -> + model.editMessage(line, chat, text) + editing = null + }, ) } @@ -1881,71 +1767,77 @@ fun ColumnScope.ChatScreen( verticalArrangement = Arrangement.spacedBy(6.dp), ) { items(chat.lines) { line -> - // THE AUTHOR, not the thread. In a group, `chat.peerId` is null - // and `chat.title` is the group's name, so every received bubble - // wore the group's initial as its avatar and the group's name as - // its author. The line has carried the writer's id all along. - val authorId = when { - line.mine -> null - chat.group == null -> chat.peerId - else -> line.author.takeIf { it != 0L } - } + // THE AUTHOR, not the thread. + val authorId = + when { + line.mine -> null + chat.group == null -> chat.peerId + else -> line.author.takeIf { it != 0L } + } var menuOpen by remember(line.at, line.mine) { mutableStateOf(false) } val clipboard = LocalClipboardManager.current - Box(modifier = Modifier.combinedClickable( - onClick = {}, - onLongClick = { menuOpen = true }, - )) { + Box( + modifier = + Modifier.combinedClickable( + onClick = {}, + onLongClick = { menuOpen = true }, + ), + ) { Bubble( line = line, model = model, - authorName = when { - line.mine || chat.group == null -> chat.title - authorId == null -> chat.title - else -> buddies.firstOrNull { it.userId == authorId } - ?.let { it.displayName.ifBlank { it.handle } } - ?: "user $authorId" - }, - // Only for a message you did not send. Your own bubble - // has no avatar in the kit, you know who you are, and a - // picture on both sides halves the width a thread has. + authorName = + when { + line.mine || chat.group == null -> { + chat.title + } + + authorId == null -> { + chat.title + } + + else -> { + buddies + .firstOrNull { it.userId == authorId } + ?.let { it.displayName.ifBlank { it.handle } } + ?: "user $authorId" + } + }, + // Only for a message you did not send. picture = authorId?.let { avatars[it] }, ) DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { - // The reactions are IN the menu, as its first row, - // rather than behind a "React" item that opens a second - // one. A reaction is a one-tap gesture; making it two taps - // to reach a picker is how the feature stops being used. - // - // Only for a message that has an id. Anything sent - // before ids existed has nothing to point at, so the row - // is absent rather than present and inert. + // The reactions are IN the menu, as its first row, rather + // than behind a "React" item that opens a second one. if (line.id != null) { Row( horizontalArrangement = Arrangement.spacedBy(Space.xs), - modifier = Modifier - .padding(horizontal = 12.dp, vertical = 4.dp) - .testTag("react-row"), + modifier = + Modifier + .padding(horizontal = 12.dp, vertical = 4.dp) + .testTag("react-row"), ) { listOf("❤️", "😂", "😮", "😢", "🔥").forEach { emoji -> val already = line.reactions[emoji]?.contains(myUserId) == true Text( emoji, fontSize = 22.sp, - modifier = Modifier - .clip(RoundedCornerShape(Radius.small)) - .background( - if (already) Palette.Accent.copy(alpha = 0.15f) - else Color.Transparent - ) - .clickable { - // Tapping the one already sent takes it back. - model.reactToMessage( - if (already) "" else emoji, line, chat) - menuOpen = false - } - .padding(4.dp) - .testTag("react-$emoji"), + modifier = + Modifier + .clip(RoundedCornerShape(Radius.small)) + .background( + if (already) { + Palette.Accent.copy(alpha = 0.15f) + } else { + Color.Transparent + }, + ).clickable { + // Tapping the one already sent + // takes it back. + model.reactToMessage(if (already) "" else emoji, line, chat) + menuOpen = false + }.padding(4.dp) + .testTag("react-$emoji"), ) } } @@ -1953,17 +1845,22 @@ fun ColumnScope.ChatScreen( if (line.text.isNotBlank()) { DropdownMenuItem( text = { Text("Copy") }, - onClick = { clipboard.setText(AnnotatedString(line.text)); menuOpen = false }, + onClick = { + clipboard.setText(AnnotatedString(line.text)) + menuOpen = false + }, ) } - // Every control below needs an id to point at, so all - // of them are absent, not disabled, on a message written - // before ids existed. A greyed-out row invites a tap and - // teaches nothing. + // Every control below needs an id to point at, so all of + // them are absent, not disabled, on a message written + // before ids existed. if (line.id != null && line.mine && line.text.isNotBlank()) { DropdownMenuItem( text = { Text("Edit") }, - onClick = { editing = line; menuOpen = false }, + onClick = { + editing = line + menuOpen = false + }, modifier = Modifier.testTag("edit-message"), ) } @@ -1971,54 +1868,55 @@ fun ColumnScope.ChatScreen( val isPinned = chat.pinnedMessageId == line.id DropdownMenuItem( text = { Text(if (isPinned) "Unpin" else "Pin") }, - onClick = { model.pinMessage(line, chat, !isPinned); menuOpen = false }, + onClick = { + model.pinMessage(line, chat, !isPinned) + menuOpen = false + }, modifier = Modifier.testTag("pin-message"), ) } - // Needs an id to point at, like Edit and Pin, and a - // 1:1 thread, because the group send path does not carry a - // reply yet. Absent rather than disabled where it cannot - // work: a greyed-out row invites a tap and teaches nothing. + // Needs an id to point at, like Edit and Pin, and a 1:1 + // thread, because the group send path does not carry a + // reply yet. if (line.id != null && line.text.isNotBlank() && chat.peerId != null) { DropdownMenuItem( text = { Text("Reply") }, - onClick = { replying = line; menuOpen = false }, + onClick = { + replying = line + menuOpen = false + }, modifier = Modifier.testTag("reply-message"), ) } if (line.text.isNotBlank()) { DropdownMenuItem( text = { Text("Forward") }, - onClick = { forwarding = line; menuOpen = false }, + onClick = { + forwarding = line + menuOpen = false + }, modifier = Modifier.testTag("forward-message"), ) } // Only on messages somebody else sent (android#20). - // This is the surface unsolicited contact, spam and - // harassment actually arrive on, and until now the person - // receiving it had Block and nothing else, which protects - // them and tells whoever runs the server nothing, so the - // account simply moves on to the next person. Reporting - // your own message is not something anybody means to do. if (!line.mine && line.text.isNotBlank()) { DropdownMenuItem( text = { Text("Report") }, - onClick = { reporting = line; menuOpen = false }, + onClick = { + reporting = line + menuOpen = false + }, modifier = Modifier.testTag("report-message"), ) } DropdownMenuItem( - // Said "Unsend" on your own messages and did - // nothing of the kind (android#18). `deleteMessage` - // reaches `vault.deleteLine` and stops there, no - // envelope leaves the phone, and the recipient's copy - // stays exactly where it is. Somebody who sent to the - // wrong person tapped it, watched the bubble vanish, - // and had no way to find out. "Delete for me" was - // already the other half of this ternary, and it was - // always the accurate half. + // Said "Unsend" on your own messages and did nothing of + // the kind (android#18). text = { Text("Delete for me") }, - onClick = { model.deleteMessage(line); menuOpen = false }, + onClick = { + model.deleteMessage(line) + menuOpen = false + }, modifier = Modifier.testTag("delete-message"), ) } @@ -2033,69 +1931,73 @@ fun ColumnScope.ChatScreen( val context = androidx.compose.ui.platform.LocalContext.current // Hoisted out of VoiceComposer so the microphone can sit IN the composer - // row. The kit's Message Box is one row, plus, the pill, then icons, and - // a voice control on a line of its own underneath was the last thing on - // this screen that did not belong to that row. + // row. var showStickers by remember { mutableStateOf(false) } var showEmoji by remember { mutableStateOf(false) } var showAttach by remember { mutableStateOf(false) } + /** The Files picker, opened from the attach panel. */ var pickingFile by remember { mutableStateOf(false) } var micGranted by remember { mutableStateOf( ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) - == PackageManager.PERMISSION_GRANTED + == PackageManager.PERMISSION_GRANTED, ) } - val askMic = rememberLauncherForActivityResult( - ActivityResultContracts.RequestPermission() - ) { allowed -> - micGranted = allowed - if (allowed) model.startRecording() - } + val askMic = + rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { allowed -> + micGranted = allowed + if (allowed) model.startRecording() + } // The system picker, so the app never asks for storage permission, it is // handed exactly the one file the person chose and nothing else. - val pickImage = androidx.activity.compose.rememberLauncherForActivityResult( - androidx.activity.result.contract.ActivityResultContracts.GetContent() - ) { uri -> - uri ?: return@rememberLauncherForActivityResult - val bytes = runCatching { - context.contentResolver.openInputStream(uri)?.use { it.readBytes() } - }.getOrNull() ?: return@rememberLauncherForActivityResult - // The ORIGINAL bytes used to go straight out. A modern camera photo - // is 5-15 MB and often over the relay's blob cap, so large pictures - // simply failed to send, and the ones that fit made the recipient - // download tens of megabytes nobody asked for. - val prepared = ImagePrep.prepare(bytes, MediaSettings(context).fullQuality) - ?: return@rememberLauncherForActivityResult - model.sendImage(prepared.bytes, prepared.mime, prepared.width, prepared.height) - } + val pickImage = + androidx.activity.compose.rememberLauncherForActivityResult( + androidx.activity.result.contract.ActivityResultContracts + .GetContent(), + ) { uri -> + uri ?: return@rememberLauncherForActivityResult + val bytes = + runCatching { + context.contentResolver.openInputStream(uri)?.use { it.readBytes() } + }.getOrNull() ?: return@rememberLauncherForActivityResult + // The ORIGINAL bytes used to go straight out. + val prepared = + ImagePrep.prepare(bytes, MediaSettings(context).fullQuality) + ?: return@rememberLauncherForActivityResult + model.sendImage(prepared.bytes, prepared.mime, prepared.width, prepared.height) + } // The kit's composer: a 50-tall pill with radius 25, a leading attach - // control and the send action on the trailing edge. An outlined text field - // with two labelled buttons beside it was the one part of this screen that - // still looked like a form. - // Groups only. There is nobody to disambiguate in a one-to-one chat, - // and an @ there is just a character. - val mentionable = chat.group?.let { group -> - val query = mentionQuery(draft) - if (query == null) emptyList() - else group.memberIds.filter { id -> - id != myUserId && - (query.isEmpty() || - memberLabel(id, buddies).contains(query, ignoreCase = true)) - } - } ?: emptyList() + // control and the send action on the trailing edge. + val mentionable = + chat.group?.let { group -> + val query = mentionQuery(draft) + if (query == null) { + emptyList() + } else { + group.memberIds.filter { id -> + id != myUserId && + ( + query.isEmpty() || + memberLabel(id, buddies).contains(query, ignoreCase = true) + ) + } + } + } ?: emptyList() if (mentionable.isNotEmpty()) { Row( - modifier = Modifier - .fillMaxWidth() - .horizontalScroll(rememberScrollState()) - .padding(bottom = Space.xs) - .testTag("mentions"), + modifier = + Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(bottom = Space.xs) + .testTag("mentions"), horizontalArrangement = Arrangement.spacedBy(Space.xs), ) { mentionable.forEach { id -> @@ -2104,38 +2006,34 @@ fun ColumnScope.ChatScreen( label, fontSize = 14.sp, color = Palette.Accent, - modifier = Modifier - .clip(RoundedCornerShape(Radius.small)) - .background(Palette.Accent.copy(alpha = 0.12f)) - .clickable { - val at = draft.lastIndexOf('@') - if (at >= 0) { - draft = draft.substring(0, at) + "@" + label + " " - pendingMentions = pendingMentions + id - } - } - .padding(horizontal = 12.dp, vertical = 6.dp) - .testTag("mention-$id"), + modifier = + Modifier + .clip(RoundedCornerShape(Radius.small)) + .background(Palette.Accent.copy(alpha = 0.12f)) + .clickable { + val at = draft.lastIndexOf('@') + if (at >= 0) { + draft = draft.substring(0, at) + "@" + label + " " + pendingMentions = pendingMentions + id + } + }.padding(horizontal = 12.dp, vertical = 6.dp) + .testTag("mention-$id"), ) } } } - // Only the ones still named in the text. Picking somebody and then - // deleting their name would otherwise still ring them, from their side, a - // notification about a message that does not mention them. - // Above the composer and dismissible. A reply that is only visible in - // the sent message is a mode nobody can see they are in, the commonest way - // to answer the wrong message is not to know you were answering one. + // Only the ones still named in the text. replying?.let { line -> Row( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = Space.xs) - .clip(RoundedCornerShape(Radius.small)) - .background(Palette.SurfaceAlt) - .padding(horizontal = Space.sm, vertical = Space.xs) - .testTag("replying-to"), + modifier = + Modifier + .fillMaxWidth() + .padding(bottom = Space.xs) + .clip(RoundedCornerShape(Radius.small)) + .background(Palette.SurfaceAlt) + .padding(horizontal = Space.sm, vertical = Space.xs) + .testTag("replying-to"), verticalAlignment = Alignment.CenterVertically, ) { Column(modifier = Modifier.weight(1f)) { @@ -2155,31 +2053,30 @@ fun ColumnScope.ChatScreen( KitIcon.Close, contentDescription = "Stop replying", tint = Palette.SecondaryText, - modifier = Modifier - .clickable { replying = null } - .testTag("cancel-reply"), + modifier = + Modifier + .clickable { replying = null } + .testTag("cancel-reply"), ) } } val sendDraft: () -> Unit = { - val named = pendingMentions - .filter { draft.contains("@" + memberLabel(it, buddies)) } - .map { it.toInt() } + val named = + pendingMentions + .filter { draft.contains("@" + memberLabel(it, buddies)) } + .map { it.toInt() } // The preview is the sender's own copy of the quoted words, and it - // travels with the reply. A recipient who joined later or cleared - // their history has never seen the message being answered, and - // "> [missing]" is worse than showing what was said. - val answering = replying?.let { line -> - line.id?.let { id -> - Content.Reference(id, line.author, line.text.take(140)) + // travels with the reply. + val answering = + replying?.let { line -> + line.id?.let { id -> + Content.Reference(id, line.author, line.text.take(140)) + } } - } model.sendMessage(draft, named, replyTo = answering) replying = null - // `setDraft`, not `draft = ""`. Clearing only the composer leaves - // the stored copy behind, and the message comes back next time as a - // draft of something already sent. + // `setDraft`, not `draft = ""`. setDraft("") pendingMentions = emptySet() } @@ -2189,31 +2086,32 @@ fun ColumnScope.ChatScreen( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Space.sm), ) { - // A choice, not a shortcut to the photo picker. This was labelled - // "Attach a photo" and launched the gallery, which is also exactly what - // the camera button beside it does, so the row had two controls doing - // one thing and no way at all to send a document. Opening a small panel - // keeps the kit's row at its four controls while giving the second kind - // of attachment somewhere to live. - IconAction(icon = KitIcon.Attach, - label = if (showAttach) "Close attachments" else "Attach", - tag = "attach", - onClick = { - showAttach = !showAttach - if (showAttach) { showEmoji = false; showStickers = false } - }) + // A choice, not a shortcut to the photo picker. + IconAction( + icon = KitIcon.Attach, + label = if (showAttach) "Close attachments" else "Attach", + tag = "attach", + onClick = { + showAttach = !showAttach + if (showAttach) { + showEmoji = false + showStickers = false + } + }, + ) // `BasicTextField` inside our own pill rather than a Material text // field with its shape overridden: `OutlinedTextField` reserves room // for a floating label and enforces a 56dp minimum, so it cannot be // made 50 tall no matter what shape it is given. Box( - modifier = Modifier - .weight(1f) - .heightIn(min = Kit.pillHeight) - .clip(RoundedCornerShape(Kit.pillRadius)) - .background(MaterialTheme.colorScheme.surfaceVariant) - .padding(horizontal = Kit.gutter, vertical = 14.dp), + modifier = + Modifier + .weight(1f) + .heightIn(min = Kit.pillHeight) + .clip(RoundedCornerShape(Kit.pillRadius)) + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(horizontal = Kit.gutter, vertical = 14.dp), contentAlignment = Alignment.CenterStart, ) { if (draft.isEmpty()) { @@ -2222,39 +2120,43 @@ fun ColumnScope.ChatScreen( BasicTextField( value = draft, onValueChange = setDraft, - textStyle = LocalTextStyle.current.copy( - fontSize = 14.sp, - color = MaterialTheme.colorScheme.onSurface, - ), + textStyle = + LocalTextStyle.current.copy( + fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurface, + ), cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), - // Return sends, same convention iOS now defaults to. The - // send button stays for anyone who taps instead. + // Return sends, same convention iOS now defaults to. singleLine = true, - keyboardOptions = androidx.compose.foundation.text.KeyboardOptions( - imeAction = androidx.compose.ui.text.input.ImeAction.Send), - keyboardActions = androidx.compose.foundation.text.KeyboardActions( - onSend = { if (draft.isNotBlank()) sendDraft() } - ), + keyboardOptions = + androidx.compose.foundation.text + .KeyboardOptions(imeAction = androidx.compose.ui.text.input.ImeAction.Send), + keyboardActions = + androidx.compose.foundation.text.KeyboardActions( + onSend = { if (draft.isNotBlank()) sendDraft() }, + ), modifier = Modifier.fillMaxWidth().testTag("draft"), ) } // Two controls, not one, because they are two different things. - // An emoji goes INTO the text and is indistinguishable from any other - // character once sent. A sticker is its own message kind that is never - // rendered as text. Putting them behind one button would imply they - // behave the same way, and they do not, see docs/STICKERS.md. IconAction( icon = KitIcon.Emoji, label = if (showEmoji) "Close emoji" else "Emoji", tag = "emoji", - onClick = { showEmoji = !showEmoji; if (showEmoji) showStickers = false }, + onClick = { + showEmoji = !showEmoji + if (showEmoji) showStickers = false + }, ) IconAction( icon = KitIcon.Sticker, label = if (showStickers) "Close stickers" else "Stickers", tag = "stickers", - onClick = { showStickers = !showStickers; if (showStickers) showEmoji = false }, + onClick = { + showStickers = !showStickers + if (showStickers) showEmoji = false + }, ) if (chat.peerId != null && chat.recordingSeconds == null) { @@ -2263,29 +2165,25 @@ fun ColumnScope.ChatScreen( label = "Record a voice note", tag = "record-voice", onClick = { - if (micGranted) model.startRecording() - else askMic.launch(Manifest.permission.RECORD_AUDIO) + if (micGranted) { + model.startRecording() + } else { + askMic.launch(Manifest.permission.RECORD_AUDIO) + } }, ) } - // No emoji icon, which the kit's pill has, for the same reason - // there is no video button: there is no emoji picker to open, and the - // system keyboard already carries one. A glyph that does nothing is - // worse than a missing one. - // - // The kit's Message Box has no send button. Measured: plus at - // x=16, the F3F3F3 pill from x=56 with an emoji icon inside it at the - // trailing end, camera at x=287, more at x=335. Nothing else. - // - // It is kept anyway, and only while there is something to send. A - // composer with no visible way to send relies on the keyboard's return - // key, which is a real convention but not one to adopt silently on - // somebody else's behalf, and when the field is empty this collapses - // to exactly the kit's row. Camera takes its place at rest. + // No emoji icon, which the kit's pill has, for the same reason there is + // no video button: there is no emoji picker to open, and the system + // keyboard already carries one. if (draft.isBlank()) { - IconAction(icon = KitIcon.Camera, label = "Take a photo", tag = "camera", - onClick = { pickImage.launch("image/*") }) + IconAction( + icon = KitIcon.Camera, + label = "Take a photo", + tag = "camera", + onClick = { pickImage.launch("image/*") }, + ) } else { Button( onClick = sendDraft, @@ -2306,8 +2204,7 @@ fun ColumnScope.ChatScreen( EmojiPicker { emoji -> // Into the draft, not sent, an emoji is a character, and somebody // picking three in a row expects three in the box, not three - // messages. Closing the panel on each tap would make that - // impossible, so it stays open. + // messages. draft += emoji } } @@ -2315,8 +2212,14 @@ fun ColumnScope.ChatScreen( if (showAttach) { AttachPanel( model = model, - onPhoto = { showAttach = false; pickImage.launch("image/*") }, - onFiles = { showAttach = false; pickingFile = true }, + onPhoto = { + showAttach = false + pickImage.launch("image/*") + }, + onFiles = { + showAttach = false + pickingFile = true + }, ) } @@ -2333,8 +2236,7 @@ fun ColumnScope.ChatScreen( if (showStickers) { StickerPicker { sticker -> - // A sticker IS the message. There is nothing to add it to and - // nothing further to say, so it sends and the panel closes. + // A sticker IS the message. model.sendSticker(chat, sticker.id) showStickers = false } @@ -2348,26 +2250,16 @@ fun ColumnScope.ChatScreen( /** * Record and send a voice note. - * - * Tap to start, tap to send rather than hold-to-talk. Hold-to-talk reads - * well in a demo and badly on a real phone: the gesture competes with scrolling, - * and letting go early, which happens constantly, silently discards what was - * said. Two deliberate taps cannot be lost by accident. */ @Composable private fun ColumnScope.VoiceComposer( model: MessengerViewModel, chat: MessengerViewModel.Phase.Chat, ) { - // The permission plumbing moved to the composer row with the mic icon - // that needs it, asked when somebody taps record, never at startup. A - // microphone prompt on first launch, before anything has been said, is the - // kind a person denies on reflex, and a denied permission is far harder to - // recover than one that was never asked for. + // The permission plumbing moved to the composer row with the mic icon that + // needs it, asked when somebody taps record, never at startup. - // Only the ACTIVE recording. Starting one is the mic icon in the - // composer row now; this is the timer and the two decisions, which the kit - // has no equivalent for and which need a line of their own. + // Only the ACTIVE recording. val seconds = chat.recordingSeconds ?: return run { Row( @@ -2381,10 +2273,18 @@ private fun ColumnScope.VoiceComposer( "Recording ${"%.1f".format(seconds)}s", modifier = Modifier.weight(1f).testTag("recording-timer"), ) - KitChip(text = "Cancel", selected = false, - onClick = model::cancelRecording, tag = "cancel-voice") - KitChip(text = "Send", selected = true, - onClick = model::stopAndSendRecording, tag = "send-voice") + KitChip( + text = "Cancel", + selected = false, + onClick = model::cancelRecording, + tag = "cancel-voice", + ) + KitChip( + text = "Send", + selected = true, + onClick = model::stopAndSendRecording, + tag = "send-voice", + ) } } } @@ -2400,226 +2300,213 @@ private fun Bubble( val display = remember { DisplaySettings(displayContext) } val showTimes = remember { display.showTimestamps } val bodySize = remember { DisplaySettings.messageTextSize(display.largerType) } - val time = remember(line.at, showTimes) { - if (!showTimes) "" - else SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(line.at)) - } + val time = + remember(line.at, showTimes) { + if (!showTimes) { + "" + } else { + SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(line.at)) + } + } Row( - modifier = Modifier - .fillMaxWidth() - // The kit insets a received bubble by 40 from the leading edge, - // the 32dp avatar plus its gutter, and lets a sent one run to the - // margin. The same number on the far side is what stops a long - // message spanning the full width, which is what makes a thread - // readable rather than a wall. - .padding( - start = if (line.mine) Kit.receivedInset else 0.dp, - end = if (line.mine) 0.dp else Kit.receivedInset, - ), + modifier = + Modifier + .fillMaxWidth() + // The kit insets a received bubble by 40 from the leading edge, the + // 32dp avatar plus its gutter, and lets a sent one run to the + // margin. + .padding( + start = if (line.mine) Kit.receivedInset else 0.dp, + end = if (line.mine) 0.dp else Kit.receivedInset, + ), // Top, so the avatar sits level with the first line of its bubble. verticalAlignment = Alignment.Top, horizontalArrangement = if (line.mine) Arrangement.End else Arrangement.Start, ) { - // The avatar the inset was already reserving space for. The - // received side has been padded by 40, a 32dp avatar plus its gutter, - // since the bubbles were measured out of the kit, and nothing was ever - // drawn in it. The picture was on the contact row and the contact screen - // and missing from the one screen you spend all your time on. - // - // Top-aligned. Centred, it drifts down the side of a long message and - // reads as belonging to whatever comes next. - if (!line.mine) { - Avatar( - name = authorName, - online = false, - picture = picture, - model = model, - size = Kit.bubbleAvatar, - ) - Spacer(Modifier.width(Space.sm)) - } - Column(horizontalAlignment = if (line.mine) Alignment.End else Alignment.Start) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(Space.xs), - modifier = Modifier.padding(horizontal = Kit.bubblePadding, vertical = 2.dp), - ) { - // Omitted entirely rather than drawn empty. An empty Text still - // takes its line height, so "timestamps off" would have left a - // blank gap above every message where the time used to be. - if (time.isNotEmpty()) { - Text(time, fontSize = 10.sp, color = Palette.SecondaryText) - } - // An edit says so (§6.15). Silently replacing the words is - // worse than the typo: the reader has no way to tell a correction - // from their own misremembering, and no way to know the message - // they replied to is not the message that is there now. - if (line.editedAt != null) { - Text("edited", fontSize = 10.sp, color = Palette.SecondaryText, - modifier = Modifier.testTag("edited-marker")) - } - // THAT it was forwarded, never from whom (§6.16). Second-hand - // words drawn as the sender's own is the thing this prevents; - // naming the original author would be a different feature and a - // worse one, because it republishes somebody who was never in this - // conversation. - if (line.forwarded) { - Text("Forwarded", fontSize = 10.sp, color = Palette.SecondaryText, - modifier = Modifier.testTag("forwarded-marker")) - } - // A message nobody received must not look like one that was - // received. That sameness is the whole of what makes a ghost - // message possible; every specific cause behind one is downstream - // of it. Only `false` marks, null means nothing was claimed, which - // is every received message and everything stored before the field - // existed. - // PENDING is deliberately quiet and grey, not a warning. It is - // the normal thing that happens when a friend is signed out, and - // there is nothing for the sender to do about it, the outbox will - // send it when they register a device again, however long that - // takes. It says "undelivered" because that is true, and "will - // send" because otherwise the reader has to guess whose job it is. - // - // FAILED is now unreachable from the outbox, which never expires. - // It stays in the enum for a send that fails for a reason waiting - // cannot fix. - when (line.delivery) { - Session.Delivery.PENDING -> { - Icon( - KitIcon.Pending, - contentDescription = null, - tint = Palette.SecondaryText, - modifier = Modifier.size(12.dp).testTag("pending"), - ) - Text("Undelivered, will send", fontSize = 10.sp, - color = Palette.SecondaryText) - } - Session.Delivery.FAILED -> { - Icon( - KitIcon.NotDelivered, - contentDescription = "Not delivered", - tint = Palette.Pink, - modifier = Modifier.size(12.dp).testTag("undelivered"), - ) - Text("Not delivered", fontSize = 10.sp, color = Palette.Pink) - } - // DELIVERED and null both draw nothing: a message that arrived - // needs no decoration, and null is every received message. - else -> Unit - } - } - // A sticker has no bubble. The kit draws none, and a glyph - // inside a grey rounded rectangle reads as a broken image rather than - // as a sticker. It keeps the timestamp above it, that is the line that - // says who and when, and nothing else. - val stk = line.sticker - if (stk != null) { - val context = LocalContext.current - Text( - StickerPack.glyph(context, stk.pack, stk.id), - fontSize = 64.sp, - modifier = Modifier - .padding(horizontal = Kit.bubblePadding) - .semantics { - contentDescription = - StickerPack.name(context, stk.pack, stk.id) + " sticker" - } - .testTag("sticker-${stk.id}"), + // The avatar the inset was already reserving space for. + if (!line.mine) { + Avatar( + name = authorName, + online = false, + picture = picture, + model = model, + size = Kit.bubbleAvatar, ) - return@Column + Spacer(Modifier.width(Space.sm)) } - - Column( - modifier = Modifier - // One corner is square, and it points at its author. - // Radius 16 everywhere except bottom-end on a message you - // sent and top-start on one you received. Rounding all four - // equally is what makes a chat app look like every other chat - // app, and it is the first thing lost when a spec is eyeballed - // from a screenshot rather than measured. See `DESIGN.md`. - .clip( - RoundedCornerShape( - topStart = if (line.mine) Kit.bubbleRadius else Kit.bubbleTail, - topEnd = Kit.bubbleRadius, - bottomEnd = if (line.mine) Kit.bubbleTail else Kit.bubbleRadius, - bottomStart = Kit.bubbleRadius, - ) - ) - .background( - if (line.mine) MaterialTheme.colorScheme.primary - else MaterialTheme.colorScheme.surfaceVariant - ) - .padding(Kit.bubblePadding), - ) { - // The kit puts the time ABOVE the bubble, outside it. Inside, a - // one-word message became a tall two-line box, "sup" rendered - // taller than it was wide. - line.attachment?.let { ref -> - // One field, two very different things. Audio has no thumbnail - // to show and a duration that matters; an image has the - // reverse. Routing on the kind rather than trying to render - // both with one composable. - when (ref.kind) { - io.flamenet.e2e.Attachment.AUDIO -> VoiceBubble(ref, model) - // A document, not a picture: AttachedImage would download - // a PDF and draw nothing. - io.flamenet.e2e.Attachment.FILE -> FileBubble( - ref, line.mine, - onSave = if (line.mine) null else { { model.saveAttachmentToFiles(ref) } }, - ) - else -> AttachedImage(ref, model) - } - } - if (line.text.isNotEmpty()) { - Text( - line.text, - // 16sp regular, per the kit's Body Text 2, which is what - // `messageTextSize` returns with larger type off, so this - // renders exactly as it always did until somebody asks. - fontSize = bodySize.sp, - color = if (line.mine) MaterialTheme.colorScheme.onPrimary - else MaterialTheme.colorScheme.onSurface, - ) - } - line.link?.let { LinkPreviewCard(it, mine = line.mine) } - } - // Under the bubble, not inside it. A reaction is something other - // people said ABOUT this message; drawing it inside the bubble makes - // it look like part of what was written. - if (line.reactions.isNotEmpty()) { + Column(horizontalAlignment = if (line.mine) Alignment.End else Alignment.Start) { Row( + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Space.xs), - modifier = Modifier - .padding(horizontal = Kit.bubblePadding, vertical = 2.dp) - .testTag("reactions"), + modifier = Modifier.padding(horizontal = Kit.bubblePadding, vertical = 2.dp), ) { - line.reactions.entries.sortedBy { it.key }.forEach { (emoji, who) -> + // Omitted entirely rather than drawn empty. + if (time.isNotEmpty()) { + Text(time, fontSize = 10.sp, color = Palette.SecondaryText) + } + // An edit says so (§6.15). + if (line.editedAt != null) { Text( - if (who.size == 1) emoji else "$emoji ${who.size}", - fontSize = 13.sp, + "edited", + fontSize = 10.sp, color = Palette.SecondaryText, - modifier = Modifier - .clip(RoundedCornerShape(Radius.small)) - .background(MaterialTheme.colorScheme.surfaceVariant) - .padding(horizontal = 6.dp, vertical = 2.dp), + modifier = Modifier.testTag("edited-marker"), ) } + // THAT it was forwarded, never from whom (§6.16). + if (line.forwarded) { + Text( + "Forwarded", + fontSize = 10.sp, + color = Palette.SecondaryText, + modifier = Modifier.testTag("forwarded-marker"), + ) + } + // A message nobody received must not look like one that was + // received. + when (line.delivery) { + Session.Delivery.PENDING -> { + Icon( + KitIcon.Pending, + contentDescription = null, + tint = Palette.SecondaryText, + modifier = Modifier.size(12.dp).testTag("pending"), + ) + Text( + "Undelivered, will send", + fontSize = 10.sp, + color = Palette.SecondaryText, + ) + } + + Session.Delivery.FAILED -> { + Icon( + KitIcon.NotDelivered, + contentDescription = "Not delivered", + tint = Palette.Pink, + modifier = Modifier.size(12.dp).testTag("undelivered"), + ) + Text("Not delivered", fontSize = 10.sp, color = Palette.Pink) + } + + // DELIVERED and null both draw nothing: a message that arrived + // needs no decoration, and null is every received message. + else -> { + Unit + } + } + } + // A sticker has no bubble. + val stk = line.sticker + if (stk != null) { + val context = LocalContext.current + Text( + StickerPack.glyph(context, stk.pack, stk.id), + fontSize = 64.sp, + modifier = + Modifier + .padding(horizontal = Kit.bubblePadding) + .semantics { + contentDescription = + StickerPack.name(context, stk.pack, stk.id) + " sticker" + }.testTag("sticker-${stk.id}"), + ) + return@Column + } + + Column( + modifier = + Modifier + // One corner is square, and it points at its author. + .clip( + RoundedCornerShape( + topStart = if (line.mine) Kit.bubbleRadius else Kit.bubbleTail, + topEnd = Kit.bubbleRadius, + bottomEnd = if (line.mine) Kit.bubbleTail else Kit.bubbleRadius, + bottomStart = Kit.bubbleRadius, + ), + ).background( + if (line.mine) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.surfaceVariant + }, + ).padding(Kit.bubblePadding), + ) { + // The kit puts the time ABOVE the bubble, outside it. + line.attachment?.let { ref -> + // One field, two very different things. + when (ref.kind) { + io.flamenet.e2e.Attachment.AUDIO -> { + VoiceBubble(ref, model) + } + + // A document, not a picture: AttachedImage would download a + // PDF and draw nothing. + io.flamenet.e2e.Attachment.FILE -> { + FileBubble( + ref, + line.mine, + onSave = + if (line.mine) { + null + } else { + { model.saveAttachmentToFiles(ref) } + }, + ) + } + + else -> { + AttachedImage(ref, model) + } + } + } + if (line.text.isNotEmpty()) { + Text( + line.text, + // 16sp regular, per the kit's Body Text 2, which is what + // `messageTextSize` returns with larger type off, so this + // renders exactly as it always did until somebody asks. + fontSize = bodySize.sp, + color = + if (line.mine) { + MaterialTheme.colorScheme.onPrimary + } else { + MaterialTheme.colorScheme.onSurface + }, + ) + } + line.link?.let { LinkPreviewCard(it, mine = line.mine) } + } + // Under the bubble, not inside it. + if (line.reactions.isNotEmpty()) { + Row( + horizontalArrangement = Arrangement.spacedBy(Space.xs), + modifier = + Modifier + .padding(horizontal = Kit.bubblePadding, vertical = 2.dp) + .testTag("reactions"), + ) { + line.reactions.entries.sortedBy { it.key }.forEach { (emoji, who) -> + Text( + if (who.size == 1) emoji else "$emoji ${who.size}", + fontSize = 13.sp, + color = Palette.SecondaryText, + modifier = + Modifier + .clip(RoundedCornerShape(Radius.small)) + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(horizontal = 6.dp, vertical = 2.dp), + ) + } + } } } - } } } /** * What we know about the other side's keys, and the two things a person can do. - * - * The changed state is not a warning banner to be dismissed. It offers - * exactly one action, accept, because the only way past it is a decision - * somebody makes on purpose. An "ignore" affordance here would be a way to - * silence the one signal in the product that means an interception. - * - * The safety number is shown, not hidden behind a menu. A number nobody can - * find is a number nobody compares, and an uncompared number verifies nothing. */ @Composable private fun ColumnScope.IdentityPanel( @@ -2639,19 +2526,16 @@ private fun ColumnScope.IdentityPanel( color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyMedium, ) - // Both readings, in the order that matters. Naming only the benign - // one trains people to tap through it. + // Both readings, in the order that matters. Text( - "That happens when someone reinstalls or adds a device, and it is also what " - + "an intercepted conversation looks like. Compare safety numbers with them " - + "on a channel this app does not control before you accept.", + "That happens when someone reinstalls or adds a device, and it is also what " + + "an intercepted conversation looks like. Compare safety numbers with them " + + "on a channel this app does not control before you accept.", style = MaterialTheme.typography.bodySmall, ) Row(horizontalArrangement = Arrangement.spacedBy(Space.sm)) { - // Accepting a changed key is the most consequential tap in - // the app. As matching orange text beside "Show number" the two - // looked interchangeable; the outline makes accepting a - // deliberate act and marks it as the dangerous one. + // Accepting a changed key is the most consequential tap in the + // app. KitSecondaryButton( text = "Accept new keys", onClick = { model.acceptIdentityChange(peerId) }, @@ -2672,11 +2556,7 @@ private fun ColumnScope.IdentityPanel( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - // Quiet, but never absent. The reassuring states are 12sp - // grey: they were as loud as the conversation's own title bar, - // which is the wrong weight for "nothing has happened". The - // CHANGED state above is untouched and stays loud, it is the one - // signal this whole mechanism exists to produce. + // Quiet, but never absent. Text( when (chat.trust) { IdentityTrust.VERIFIED -> "Verified" @@ -2710,9 +2590,9 @@ private fun ColumnScope.IdentityPanel( modifier = Modifier.testTag("safety-number"), ) Text( - "Read this to each other in person or on a call. If it matches, mark them " - + "verified. It covers their first device only, someone signed in on " - + "several has more than one number.", + "Read this to each other in person or on a call. If it matches, mark them " + + "verified. It covers their first device only, someone signed in on " + + "several has more than one number.", style = MaterialTheme.typography.bodySmall, ) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { @@ -2720,8 +2600,13 @@ private fun ColumnScope.IdentityPanel( onClick = { model.setVerified(peerId, chat.trust != IdentityTrust.VERIFIED) }, modifier = Modifier.testTag("toggle-verified"), ) { - Text(if (chat.trust == IdentityTrust.VERIFIED) "Unmark verified" - else "Mark verified") + Text( + if (chat.trust == IdentityTrust.VERIFIED) { + "Unmark verified" + } else { + "Mark verified" + }, + ) } } } @@ -2731,14 +2616,12 @@ private fun ColumnScope.IdentityPanel( /** * A received voice note: play, stop, and how long it runs. - * - * Fetched and decrypted on tap, never on render. A thread of twenty - * notes would otherwise download and decrypt all twenty on scroll, bytes - * nobody asked for, and twenty decrypted files in the cache for the sake of a - * list that has not been listened to. */ @Composable -private fun VoiceBubble(ref: io.flamenet.e2e.Attachment.Ref, model: MessengerViewModel) { +private fun VoiceBubble( + ref: io.flamenet.e2e.Attachment.Ref, + model: MessengerViewModel, +) { val context = LocalContext.current val scope = rememberCoroutineScope() val player = remember { VoiceNote.Player(context.cacheDir) } @@ -2746,8 +2629,7 @@ private fun VoiceBubble(ref: io.flamenet.e2e.Attachment.Ref, model: MessengerVie var busy by remember(ref.id) { mutableStateOf(false) } // A MediaPlayer that outlives the composable holds an audio focus and a - // file handle. Leaving the thread mid-playback must stop it, or the note - // keeps playing into a screen that is no longer there. + // file handle. DisposableEffect(ref.id) { onDispose { player.stop() } } Row( @@ -2786,17 +2668,6 @@ private fun VoiceBubble(ref: io.flamenet.e2e.Attachment.Ref, model: MessengerVie /** * The story strip (§6.6). - * - * Stories are sent to contacts, not published. There is no feed on the - * relay and there should not be: a server-side story would need the relay to - * know who is allowed to read it, which means handing it your contact list for - * a feature that works without one. Each story goes down an existing ratchet, - * one per person. - * - * Expiry is a promise, not a guarantee, and the copy says so. This - * client deletes the bytes when the clock runs out because it chooses to. - * Nothing stops a recipient's client keeping them, and a UI implying otherwise - * would be telling people a story is safe to post when it is not. */ @Composable fun ColumnScope.StoryStrip( @@ -2806,25 +2677,17 @@ fun ColumnScope.StoryStrip( val context = LocalContext.current // The system picker again: the app is handed the one file the person chose // and never asks for storage permission. - val pickStory = rememberLauncherForActivityResult( - ActivityResultContracts.GetContent() - ) { uri -> - uri ?: return@rememberLauncherForActivityResult - StoryMedia.stage(context, uri, model) - } + val pickStory = + rememberLauncherForActivityResult( + ActivityResultContracts.GetContent(), + ) { uri -> + uri ?: return@rememberLauncherForActivityResult + StoryMedia.stage(context, uri, model) + } LaunchedEffect(Unit) { model.refreshStories() } - // A strip of faces, which is what the kit's "Recent" row is. This - // was a heading, a Post button, and, with nothing posted, which is the - // normal state, three lines of prose explaining what a story is. Prose - // where the design has a row of circles is the single loudest thing - // telling you this is not the same app. The explanation still exists; it - // moved to the one place it is actually needed, under the button that - // posts one. - // - // The add-circle is always first and always present, so the strip never - // collapses to nothing and there is always somewhere to tap. + // A strip of faces, which is what the kit's "Recent" row is. LazyRow( modifier = Modifier.fillMaxWidth().testTag("stories"), horizontalArrangement = Arrangement.spacedBy(Space.md), @@ -2835,11 +2698,12 @@ fun ColumnScope.StoryStrip( modifier = Modifier.testTag("post-story"), ) { Box( - modifier = Modifier - .size(Kit.avatar) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.surfaceVariant) - .clickable { pickStory.launch("*/*") }, + modifier = + Modifier + .size(Kit.avatar) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceVariant) + .clickable { pickStory.launch("*/*") }, contentAlignment = Alignment.Center, ) { Icon( @@ -2853,29 +2717,33 @@ fun ColumnScope.StoryStrip( } } items(phase.stories, key = { it.id }) { story -> - val label = phase.buddies.firstOrNull { it.userId == story.authorId } - ?.let { it.displayName.ifBlank { it.handle } } - ?: if (story.mine) "You" else "#${story.authorId}" + val label = + phase.buddies + .firstOrNull { it.userId == story.authorId } + ?.let { it.displayName.ifBlank { it.handle } } + ?: if (story.mine) "You" else "#${story.authorId}" Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.testTag("story-${story.id}"), ) { Box( - modifier = Modifier - .size(Kit.avatar) - .clip(CircleShape) - // Unseen is marked rather than styled away: a strip - // where read and unread look alike is a strip nobody - // comes back to. A ring, since there is no room for a - // bullet beside a 48dp circle. - .background( - if (story.seen) Palette.AvatarFill - else MaterialTheme.colorScheme.primary - ) - .padding(if (story.seen) 0.dp else 2.dp) - .clip(CircleShape) - .background(Palette.AvatarFill) - .clickable { model.openStory(story) }, + modifier = + Modifier + .size(Kit.avatar) + .clip(CircleShape) + // Unseen is marked rather than styled away: a strip + // where read and unread look alike is a strip nobody + // comes back to. + .background( + if (story.seen) { + Palette.AvatarFill + } else { + MaterialTheme.colorScheme.primary + }, + ).padding(if (story.seen) 0.dp else 2.dp) + .clip(CircleShape) + .background(Palette.AvatarFill) + .clickable { model.openStory(story) }, contentAlignment = Alignment.Center, ) { Text( @@ -2901,15 +2769,19 @@ fun ColumnScope.StoryStrip( /** One story, full width, with what it says about its own lifetime. */ @Composable -private fun ColumnScope.StoryViewer(model: MessengerViewModel, story: Vault.Story) { - val remaining = remember(story.id) { - val seconds = story.expires - System.currentTimeMillis() / 1000.0 - when { - seconds <= 0 -> "expired" - seconds < 3600 -> "${(seconds / 60).toInt()}m left" - else -> "${(seconds / 3600).toInt()}h left" +private fun ColumnScope.StoryViewer( + model: MessengerViewModel, + story: Vault.Story, +) { + val remaining = + remember(story.id) { + val seconds = story.expires - System.currentTimeMillis() / 1000.0 + when { + seconds <= 0 -> "expired" + seconds < 3600 -> "${(seconds / 60).toInt()}m left" + else -> "${(seconds / 3600).toInt()}h left" + } } - } Column( modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp).testTag("story-viewer"), verticalArrangement = Arrangement.spacedBy(6.dp), @@ -2924,15 +2796,23 @@ private fun ColumnScope.StoryViewer(model: MessengerViewModel, story: Vault.Stor ) { Text(remaining, style = MaterialTheme.typography.bodySmall) Row { - // ios#34, only on your own, and the rule is - // `Vault.isSaveable`, not this condition. + // ios#34, only on your own, and the rule is `Vault.isSaveable`, + // not this condition. if (Vault.isSaveable(story.mine, story.ref != null)) { - KitChip(text = "Save", selected = false, - onClick = { model.saveStory(story) }, tag = "save-story") + KitChip( + text = "Save", + selected = false, + onClick = { model.saveStory(story) }, + tag = "save-story", + ) Spacer(Modifier.size(Space.sm)) } - KitChip(text = "Close", selected = false, - onClick = model::closeStory, tag = "close-story") + KitChip( + text = "Close", + selected = false, + onClick = model::closeStory, + tag = "close-story", + ) } } } @@ -2940,12 +2820,6 @@ private fun ColumnScope.StoryViewer(model: MessengerViewModel, story: Vault.Stor /** * Add and remove people after a group exists. - * - * Membership is the relay's list, and it decides who receives. A member - * added here starts getting messages from the next send; one removed stops. - * Neither is retroactive, removing somebody does not take back what they - * already have, and the copy says so rather than implying a group can be - * un-read. */ @Composable private fun ColumnScope.GroupMembers( @@ -2964,13 +2838,12 @@ private fun ColumnScope.GroupMembers( ) { Text("${group.memberIds.size} members", style = MaterialTheme.typography.bodySmall) Row { - // The only way a group gets a name at all after the first - // one. Names used to be fixed at creation and served by the - // relay; the relay has stopped holding them, so every group made - // before that change reads as a placeholder on every phone and - // there is nobody left to ask what it was called. + // The only way a group gets a name at all after the first one. TextButton( - onClick = { draftName = group.name; renaming = !renaming }, + onClick = { + draftName = group.name + renaming = !renaming + }, modifier = Modifier.testTag("rename-group"), ) { Text(if (renaming) "Cancel" else "Rename") } TextButton( @@ -2989,8 +2862,8 @@ private fun ColumnScope.GroupMembers( modifier = Modifier.fillMaxWidth().testTag("group-name-field"), ) Text( - "Everyone in the group sees the new name. The server never does, it holds no " - + "group names at all.", + "Everyone in the group sees the new name. The server never does, it holds no " + + "group names at all.", style = MaterialTheme.typography.bodySmall, ) TextButton( @@ -3002,23 +2875,16 @@ private fun ColumnScope.GroupMembers( ) { Text("Save") } } - // Only the owner, and only while it still applies. A group made - // before sealing keeps a readable member list on the server until somebody - // replaces it, and only the owner can. Recomposes away once sealed, because - // sealing refreshes the group list. + // Only the owner, and only while it still applies. if (model.canSealGroup(group)) { Text( // Says what is true now, what changes, and what it does NOT buy. - // "The server cannot see who you talk to" would be the easy sentence - // and it is not true: messages are addressed to each device, so who - // receives a group's traffic still gives the group away to whoever - // runs the server. - "This group was made before the server stopped keeping member lists, so it still " - + "holds this one in the clear. Encrypting it puts something in its place that " - + "the server has no key for. Everyone stays in the group and nobody else has " - + "to do anything. It protects the list if the server's database is ever stolen " - + "or handed over; it does not hide the group from whoever runs the server day " - + "to day.", + "This group was made before the server stopped keeping member lists, so it still " + + "holds this one in the clear. Encrypting it puts something in its place that " + + "the server has no key for. Everyone stays in the group and nobody else has " + + "to do anything. It protects the list if the server's database is ever stolen " + + "or handed over; it does not hide the group from whoever runs the server day " + + "to day.", style = MaterialTheme.typography.bodySmall, ) TextButton( @@ -3030,8 +2896,8 @@ private fun ColumnScope.GroupMembers( if (!editing) return Text( - "Adding someone lets them read what is sent from now on. Removing them stops future " - + "messages, it does not take back what they already have.", + "Adding someone lets them read what is sent from now on. Removing them stops future " + + "messages, it does not take back what they already have.", style = MaterialTheme.typography.bodySmall, ) @@ -3048,11 +2914,14 @@ private fun ColumnScope.GroupMembers( ) TextButton( onClick = { - if (isMember) model.updateGroupMembers(group.id, emptyList(), listOf(buddy.userId)) - else model.updateGroupMembers(group.id, listOf(buddy.userId), emptyList()) + if (isMember) { + model.updateGroupMembers(group.id, emptyList(), listOf(buddy.userId)) + } else { + model.updateGroupMembers(group.id, listOf(buddy.userId), emptyList()) + } }, - modifier = Modifier.testTag( - (if (isMember) "remove-member-" else "add-member-") + buddy.userId), + modifier = + Modifier.testTag((if (isMember) "remove-member-" else "add-member-") + buddy.userId), ) { Text(if (isMember) "Remove" else "Add") } } } @@ -3060,15 +2929,12 @@ private fun ColumnScope.GroupMembers( /** * The call banner: ringing, in progress, and the one-time privacy warning. - * - * The warning is shown BEFORE the first call, and it waits. A direct - * connection shows the other person your IP address, that is what makes it - * direct, and it is not something to discover afterwards. Asked once rather - * than every time, because a prompt people see repeatedly is a prompt they - * stop reading. */ @Composable -fun ColumnScope.CallBanner(model: MessengerViewModel, phase: MessengerViewModel.Phase.SignedIn) { +fun ColumnScope.CallBanner( + model: MessengerViewModel, + phase: MessengerViewModel.Phase.SignedIn, +) { phase.callPrivacyPrompt?.let { Column( modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp).testTag("call-privacy"), @@ -3076,10 +2942,10 @@ fun ColumnScope.CallBanner(model: MessengerViewModel, phase: MessengerViewModel. ) { Text("Before your first call", style = MaterialTheme.typography.titleMedium) Text( - "Calls connect your phone directly to theirs, which is what keeps them fast and " - + "private from this server, but a direct connection means their device " - + "sees your IP address, and yours sees theirs. What you say is encrypted " - + "either way.", + "Calls connect your phone directly to theirs, which is what keeps them fast and " + + "private from this server, but a direct connection means their device " + + "sees your IP address, and yours sees theirs. What you say is encrypted " + + "either way.", style = MaterialTheme.typography.bodySmall, ) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { @@ -3103,23 +2969,34 @@ fun ColumnScope.CallBanner(model: MessengerViewModel, phase: MessengerViewModel. ) { Text( when (call.state) { - CallManager.State.RINGING -> + CallManager.State.RINGING -> { if (call.incoming) "${call.peerName} is calling" else "Ringing ${call.peerName}" - CallManager.State.DIALLING -> "Calling ${call.peerName}…" - CallManager.State.CONNECTING -> "Connecting…" - CallManager.State.ACTIVE -> "On a call with ${call.peerName}" - else -> "Call ended" + } + + CallManager.State.DIALLING -> { + "Calling ${call.peerName}…" + } + + CallManager.State.CONNECTING -> { + "Connecting…" + } + + CallManager.State.ACTIVE -> { + "On a call with ${call.peerName}" + } + + else -> { + "Call ended" + } }, style = MaterialTheme.typography.titleMedium, ) - // Said out loud rather than left as a mystery. "Calling did not - // connect", with no reason, is the kind of failure people blame on the - // app for ever. + // Said out loud rather than left as a mystery. if (call.relayless && call.state != CallManager.State.ACTIVE) { Text( - "This server has no call relay, so the call only connects if a direct path " - + "exists between the two networks.", + "This server has no call relay, so the call only connects if a direct path " + + "exists between the two networks.", style = MaterialTheme.typography.bodySmall, ) } @@ -3150,8 +3027,7 @@ fun ColumnScope.CallBanner(model: MessengerViewModel, phase: MessengerViewModel. /** * The kit's `Recent/White` row: 56dp photo circles, an 11sp name centred - * beneath, 24dp gaps, every contact one tap from their chat. Shared by the - * Home screen and the Messages tab, so the two rows cannot drift. + * beneath, 24dp gaps, every contact one tap from their chat. */ @Composable fun BuddyCirclesRow( @@ -3166,18 +3042,20 @@ fun BuddyCirclesRow( ) { items(phase.buddies.size) { i -> val buddy = phase.buddies[i] - val circleName = buddy.displayName.ifBlank { buddy.handle } - .ifBlank { "user ${buddy.userId}" } + val circleName = + buddy.displayName + .ifBlank { buddy.handle } + .ifBlank { "user ${buddy.userId}" } Column( horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier - .pointerInput(buddy.userId) { - detectTapGestures( - onTap = { model.openChat(buddy.userId, circleName) }, - onLongPress = { onOpenProfile?.invoke(buddy.userId) }, - ) - } - .testTag("buddy-circle-${buddy.userId}"), + modifier = + Modifier + .pointerInput(buddy.userId) { + detectTapGestures( + onTap = { model.openChat(buddy.userId, circleName) }, + onLongPress = { onOpenProfile?.invoke(buddy.userId) }, + ) + }.testTag("buddy-circle-${buddy.userId}"), ) { Avatar( name = circleName, @@ -3200,8 +3078,8 @@ fun BuddyCirclesRow( } /** - * Look-up-then-add, shared by Home (its Add action) and anywhere else that - * adds a person. Extracted so the form exists exactly once. + * Look-up-then-add, shared by Home (its Add action) and anywhere else that adds + * a person. */ @Composable fun ColumnScope.AddContactForm( @@ -3212,35 +3090,48 @@ fun ColumnScope.AddContactForm( onDone: () -> Unit, ) { var username by remember(initial) { mutableStateOf(initial) } - // Supervision: adding someone is the guarded act. The form only renders - // once the parent's PIN has been entered, and the unlock lasts for this - // form's life, not the app's. + // Supervision: adding someone is the guarded act. val gateContext = androidx.compose.ui.platform.LocalContext.current var supervisionUnlocked by remember { mutableStateOf(false) } if (ParentalControls.contactsGated(gateContext) && !supervisionUnlocked) { var gatePin by remember { mutableStateOf("") } var gateWrong by remember { mutableStateOf(false) } - Text("Supervision is on, a parent's PIN is needed to add a contact.", - fontSize = 12.sp, color = Palette.SecondaryText) + Text( + "Supervision is on, a parent's PIN is needed to add a contact.", + fontSize = 12.sp, + color = Palette.SecondaryText, + ) Spacer(Modifier.height(Space.xs)) OutlinedTextField( - value = gatePin, onValueChange = { gatePin = it; gateWrong = false }, + value = gatePin, + onValueChange = { + gatePin = it + gateWrong = false + }, placeholder = { Text("Parent PIN", fontSize = 14.sp) }, singleLine = true, - visualTransformation = androidx.compose.ui.text.input.PasswordVisualTransformation(), + visualTransformation = + androidx.compose.ui.text.input + .PasswordVisualTransformation(), isError = gateWrong, modifier = Modifier.fillMaxWidth().testTag("contact-gate-pin"), ) if (gateWrong) { - Text("That's not the PIN.", fontSize = 12.sp, - color = MaterialTheme.colorScheme.error) + Text( + "That's not the PIN.", + fontSize = 12.sp, + color = MaterialTheme.colorScheme.error, + ) } Spacer(Modifier.height(Space.xs)) KitPrimaryButton( text = "Unlock", onClick = { - if (ParentalControls.verify(gateContext, gatePin)) supervisionUnlocked = true - else gateWrong = true + if (ParentalControls.verify(gateContext, gatePin)) { + supervisionUnlocked = true + } else { + gateWrong = true + } gatePin = "" }, enabled = gatePin.isNotEmpty(), @@ -3250,89 +3141,85 @@ fun ColumnScope.AddContactForm( return } - // A scanned code is an ADDRESS, and this form treated everything - // as a username. The scanner already prefills here, so somebody who - // scanned a card from another relay got their address looked up as a - // handle on ours, was told no such person exists, and had no other way - // in. One field either way, what it is, is a fact about the text, not - // a mode the person has to pick. - val card = remember(username) { ContactCard.decode(username) } + // A scanned code is an ADDRESS, and this form treated everything as a + // username. + val card = remember(username) { ContactCard.decode(username) } - OutlinedTextField( - value = username, - onValueChange = { - username = it - if (phase.lookedUp != null || phase.lookupMiss != null) model.clearLookup() - }, - label = { Text(if (card != null) "Address" else "Username or address") }, - singleLine = true, - modifier = Modifier.fillMaxWidth().testTag("new-contact"), + OutlinedTextField( + value = username, + onValueChange = { + username = it + if (phase.lookedUp != null || phase.lookupMiss != null) model.clearLookup() + }, + label = { Text(if (card != null) "Address" else "Username or address") }, + singleLine = true, + modifier = Modifier.fillMaxWidth().testTag("new-contact"), + ) + + if (card != null) { + Text( + "That's an address on ${card.relay}. Messages go straight there, nothing " + + "to add to your contacts first, and you don't need an account on it.", + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.testTag("address-recognised"), ) - - if (card != null) { - Text( - "That's an address on ${card.relay}. Messages go straight there, nothing " - + "to add to your contacts first, and you don't need an account on it.", - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.testTag("address-recognised"), - ) - KitPrimaryButton( - text = "Message them", - onClick = { model.openAddressChat(username); onDone() }, - enabled = !phase.busy, - modifier = Modifier.fillMaxWidth(), - tag = "message-address", - ) - } else { - // Look up first, then add. Adding straight from a typed - // string means a typo silently becomes a contact who is a stranger, - // and the first thing sent to them is a message meant for somebody - // else. - KitPrimaryButton( - text = "Look up", - onClick = { model.lookupMember(username) }, - enabled = username.isNotBlank() && !phase.busy, - modifier = Modifier.fillMaxWidth(), - tag = "lookup-contact", - ) - } - - phase.lookupMiss?.let { - Text( - it, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.testTag("lookup-miss"), - ) - } - - phase.lookedUp?.let { found -> - Column( - modifier = Modifier.fillMaxWidth().testTag("lookup-result"), - verticalArrangement = Arrangement.spacedBy(2.dp), - ) { - Text(found.displayName.ifBlank { found.handle }) - Text("@${found.handle}", style = MaterialTheme.typography.bodySmall) - // Named plainly: identity is checked by comparing safety - // numbers once the conversation exists, and a lookup result is - // not a substitute for that. - Text( - "Check their safety number after you start talking, this only says the " - + "handle exists on this server.", - style = MaterialTheme.typography.bodySmall, - ) - } - } - - Button( + KitPrimaryButton( + text = "Message them", onClick = { - model.addBuddy(username.trim()) - username = "" + model.openAddressChat(username) onDone() - model.clearLookup() }, - enabled = phase.lookedUp != null && !phase.busy, - modifier = Modifier.fillMaxWidth().testTag("confirm-contact"), - ) { Text("Add contact") } + enabled = !phase.busy, + modifier = Modifier.fillMaxWidth(), + tag = "message-address", + ) + } else { + // Look up first, then add. + KitPrimaryButton( + text = "Look up", + onClick = { model.lookupMember(username) }, + enabled = username.isNotBlank() && !phase.busy, + modifier = Modifier.fillMaxWidth(), + tag = "lookup-contact", + ) + } + + phase.lookupMiss?.let { + Text( + it, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.testTag("lookup-miss"), + ) + } + + phase.lookedUp?.let { found -> + Column( + modifier = Modifier.fillMaxWidth().testTag("lookup-result"), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text(found.displayName.ifBlank { found.handle }) + Text("@${found.handle}", style = MaterialTheme.typography.bodySmall) + // Named plainly: identity is checked by comparing safety + // numbers once the conversation exists, and a lookup result is + // not a substitute for that. + Text( + "Check their safety number after you start talking, this only says the " + + "handle exists on this server.", + style = MaterialTheme.typography.bodySmall, + ) + } + } + + Button( + onClick = { + model.addBuddy(username.trim()) + username = "" + onDone() + model.clearLookup() + }, + enabled = phase.lookedUp != null && !phase.busy, + modifier = Modifier.fillMaxWidth().testTag("confirm-contact"), + ) { Text("Add contact") } } /** P1: where the contact list lives, and the door that moves it. */ @@ -3344,22 +3231,22 @@ fun ContactListPrivacySection(model: MessengerViewModel) { Text("Contact list", style = MaterialTheme.typography.titleMedium) if (off) { Text( - "Your contact list lives only on your devices, kept in this " - + "phone's encrypted store, and carried by your encrypted " - + "history export. The server no longer knows who your " - + "contacts are.", + "Your contact list lives only on your devices, kept in this " + + "phone's encrypted store, and carried by your encrypted " + + "history export. The server no longer knows who your " + + "contacts are.", fontSize = 12.sp, color = Palette.SecondaryText, ) } else { Text( - "Today the server stores who your contacts are, because your " - + "online status is shown only to people on that list. Removing " - + "it keeps contacts in this phone's encrypted store instead, " - + "the honest cost is that buddies will see you as offline, and " - + "a new device gets your contacts from your encrypted history " - + "export rather than the server. Do this after your other " - + "devices have updated to this version.", + "Today the server stores who your contacts are, because your " + + "online status is shown only to people on that list. Removing " + + "it keeps contacts in this phone's encrypted store instead, " + + "the honest cost is that buddies will see you as offline, and " + + "a new device gets your contacts from your encrypted history " + + "export rather than the server. Do this after your other " + + "devices have updated to this version.", fontSize = 12.sp, color = Palette.SecondaryText, ) @@ -3370,13 +3257,19 @@ fun ContactListPrivacySection(model: MessengerViewModel) { modifier = Modifier.testTag("roster-forget"), ) { Text("Take my contact list off the server") } } else { - Text("The server's copy is deleted, and buddies will see you as " - + "offline from now on.", - fontSize = 12.sp, color = Palette.SecondaryText) + Text( + "The server's copy is deleted, and buddies will see you as " + + "offline from now on.", + fontSize = 12.sp, + color = Palette.SecondaryText, + ) Spacer(Modifier.height(Space.xs)) Row { OutlinedButton( - onClick = { model.takeRosterOffServer(); confirming = false }, + onClick = { + model.takeRosterOffServer() + confirming = false + }, modifier = Modifier.testTag("roster-forget-really"), ) { Text("Really remove it", color = MaterialTheme.colorScheme.error) } Spacer(Modifier.width(Space.sm)) @@ -3389,11 +3282,6 @@ fun ContactListPrivacySection(model: MessengerViewModel) { /** * The account's registered devices, every one is a full mailbox and a prekey * pool on the relay, which caps them at three. - * - * This card is the ONLY way off the cap. Registration refuses a fourth - * device, and every reinstall that didn't sign out leaves a corpse behind, - * sends still fan out to it, and it counts against the limit. The remedy has - * to be user-visible, or "the limit is 3" is a support ticket. */ @Composable fun RegisteredDevicesSection( @@ -3405,9 +3293,9 @@ fun RegisteredDevicesSection( Text("Registered devices", style = MaterialTheme.typography.titleMedium) Text( - "${phase.registeredDevices.size} of 3 registered. Each device holds its " - + "own keys and its own mailbox; removing one deletes its undelivered " - + "messages and signs it out for good.", + "${phase.registeredDevices.size} of 3 registered. Each device holds its " + + "own keys and its own mailbox; removing one deletes its undelivered " + + "messages and signs it out for good.", fontSize = 12.sp, color = Palette.SecondaryText, ) @@ -3427,10 +3315,11 @@ fun RegisteredDevicesSection( .forEach { device -> val isThis = device.deviceId == phase.deviceId Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = Space.xs) - .testTag("device-${device.deviceId}"), + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = Space.xs) + .testTag("device-${device.deviceId}"), verticalAlignment = Alignment.CenterVertically, ) { Column(modifier = Modifier.weight(1f)) { @@ -3456,10 +3345,8 @@ fun RegisteredDevicesSection( color = Palette.SecondaryText, ) } - // The device you are holding is not removable from itself, - // that is Sign out, which also wipes the local keys. Removing - // it here would leave this app confidently talking to a - // mailbox that no longer exists. + // The device you are holding is not removable from itself, that + // is Sign out, which also wipes the local keys. if (!isThis) { if (confirming == device.deviceId) { androidx.compose.material3.TextButton( @@ -3482,57 +3369,41 @@ fun RegisteredDevicesSection( } } - /** - * Presets, not a free-form duration. Every extra option is a decision the - * user has to make about something they mostly want to be roughly right, and - * the arbitrary values are the ones nobody can reason about later. + * Presets, not a free-form duration. */ -private val DISAPPEAR_CHOICES = listOf( - "Off" to 0, - "1 hour" to 3_600, - "1 day" to 86_400, - "1 week" to 604_800, -) +private val DISAPPEAR_CHOICES = + listOf( + "Off" to 0, + "1 hour" to 3_600, + "1 day" to 86_400, + "1 week" to 604_800, + ) -private val DISAPPEAR_LABELS = mapOf( - 3_600 to "an hour", - 86_400 to "a day", - 604_800 to "a week", -) +private val DISAPPEAR_LABELS = + mapOf( + 3_600 to "an hour", + 86_400 to "a day", + 604_800 to "a week", + ) /** * "Registered " for one device row. - * - * This shipped rendering its own source code. The string was written - * `"Registered ${'$'}{fmt.format(instant)}"`, and `${'$'}` emits a LITERAL - * dollar sign, so the template was never evaluated and the screen showed - * `Registered ${fmt.format(instant)}` to the user. Kotlin cannot warn about - * it: escaping a dollar is a legitimate thing to want, just never here. - * - * Lifted out of the Composable so it can be asserted. A string built inline in - * a `Text(...)` is a string nothing can test, which is why this survived. */ internal fun registeredLine(createdAt: String?): String { val raw = createdAt ?: return "Registered date unknown" return runCatching { val instant = java.time.Instant.parse(raw) - val fmt = java.time.format.DateTimeFormatter - .ofPattern("MMM d, yyyy HH:mm") - .withZone(java.time.ZoneId.systemDefault()) + val fmt = + java.time.format.DateTimeFormatter + .ofPattern("MMM d, yyyy HH:mm") + .withZone(java.time.ZoneId.systemDefault()) "Registered ${fmt.format(instant)}" }.getOrDefault("Registered $raw") } /** * How photos are sent. - * - * This app sent the ORIGINAL file. The picker read the bytes and handed - * them straight on, so a modern camera photo, often 5-15 MB, went at the - * relay's blob cap directly: over it the send simply failed, under it the - * recipient downloaded tens of megabytes nobody asked for. iOS has downscaled - * since it shipped, so the two platforms disagreed about what sending a photo - * even means. */ @Composable private fun ColumnScope.MediaQualitySection() { @@ -3550,7 +3421,10 @@ private fun ColumnScope.MediaQualitySection() { Text("Send full quality", fontSize = 16.sp, modifier = Modifier.weight(1f)) Switch( checked = full, - onCheckedChange = { on -> settings.fullQuality = on; full = on }, + onCheckedChange = { on -> + settings.fullQuality = on + full = on + }, modifier = Modifier.testTag("full-quality-media"), ) } @@ -3562,13 +3436,15 @@ private fun ColumnScope.MediaQualitySection() { Text("Preview links you send", fontSize = 16.sp, modifier = Modifier.weight(1f)) Switch( checked = previews, - onCheckedChange = { on -> settings.linkPreviews = on; previews = on }, + onCheckedChange = { on -> + settings.linkPreviews = on + previews = on + }, modifier = Modifier.testTag("link-previews"), ) } - // Says exactly who learns what. The receiving side is the part people - // assume is leaky and is the part that is not. + // Says exactly who learns what. Text( "Link previews are fetched by THIS phone before the message is sent, and " + "travel inside the encryption, whoever you send to never contacts the " + @@ -3578,70 +3454,64 @@ private fun ColumnScope.MediaQualitySection() { color = Palette.SecondaryText, ) - // Says what the ceiling is rather than implying there is none. "Full - // quality" that silently failed on a large photo would be a worse answer - // than the compression it replaced. + // Says what the ceiling is rather than implying there is none. Text( - "Photos are sent at up to " + (if (full) "4096" else "2048") + " pixels on the " - + "longest side. Very large photos are reduced further until they fit what " - + "the server accepts, a photo that failed to send would be worse than a " - + "slightly softer one.", + "Photos are sent at up to " + (if (full) "4096" else "2048") + " pixels on the " + + "longest side. Very large photos are reduced further until they fit what " + + "the server accepts, a photo that failed to send would be worse than a " + + "slightly softer one.", fontSize = 12.sp, color = Palette.SecondaryText, ) } +private val SNOOZE_CHOICES = + listOf( + "Off" to 0, + "1 hour" to 3_600, + "8 hours" to 28_800, + "1 day" to 86_400, + ) -private val SNOOZE_CHOICES = listOf( - "Off" to 0, - "1 hour" to 3_600, - "8 hours" to 28_800, - "1 day" to 86_400, -) - -private val SEND_LATER_CHOICES = listOf( - "In 1 hour" to 3_600, - "In 3 hours" to 10_800, - "Tomorrow" to 86_400, -) - -private val REMINDER_CHOICES = listOf( - "Off" to 0, - "1 hour" to 3_600, - "3 hours" to 10_800, - "Tomorrow" to 86_400, -) +private val SEND_LATER_CHOICES = + listOf( + "In 1 hour" to 3_600, + "In 3 hours" to 10_800, + "Tomorrow" to 86_400, + ) +private val REMINDER_CHOICES = + listOf( + "Off" to 0, + "1 hour" to 3_600, + "3 hours" to 10_800, + "Tomorrow" to 86_400, + ) /** * The card under a message containing a link (spec §6.12). - * - * Drawn entirely from bytes the sender put in the ciphertext. Nothing - * here fetches: a card each recipient fetched would tell the site the reader's - * IP, that they received a link, and roughly when they opened it, once per - * member on a group message. */ @Composable private fun LinkPreviewCard( preview: io.flamenet.e2e.Content.LinkPreview, mine: Boolean, ) { - val onBubble = if (mine) MaterialTheme.colorScheme.onPrimary - else MaterialTheme.colorScheme.onSurface + val onBubble = + if (mine) { + MaterialTheme.colorScheme.onPrimary + } else { + MaterialTheme.colorScheme.onSurface + } Column( - modifier = Modifier - .padding(top = Space.xs) - .fillMaxWidth() - .testTag("link-preview"), + modifier = + Modifier + .padding(top = Space.xs) + .fillMaxWidth() + .testTag("link-preview"), ) { - // The host, always, and separately from the title. Title and - // description come from a page the sender did not write, so a card - // claiming one destination while the link points at another is a - // phishing primitive. The host is the part that cannot lie. + // The host, always, and separately from the title. Text(preview.host, fontSize = 11.sp, color = onBubble.copy(alpha = 0.7f)) - // Plain text, never markup, and line-limited. Both fields are - // attacker-controlled; they are bounded on the way in as well, so this - // is the second of two limits rather than the only one. + // Plain text, never markup, and line-limited. Text( preview.title, fontSize = 14.sp, @@ -3664,12 +3534,6 @@ private fun LinkPreviewCard( /** * Everything you can set about one conversation, behind ⋯. - * - * These were three full-width panels of chips, disappearing, snooze, - * remind me, send later, stacked above the messages, and together they took - * half the screen in a conversation you had opened to read. Controls belong in - * a menu; only STATE belongs on the thread, which is what - * [ConversationStatusLine] keeps. */ @Composable private fun ConversationMenu( @@ -3680,45 +3544,60 @@ private fun ConversationMenu( ) { val peerId = chat.peerId ?: return var open by remember { mutableStateOf(false) } - val snoozed = chat.threadId in - ((model.phase.value as? MessengerViewModel.Phase.SignedIn)?.snoozedThreads ?: emptySet()) + val snoozed = + chat.threadId in + ((model.phase.value as? MessengerViewModel.Phase.SignedIn)?.snoozedThreads ?: emptySet()) Box { - IconAction(icon = KitIcon.More, label = "More", tag = "chat-more", - onClick = { open = true }) + IconAction( + icon = KitIcon.More, + label = "More", + tag = "chat-more", + onClick = { open = true }, + ) androidx.compose.material3.DropdownMenu( expanded = open, onDismissRequest = { open = false }, ) { MenuHeading("Disappearing messages") DISAPPEAR_CHOICES.forEach { (label, seconds) -> - MenuChoice(label, selected = chat.disappearSeconds == seconds, - tag = "disappear-$seconds") { - model.setDisappearing(seconds); open = false + MenuChoice( + label, + selected = chat.disappearSeconds == seconds, + tag = "disappear-$seconds", + ) { + model.setDisappearing(seconds) + open = false } } androidx.compose.material3.HorizontalDivider() MenuHeading("Snooze this chat") SNOOZE_CHOICES.forEach { (label, seconds) -> - MenuChoice(label, selected = (seconds == 0) == !snoozed, - tag = "snooze-$seconds") { - model.snooze(peerId, seconds); open = false + MenuChoice( + label, + selected = (seconds == 0) == !snoozed, + tag = "snooze-$seconds", + ) { + model.snooze(peerId, seconds) + open = false } } androidx.compose.material3.HorizontalDivider() MenuHeading("Remind me if no reply") REMINDER_CHOICES.forEach { (label, seconds) -> - MenuChoice(label, selected = (seconds == 0) == (chat.replyReminderAt == 0L), - tag = "remind-$seconds") { - model.setReplyReminder(peerId, seconds); open = false + MenuChoice( + label, + selected = (seconds == 0) == (chat.replyReminderAt == 0L), + tag = "remind-$seconds", + ) { + model.setReplyReminder(peerId, seconds) + open = false } } - // Only offered when there is something to send. "Send later" with - // an empty composer is a control whose only possible effect is - // nothing. + // Only offered when there is something to send. if (draft.isNotBlank()) { androidx.compose.material3.HorizontalDivider() MenuHeading("Send later") @@ -3761,12 +3640,6 @@ private fun MenuChoice( /** * One line saying what is currently set on this conversation. - * - * State stays on the thread even though the controls moved. A - * disappearing timer both people are deleting on, a snooze, a pending reminder - * and a queued message are facts about the room, and a fact you have to open a - * menu to discover is one you will not rely on. Absent entirely when nothing is - * set, so an ordinary conversation carries no chrome at all. */ @Composable private fun ColumnScope.ConversationStatusLine( @@ -3774,20 +3647,27 @@ private fun ColumnScope.ConversationStatusLine( chat: MessengerViewModel.Phase.Chat, ) { val peerId = chat.peerId ?: return - val snoozed = chat.threadId in - ((model.phase.value as? MessengerViewModel.Phase.SignedIn)?.snoozedThreads ?: emptySet()) + val snoozed = + chat.threadId in + ((model.phase.value as? MessengerViewModel.Phase.SignedIn)?.snoozedThreads ?: emptySet()) - val parts = buildList { - DISAPPEAR_LABELS[chat.disappearSeconds]?.let { - add("disappearing after $it, on both phones") + val parts = + buildList { + DISAPPEAR_LABELS[chat.disappearSeconds]?.let { + add("disappearing after $it, on both phones") + } + if (snoozed) add("snoozed") + if (chat.replyReminderAt != 0L) add("reminder set") + if (chat.scheduled.isNotEmpty()) { + add( + if (chat.scheduled.size == 1) { + "1 message waiting to send" + } else { + "${chat.scheduled.size} messages waiting to send" + }, + ) + } } - if (snoozed) add("snoozed") - if (chat.replyReminderAt != 0L) add("reminder set") - if (chat.scheduled.isNotEmpty()) { - add(if (chat.scheduled.size == 1) "1 message waiting to send" - else "${chat.scheduled.size} messages waiting to send") - } - } if (parts.isEmpty()) return Row( @@ -3811,15 +3691,6 @@ private fun ColumnScope.ConversationStatusLine( /** * The relays that hold mail for this account (§20.4). - * - * Your address survives a relay disappearing; your mailbox does not. - * Mail sitting at a relay that goes away is gone with it, and a contact holding - * only that relay's name has nowhere to write. A second mailbox fixes both, - * and only if it is added first, because afterwards there is nothing left to - * publish a new record from and no way to say where you went. - * - * So the copy here does not say "backup" and stop. It says what is protected, - * what is not, and why it has to be done in advance. */ @Composable fun ColumnScope.MailboxesSection( @@ -3834,10 +3705,11 @@ fun ColumnScope.MailboxesSection( SettingsRow( label = "Mailboxes", - description = "Senders deliver a copy to every relay listed here, so losing one " - + "does not lose the message. Add one before you need it: if a relay " - + "disappears while it is your only mailbox your address still works, but " - + "nobody can find out where you moved to without you telling them.", + description = + "Senders deliver a copy to every relay listed here, so losing one " + + "does not lose the message. Add one before you need it: if a relay " + + "disappears while it is your only mailbox your address still works, but " + + "nobody can find out where you moved to without you telling them.", ) {} if (phase.mailboxes.isEmpty()) { @@ -3860,20 +3732,28 @@ fun ColumnScope.MailboxesSection( modifier = Modifier.weight(1f).testTag("mailbox-$relay"), ) when { - relay == phase.relay -> + relay == phase.relay -> { Text("this device", fontSize = 11.sp, color = Palette.SecondaryText) + } + // A mailbox in the published record that this phone holds - // no credential for. Mail arrives there and this device - // cannot read it, worth saying rather than drawing as an - // ordinary row. Another of your devices added it, or this - // one was reinstalled. - relay !in phase.readableMailboxes -> - Text("not readable here", fontSize = 11.sp, - color = MaterialTheme.colorScheme.error) - else -> KitChip( - text = "Remove", selected = false, - onClick = { model.removeMailbox(relay) }, - tag = "remove-mailbox-$relay") + // no credential for. + relay !in phase.readableMailboxes -> { + Text( + "not readable here", + fontSize = 11.sp, + color = MaterialTheme.colorScheme.error, + ) + } + + else -> { + KitChip( + text = "Remove", + selected = false, + onClick = { model.removeMailbox(relay) }, + tag = "remove-mailbox-$relay", + ) + } } } } @@ -3881,8 +3761,12 @@ fun ColumnScope.MailboxesSection( Spacer(Modifier.height(Space.sm)) if (!adding) { - KitChip(text = "Add a mailbox", selected = false, - onClick = { adding = true }, tag = "add-mailbox") + KitChip( + text = "Add a mailbox", + selected = false, + onClick = { adding = true }, + tag = "add-mailbox", + ) return } @@ -3899,14 +3783,16 @@ fun ColumnScope.MailboxesSection( onValueChange = { passphrase = it }, placeholder = { Text("Your passphrase", fontSize = 14.sp) }, singleLine = true, - visualTransformation = androidx.compose.ui.text.input.PasswordVisualTransformation(), + visualTransformation = + androidx.compose.ui.text.input + .PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth().testTag("new-mailbox-passphrase"), ) Spacer(Modifier.height(Space.xs)) Text( - "You need an account on that relay already. Sign up there with the same " - + "passphrase first, that is what gives you the same address, and it is " - + "why the passphrase is asked for here.", + "You need an account on that relay already. Sign up there with the same " + + "passphrase first, that is what gives you the same address, and it is " + + "why the passphrase is asked for here.", fontSize = 12.sp, color = Palette.SecondaryText, ) @@ -3915,10 +3801,7 @@ fun ColumnScope.MailboxesSection( text = "Add", onClick = { model.addMailbox(host, passphrase) - // The passphrase does not outlive the attempt. It was collected - // for one sign-in at one relay; leaving it in a composable's state - // keeps a copy of the account's secret on screen for as long as the - // section is open. + // The passphrase does not outlive the attempt. passphrase = "" host = "" adding = false @@ -3931,16 +3814,6 @@ fun ColumnScope.MailboxesSection( /** * A document that arrived in a conversation. - * - * The classification is shown, and it is a marking. It says how the - * sender had it filed, it does not mean this device was cleared for it, and - * the row must not imply that. Whoever can read the message already has the - * key. - * - * No "Save to Files" here yet: this build has no Files of its own, so - * offering one would be a button that cannot work. It shows what arrived - * rather than pretending to more, which is the honest half of parity while the - * other half is built. */ @Composable private fun FileBubble( @@ -3948,17 +3821,18 @@ private fun FileBubble( mine: Boolean, onSave: (() -> Unit)? = null, ) { - val onColour = if (mine) MaterialTheme.colorScheme.onPrimary - else MaterialTheme.colorScheme.onSurface + val onColour = + if (mine) { + MaterialTheme.colorScheme.onPrimary + } else { + MaterialTheme.colorScheme.onSurface + } Row( verticalAlignment = Alignment.Top, horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.padding(vertical = 2.dp).testTag("chat.file"), ) { - // The kit's own icon set, not Material's. This file imports no - // Material icons at all, and the version that reached for one did not - // compile, which went unnoticed because the build was failing on a - // missing ANDROID_HOME before it ever got as far as Kotlin. + // The kit's own icon set, not Material's. Icon( KitIcon.Attach, contentDescription = null, @@ -3976,8 +3850,8 @@ private fun FileBubble( ) Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { Text( - android.text.format.Formatter.formatShortFileSize( - LocalContext.current, ref.bytes.toLong()), + android.text.format.Formatter + .formatShortFileSize(LocalContext.current, ref.bytes.toLong()), fontSize = 12.sp, color = onColour.copy(alpha = 0.75f), ) @@ -3989,30 +3863,28 @@ private fun FileBubble( fontSize = 10.sp, fontWeight = FontWeight.Bold, color = onColour.copy(alpha = 0.9f), - modifier = Modifier - .background( - onColour.copy(alpha = 0.14f), - RoundedCornerShape(4.dp), - ) - .padding(horizontal = 6.dp, vertical = 2.dp) - .testTag("chat.file.classification"), + modifier = + Modifier + .background( + onColour.copy(alpha = 0.14f), + RoundedCornerShape(4.dp), + ).padding(horizontal = 6.dp, vertical = 2.dp) + .testTag("chat.file.classification"), ) } } - // Offered on a received file only. Saving something already - // in the vault back into it would make a second, unrelated copy at - // whatever label this bubble happens to carry, which is how one - // document quietly becomes two at different classifications. + // Offered on a received file only. if (onSave != null) { Text( "Save to Files", fontSize = 12.sp, fontWeight = FontWeight.Medium, color = onColour, - modifier = Modifier - .clickable(onClick = onSave) - .padding(top = 2.dp) - .testTag("chat.file.save"), + modifier = + Modifier + .clickable(onClick = onSave) + .padding(top = 2.dp) + .testTag("chat.file.save"), ) } } @@ -4021,29 +3893,12 @@ private fun FileBubble( /** * Hard-coded, matching iOS's local vocabulary, because there is no server to - * ask on either side any more. An ordinal outside the set is shown as itself - * rather than guessed at, inventing "Secret" for a level this deployment calls - * something else would be worse than a number. - * - * One vocabulary, not two. This was a second `when` listing the same - * four names, agreeing with [FileLevels] by coincidence, with nothing making - * them agree. Two places to change is how one of them gets changed: rename a - * level for the Files screen and the chat bubble keeps showing the old word for - * the same number, on the same phone, in the same conversation. - * - * It matters more than a rename. The ORDINAL is what travels on the wire, - * so the name is the only thing telling somebody what a number means, and two - * answers to that is worse than none. + * ask on either side any more. */ -private fun classificationName(ordinal: Int): String = - FileLevels.levelName(ordinal.toShort()) +private fun classificationName(ordinal: Int): String = FileLevels.levelName(ordinal.toShort()) /** * What is being typed after an unfinished `@`, or null when there isn't one. - * - * Driven off the draft rather than a mode flag: somebody deleting their way - * back past the `@` expects the list to go, and a flag has to be remembered to - * be cleared. A space ends it, "@ " is somebody typing an address. */ internal fun mentionQuery(draft: String): String? { val at = draft.lastIndexOf('@') @@ -4055,27 +3910,17 @@ internal fun mentionQuery(draft: String): String? { /** * A group member by the name you know them by. - * - * The relay stores ids and no names, so somebody who is in the group but not - * in your contacts has no name to show. They stay mentionable under their id - * rather than being hidden, being un-mentionable is worse than being ugly. */ internal fun memberLabel( id: Long, buddies: List, -): String = buddies.firstOrNull { it.userId == id } - ?.let { it.displayName.ifBlank { it.handle } } ?: "user $id" +): String = + buddies + .firstOrNull { it.userId == id } + ?.let { it.displayName.ifBlank { it.handle } } ?: "user $id" /** * Change the wording of a message already sent (§6.15). - * - * Opens with the current text already in it. An edit box that starts - * empty is a retype, and somebody correcting one word in a long message would - * rather not. The cursor sits at the end for the same reason. - * - * Saving nothing is refused rather than treated as a delete. Emptying the - * box is the shape of a slip, and "Delete for me" is right there in the same - * menu for anybody who meant it. */ @Composable private fun EditMessageSheet( @@ -4096,13 +3941,10 @@ private fun EditMessageSheet( singleLine = false, ) Spacer(Modifier.height(Space.xs)) - // Says what the other side will see. An edit is not a quiet - // repair: the recipient's bubble changes and is marked, and - // somebody about to correct a typo should know that before they - // do it rather than discover it afterwards. + // Says what the other side will see. Text( - "Everyone in this conversation sees the new wording, and their " - + "copy is marked as edited.", + "Everyone in this conversation sees the new wording, and their " + + "copy is marked as edited.", fontSize = 12.sp, color = Palette.SecondaryText, ) @@ -4123,33 +3965,15 @@ private fun EditMessageSheet( /** * Pass a message's words on to another conversation (§6.16). - * - * The list is people and groups, and the message travels without its - * author. What is sent is the text with `fwd` set, no original thread, no - * name, no quote. A forward that named its source would republish a private - * message's author to somebody who was never in that conversation, which is - * the one thing §6.16 exists to prevent. */ + /** * Reporting a message to whoever runs the relay (§6.25, android#20). - * - * The consent is the feature; the form is scaffolding around it. - * Everything else this app sends is sealed, and a report is not: the relay - * cannot open a message, so a report about one is actionable only if the - * reporter hands over what they saw. That is a reasonable bargain and an - * invisible one, so it is stated here, in these words, before anything is sent - * not in a policy document and not after the fact. - * - * Blocking is offered beside it and never merged into it. A block is the - * reporter's own decision and takes effect immediately; a report is a request - * to somebody who may do nothing. */ @Composable /** - * Was `private` to this file, which is why the stories feed could not use - * it and Android had no way to report a story at all. Widened rather than - * copied: two report sheets would be two places for the consent wording to - * drift apart, and the consent is the part that matters. + * Was `private` to this file, which is why the stories feed could not use it + * and Android had no way to report a story at all. */ internal fun ReportSheet( authorName: String, @@ -4167,19 +3991,21 @@ internal fun ReportSheet( title = { Text("Report message") }, text = { Column( - modifier = Modifier - .testTag("report-sheet") - .verticalScroll(rememberScrollState()), + modifier = + Modifier + .testTag("report-sheet") + .verticalScroll(rememberScrollState()), ) { Text("What is wrong", fontSize = 12.sp, color = Palette.SecondaryText) Spacer(Modifier.height(Space.xs)) MessengerViewModel.ReportReason.entries.forEach { option -> Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .clickable { reason = option } - .testTag("report-reason-${option.wire}"), + modifier = + Modifier + .fillMaxWidth() + .clickable { reason = option } + .testTag("report-reason-${option.wire}"), ) { androidx.compose.material3.RadioButton( selected = reason == option, @@ -4202,10 +4028,8 @@ internal fun ReportSheet( Text("Include a copy of the message", fontSize = 14.sp) } - // The disclosure, in the words §6.25 requires: what leaves - // the device, who can read it, and that it cannot be taken - // back. "Your report will be reviewed" would be a sentence - // about them rather than about what is happening. + // The disclosure, in the words §6.25 requires: what leaves the + // device, who can read it, and that it cannot be taken back. Text( if (includeContent) { "Whoever runs this server cannot read your messages. Sending " + @@ -4217,13 +4041,18 @@ internal fun ReportSheet( "message themselves, so there may be nothing for them to " + "act on." }, - fontSize = 12.sp, color = Palette.SecondaryText, + fontSize = 12.sp, + color = Palette.SecondaryText, ) if (includeContent && preview.isNotBlank()) { Spacer(Modifier.height(Space.xs)) - Text(preview, fontSize = 12.sp, color = Palette.SecondaryText, - maxLines = 3) + Text( + preview, + fontSize = 12.sp, + color = Palette.SecondaryText, + maxLines = 3, + ) } Spacer(Modifier.height(Space.sm)) @@ -4240,10 +4069,11 @@ internal fun ReportSheet( modifier = Modifier.testTag("report-block"), ) { Text("Block $authorName") } Text( - "Blocking takes effect immediately and is your own decision. A " - + "report is a request to whoever runs this server, and nothing " - + "may come of it.", - fontSize = 12.sp, color = Palette.SecondaryText, + "Blocking takes effect immediately and is your own decision. A " + + "report is a request to whoever runs this server, and nothing " + + "may come of it.", + fontSize = 12.sp, + color = Palette.SecondaryText, ) } }, @@ -4273,11 +4103,9 @@ private fun ForwardSheet( text = { Column(modifier = Modifier.testTag("forward-list")) { Text( - // States the cost plainly. "Forwarded" is all the - // recipient is told, and somebody passing on a message - // should know their own name is what travels with it. - "This sends the words on their own. Whoever receives it sees " - + "that it was forwarded, and sees it from you.", + // States the cost plainly. + "This sends the words on their own. Whoever receives it sees " + + "that it was forwarded, and sees it from you.", fontSize = 12.sp, color = Palette.SecondaryText, ) @@ -4285,21 +4113,23 @@ private fun ForwardSheet( groups.forEach { group -> Text( group.name.ifBlank { "Group" }, - modifier = Modifier - .fillMaxWidth() - .clickable { onPick(null, group) } - .padding(vertical = 10.dp) - .testTag("forward-group-${group.id}"), + modifier = + Modifier + .fillMaxWidth() + .clickable { onPick(null, group) } + .padding(vertical = 10.dp) + .testTag("forward-group-${group.id}"), ) } buddies.forEach { buddy -> Text( buddy.displayName.ifBlank { buddy.handle }, - modifier = Modifier - .fillMaxWidth() - .clickable { onPick(buddy.userId, null) } - .padding(vertical = 10.dp) - .testTag("forward-to-${buddy.userId}"), + modifier = + Modifier + .fillMaxWidth() + .clickable { onPick(buddy.userId, null) } + .padding(vertical = 10.dp) + .testTag("forward-to-${buddy.userId}"), ) } } diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/ContactDetail.kt b/app/src/main/kotlin/io/flamenet/messenger/app/ContactDetail.kt index d566c37..51d7545 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ContactDetail.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ContactDetail.kt @@ -38,16 +38,6 @@ import io.flamenet.client.MessengerApi /** * One contact, as the kit draws it: a cover, a name plate, and a rail of * actions floating at the seam between them. - * - * Android had no contact screen at all. Everything you could do to a - * person was an inline row that unfolded under their list entry. Block, Clear - * chat, Remove, three text buttons in a line, with no room for anything that - * needed explaining and no place to put a safety number. iOS has the kit's - * version; this is the same screen. - * - * The rail carries only actions that do something. The kit's has a - * "personal page" icon; there is no such page here, and a fourth control that - * opens nothing is worse than a rail of three. */ @Composable fun ColumnScope.ContactDetail( @@ -59,22 +49,31 @@ fun ColumnScope.ContactDetail( var confirmingRemove by remember { mutableStateOf(false) } val isBlocked = buddy.userId in phase.blocked val name = buddy.displayName.ifBlank { buddy.handle }.ifBlank { "user ${buddy.userId}" } - val initials = remember(name) { - name.trim().split(" ").filter { it.isNotBlank() } - .take(2).mapNotNull { it.firstOrNull() }.joinToString("").uppercase().ifEmpty { "?" } - } + val initials = + remember(name) { + name + .trim() + .split(" ") + .filter { it.isNotBlank() } + .take(2) + .mapNotNull { it.firstOrNull() } + .joinToString("") + .uppercase() + .ifEmpty { "?" } + } Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, ) { Box( - modifier = Modifier - .size(Kit.icon + Space.md) - .clip(CircleShape) - .clickable(onClick = onClose) - .semantics { contentDescription = "Back" } - .testTag("contact-back"), + modifier = + Modifier + .size(Kit.icon + Space.md) + .clip(CircleShape) + .clickable(onClick = onClose) + .semantics { contentDescription = "Back" } + .testTag("contact-back"), contentAlignment = Alignment.Center, ) { Icon( @@ -89,20 +88,17 @@ fun ColumnScope.ContactDetail( Box(modifier = Modifier.fillMaxWidth()) { Column { - // The cover. A photo in the kit; we hold no avatars, so it is this - // person's initials on a wash of the accent, colourless, but at - // least theirs, rather than a stock face standing in for them. + // The cover. Box( - modifier = Modifier - .fillMaxWidth() - .height(200.dp) - .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)), + modifier = + Modifier + .fillMaxWidth() + .height(200.dp) + .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)), contentAlignment = Alignment.Center, ) { // Their picture if they have sent us one; their initials if - // not. Initials are the normal case, not a placeholder, a - // picture only exists once somebody has set one AND messaged - // you, so most covers will be letters and that is fine. + // not. val picture = phase.avatars[buddy.userId] if (picture != null) { AttachedImage(picture, model) @@ -118,18 +114,19 @@ fun ColumnScope.ContactDetail( // Name plate: 60 tall, radius 16, on the kit's neutral fill. Row( - modifier = Modifier - .fillMaxWidth() - .height(60.dp) - .clip(RoundedCornerShape(Radius.medium)) - .background(Palette.SurfaceAlt) - .padding(horizontal = Kit.margin), + modifier = + Modifier + .fillMaxWidth() + .height(60.dp) + .clip(RoundedCornerShape(Radius.medium)) + .background(Palette.SurfaceAlt) + .padding(horizontal = Kit.margin), verticalAlignment = Alignment.CenterVertically, ) { Column(modifier = Modifier.weight(1f)) { Text(name, fontSize = 20.sp, fontWeight = FontWeight.Bold, maxLines = 1) - // Presence goes on THIS line, not the trailing edge, - // the rail floats over the plate's right-hand third, so + // Presence goes on THIS line, not the trailing edge, the + // rail floats over the plate's right-hand third, so // anything put there is behind it. Row(verticalAlignment = Alignment.CenterVertically) { if (buddy.presence.equals("online", true)) { @@ -138,7 +135,8 @@ fun ColumnScope.ContactDetail( } Text( listOfNotNull( - buddy.handle.takeIf { it.isNotBlank() && !it.equals(name, true) } + buddy.handle + .takeIf { it.isNotBlank() && !it.equals(name, true) } ?.let { "@$it" }, buddy.presence.takeIf { it.isNotBlank() }, ).joinToString(" · "), @@ -156,17 +154,19 @@ fun ColumnScope.ContactDetail( // The rail, overlapping the seam on purpose, that is what stops the // cover reading as a dead banner. Column( - modifier = Modifier - .align(Alignment.TopEnd) - .padding(top = 120.dp, end = Kit.margin) - .width(60.dp) - .clip(RoundedCornerShape(30.dp)) - .background( - if (isBlocked) Palette.SecondaryText else MaterialTheme.colorScheme.primary - ), + modifier = + Modifier + .align(Alignment.TopEnd) + .padding(top = 120.dp, end = Kit.margin) + .width(60.dp) + .clip(RoundedCornerShape(30.dp)) + .background( + if (isBlocked) Palette.SecondaryText else MaterialTheme.colorScheme.primary, + ), ) { RailButton(KitIcon.Chats, "Message", "contact-message", enabled = !isBlocked) { - model.openChat(buddy.userId, buddy.displayName); onClose() + model.openChat(buddy.userId, buddy.displayName) + onClose() } RailDivider() RailButton(KitIcon.Call, "Call", "contact-call", enabled = !isBlocked) { @@ -196,19 +196,26 @@ fun ColumnScope.ContactDetail( "Clear this conversation", "Removes it from this phone only. Their copy is theirs.", tag = "contact-clear", - ) { model.clearHistory(io.flamenet.e2e.Vault.threadForUser(buddy.userId)) } + ) { + model.clearHistory( + io.flamenet.e2e.Vault + .threadForUser(buddy.userId), + ) + } - // Confirms in place rather than in a dialog. A dialog is dismissed - // reflexively; a row that changes to say what it is about to do is - // read. Same posture as the Account screen's two destructive rows. + // Confirms in place rather than in a dialog. DetailRow( if (confirmingRemove) "Really remove, this cannot be undone" else "Remove contact", "Takes them off your list. It does not block them.", tag = "contact-remove", danger = true, ) { - if (confirmingRemove) { model.removeBuddy(buddy.userId); onClose() } - else confirmingRemove = true + if (confirmingRemove) { + model.removeBuddy(buddy.userId) + onClose() + } else { + confirmingRemove = true + } } } } @@ -227,11 +234,12 @@ private fun RailButton( onClick: () -> Unit, ) { Box( - modifier = Modifier - .size(60.dp) - .clickable(enabled = enabled, onClick = onClick) - .semantics { contentDescription = label } - .testTag(tag), + modifier = + Modifier + .size(60.dp) + .clickable(enabled = enabled, onClick = onClick) + .semantics { contentDescription = label } + .testTag(tag), contentAlignment = Alignment.Center, ) { Icon( @@ -252,11 +260,12 @@ private fun DetailRow( onClick: (() -> Unit)? = null, ) { Row( - modifier = Modifier - .fillMaxWidth() - .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier) - .then(if (tag != null) Modifier.testTag(tag) else Modifier) - .padding(vertical = Kit.bubblePadding), + modifier = + Modifier + .fillMaxWidth() + .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier) + .then(if (tag != null) Modifier.testTag(tag) else Modifier) + .padding(vertical = Kit.bubblePadding), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Space.md), ) { diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/DisplaySection.kt b/app/src/main/kotlin/io/flamenet/messenger/app/DisplaySection.kt index 5473f21..f608e65 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/DisplaySection.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/DisplaySection.kt @@ -22,12 +22,6 @@ import androidx.compose.ui.unit.sp /** * How this phone draws things. - * - * Named for what they do. iOS shipped "Show online status" on a Privacy - * & Security screen, where every other messenger uses those words for the - * reciprocal thing; somebody switching it off to disappear would have been - * wrong, because it only hides other people's dots from their own phone. It was - * renamed on 2026-09-06 and this section is named that way from the start. */ @Composable fun ColumnScope.DisplaySection() { @@ -41,25 +35,36 @@ fun ColumnScope.DisplaySection() { Text("Display", style = MaterialTheme.typography.titleMedium) @Composable - fun row(label: String, tag: String, value: Boolean, onChange: (Boolean) -> Unit) { + fun row( + label: String, + tag: String, + value: Boolean, + onChange: (Boolean) -> Unit, + ) { Row( modifier = Modifier.fillMaxWidth().padding(vertical = Space.sm), verticalAlignment = Alignment.CenterVertically, ) { Text(label, fontSize = 16.sp, modifier = Modifier.weight(1f)) - Switch(checked = value, onCheckedChange = onChange, - modifier = Modifier.testTag(tag)) + Switch( + checked = value, + onCheckedChange = onChange, + modifier = Modifier.testTag(tag), + ) } } row("Show whether contacts are online", "display-online", online) { on -> - settings.showOnlineStatus = on; online = on + settings.showOnlineStatus = on + online = on } row("Show the time on messages", "display-timestamps", times) { on -> - settings.showTimestamps = on; times = on + settings.showTimestamps = on + times = on } row("Larger message text", "display-larger-type", larger) { on -> - settings.largerType = on; larger = on + settings.largerType = on + larger = on } Spacer(Modifier.height(Space.sm)) @@ -68,7 +73,10 @@ fun ColumnScope.DisplaySection() { DisplaySettings.Appearance.entries.forEach { option -> KitSecondaryButton( text = if (option == appearance) "✓ ${option.label}" else option.label, - onClick = { settings.appearance = option; appearance = option }, + onClick = { + settings.appearance = option + appearance = option + }, modifier = Modifier.weight(1f).padding(end = Space.xs), tag = "display-appearance-${option.stored}", ) @@ -76,13 +84,13 @@ fun ColumnScope.DisplaySection() { } Spacer(Modifier.height(Space.xs)) - // Says the thing the label alone cannot: this hides them from you, not - // you from them. + // Says the thing the label alone cannot: this hides them from you, not you + // from them. Text( - "These change what this phone shows you. Your contacts are still told when " - + "you are online whichever way this is set, hiding yourself is not " - + "something the app can do yet.\n\nAppearance takes effect next time the " - + "app starts.", + "These change what this phone shows you. Your contacts are still told when " + + "you are online whichever way this is set, hiding yourself is not " + + "something the app can do yet.\n\nAppearance takes effect next time the " + + "app starts.", fontSize = 12.sp, color = Palette.SecondaryText, ) diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/DisplaySettings.kt b/app/src/main/kotlin/io/flamenet/messenger/app/DisplaySettings.kt index a0868b2..60d0acb 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/DisplaySettings.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/DisplaySettings.kt @@ -3,23 +3,11 @@ package io.flamenet.messenger.app import android.content.Context /** - * How this phone draws things. Preferences, not privacy. - * - * None of these change what anybody else sees. "Show whether contacts - * are online" hides the dot on THIS screen; the relay still tells your contacts - * when you are online, exactly as before. iOS shipped the same three settings - * with names, "Show online status", "Show last seen", that every other - * messenger uses for the reciprocal thing, on a screen called Privacy & - * Security, and somebody switching them off to disappear would have been wrong. - * They were renamed on 2026-09-06; these are named that way from the start. - * - * There is no last-seen setting here, because Android does not show last - * seen at all. iOS does. That is a missing feature rather than a missing - * toggle, and adding a switch for something the app never draws would be the - * exact decoration the parity scrub exists to catch. + * How this phone draws things. */ -class DisplaySettings(context: Context) { - +class DisplaySettings( + context: Context, +) { private val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) /** Draw the presence dot on avatars. Hides theirs from you, not you from them. */ @@ -39,39 +27,33 @@ class DisplaySettings(context: Context) { /** * Light, dark, or whatever the phone is set to. - * - * Stored as a string and clamped on read. An unrecognised value, from a - * future build, or a corrupted preference, reads as `system`, which is - * what somebody who never touched this setting has. */ var appearance: Appearance get() = Appearance.from(prefs.getString(APPEARANCE, null)) set(value) = prefs.edit().putString(APPEARANCE, value.stored).apply() - enum class Appearance(val stored: String, val label: String) { + enum class Appearance( + val stored: String, + val label: String, + ) { SYSTEM("system", "Match the phone"), LIGHT("light", "Light"), - DARK("dark", "Dark"); + DARK("dark", "Dark"), + ; companion object { - fun from(stored: String?) = - entries.firstOrNull { it.stored == stored } ?: SYSTEM + fun from(stored: String?) = entries.firstOrNull { it.stored == stored } ?: SYSTEM } } companion object { /** * The message font size, in sp. - * - * Pure and lifted out so it can be asserted, for the same reason - * `NotificationSettings.messageText` is: a size computed inline in a - * `Text(...)` is a number nothing can test. */ /** - * The base is 16, not a number of my choosing: the kit specifies - * 16sp for a message body ("Body Text 2"), and `ChatScreens` says so at - * the point it draws one. A setting that quietly restyled every message - * when it was switched OFF would be a worse bug than the one it fixes. + * The base is 16, not a number of my choosing: the kit specifies 16sp + * for a message body ("Body Text 2"), and `ChatScreens` says so at the + * point it draws one. */ fun messageTextSize(largerType: Boolean): Int = if (largerType) 20 else 16 diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/FilesScreen.kt b/app/src/main/kotlin/io/flamenet/messenger/app/FilesScreen.kt index 1b1f76a..fa49015 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/FilesScreen.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/FilesScreen.kt @@ -41,24 +41,6 @@ import java.util.UUID /** * Files, on this phone. - * - * Local-first. Nothing here talks to a server. Files are encrypted into - * a vault in this app's own container, and the only thing that ever leaves the - * device is a transfer somebody asks for, where the relay is a courier holding - * ciphertext until the other device collects it, and then not. - * - * What that changed, and the words this screen must not reuse. When the - * rows lived in Postgres, read-down was a database predicate: the server would - * not return a row above your clearance whatever the client asked. There is no - * database now. Labels still gate decryption completely, no label key, no - * plaintext, but "what you're cleared for" now means *the keys this phone - * holds*, not *what a server will show you*. Those are different claims and only - * one of them is still true, so the screen says the true one. - * - * It asks for a passphrase, and that is not friction to be optimised away. - * Messenger deliberately keeps no copy of one, and the Files identity is - * protected with it, which is what makes a borrowed, unlocked phone still not - * open somebody's files. */ @Composable fun ColumnScope.FilesScreen( @@ -67,8 +49,12 @@ fun ColumnScope.FilesScreen( ) { val files = phase.files ?: MessengerViewModel.FilesState() - Text("Files", fontWeight = FontWeight.SemiBold, fontSize = 22.sp, - modifier = Modifier.padding(bottom = Space.xs)) + Text( + "Files", + fontWeight = FontWeight.SemiBold, + fontSize = 22.sp, + modifier = Modifier.padding(bottom = Space.xs), + ) when { !files.hasIdentity -> FilesSetUp(model, files) @@ -88,27 +74,27 @@ private fun ColumnScope.FilesMessages( val message = files.problem ?: files.notice ?: return Spacer(Modifier.height(Space.sm)) Column( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(Radius.small)) - .background(if (files.problem != null) Palette.AccentSoft else Palette.SurfaceAlt) - .clickable { model.filesClearMessages() } - .padding(Space.md) - .testTag("files-message"), + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(Radius.small)) + .background(if (files.problem != null) Palette.AccentSoft else Palette.SurfaceAlt) + .clickable { model.filesClearMessages() } + .padding(Space.md) + .testTag("files-message"), ) { - Text(message, fontSize = 13.sp, - color = if (files.problem != null) Palette.Danger else Palette.SecondaryText) + Text( + message, + fontSize = 13.sp, + color = if (files.problem != null) Palette.Danger else Palette.SecondaryText, + ) } } -// ---- Setting up ---------------------------------------------------------- +// ---- Setting up ----------------------------------------------------------. /** * The one-way door is named before it is walked through, not after. - * Forgetting this passphrase does not lock somebody out temporarily, the label - * keys are derived from the identity it protects and exist nowhere else, so - * every vaulted file becomes permanently unreadable. A screen that discovers - * that for somebody afterwards has failed at the only moment it mattered. */ @Composable private fun ColumnScope.FilesSetUp( @@ -118,34 +104,28 @@ private fun ColumnScope.FilesSetUp( var passphrase by remember { mutableStateOf("") } var again by remember { mutableStateOf("") } - // "A passphrase of their own" told people to invent a second secret, - // and iOS never did. iOS says "your passphrase" and takes whatever is - // typed, so a person naturally uses their Flamenet one and it simply - // works. Android's copy sent them off to make up something else, then - // asked for it again weeks later, and forgetting it is the one-way door - // described above this function. - // - // Nothing about the derivation changes. This is still a free-choice - // string protecting the identity record, so anyone who already set a - // different one keeps opening their files with it. Making the account - // passphrase the ACTUAL key would be a migration with permanent data loss - // on the far side of any mistake, and it is not what this fixes. + // "A passphrase of their own" told people to invent a second secret, and + // iOS never did. iOS says "your passphrase" and takes whatever is typed, so + // a person naturally uses their Flamenet one and it simply works. Text( - "Use your Flamenet passphrase, the one you sign in with. Files on " - + "this phone are encrypted with it, and Flamenet keeps no copy, so " - + "nobody can open them by picking up your unlocked phone.", - fontSize = 14.sp, color = Palette.SecondaryText, + "Use your Flamenet passphrase, the one you sign in with. Files on " + + "this phone are encrypted with it, and Flamenet keeps no copy, so " + + "nobody can open them by picking up your unlocked phone.", + fontSize = 14.sp, + color = Palette.SecondaryText, ) Spacer(Modifier.height(Space.sm)) Text( - "If you forget it, the files are gone for good, there's no reset, " - + "because there's nothing anywhere that could do one.", - fontSize = 13.sp, color = Palette.Warning, + "If you forget it, the files are gone for good, there's no reset, " + + "because there's nothing anywhere that could do one.", + fontSize = 13.sp, + color = Palette.Warning, ) Spacer(Modifier.height(Space.md)) OutlinedTextField( - value = passphrase, onValueChange = { passphrase = it }, + value = passphrase, + onValueChange = { passphrase = it }, label = { Text("Your Flamenet passphrase") }, visualTransformation = PasswordVisualTransformation(), singleLine = true, @@ -153,13 +133,12 @@ private fun ColumnScope.FilesSetUp( ) Spacer(Modifier.height(Space.sm)) OutlinedTextField( - value = again, onValueChange = { again = it }, + value = again, + onValueChange = { again = it }, label = { Text("Type it again") }, visualTransformation = PasswordVisualTransformation(), singleLine = true, - // Confirmed rather than trusted. Everywhere else a typo means one - // failed sign-in; here it means a vault nobody can ever open, and the - // person would not find out until the files were already in it. + // Confirmed rather than trusted. isError = again.isNotEmpty() && again != passphrase, modifier = Modifier.fillMaxWidth().testTag("files-new-passphrase-again"), ) @@ -180,14 +159,16 @@ private fun ColumnScope.FilesUnlock( ) { var passphrase by remember { mutableStateOf("") } - // Not "your Files passphrase". That name implied a second secret even - // for somebody who had used their Flamenet one, and left them hunting for - // a passphrase they never set. - Text("Enter your Flamenet passphrase to open them.", - fontSize = 14.sp, color = Palette.SecondaryText) + // Not "your Files passphrase". + Text( + "Enter your Flamenet passphrase to open them.", + fontSize = 14.sp, + color = Palette.SecondaryText, + ) Spacer(Modifier.height(Space.md)) OutlinedTextField( - value = passphrase, onValueChange = { passphrase = it }, + value = passphrase, + onValueChange = { passphrase = it }, label = { Text("Passphrase") }, visualTransformation = PasswordVisualTransformation(), singleLine = true, @@ -203,7 +184,7 @@ private fun ColumnScope.FilesUnlock( ) } -// ---- Browsing ------------------------------------------------------------ +// ---- Browsing ------------------------------------------------------------. @Composable private fun ColumnScope.FilesBrowser( @@ -215,31 +196,29 @@ private fun ColumnScope.FilesBrowser( var namingFolder by remember { mutableStateOf(false) } /** - * `OpenDocument`, never `GetContent`. `GetContent` returns a URI whose - * read grant dies with this process, so a linked file works all session and - * is broken tomorrow, and whoever added it watched it work. + * `OpenDocument`, never `GetContent`. */ - val pick = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> - uri ?: return@rememberLauncherForActivityResult - val saf = SafReferences(context) - pending = PendingFile(uri, saf.displayName(uri) ?: "Untitled") - } + val pick = + rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + uri ?: return@rememberLauncherForActivityResult + val saf = SafReferences(context) + pending = PendingFile(uri, saf.displayName(uri) ?: "Untitled") + } FilesSectionSwitch(model, files) Spacer(Modifier.height(Space.sm)) FilesBreadcrumb(model, files) - // Folders show in BOTH sections. A folder has no storage of its own, it - // is a place, not a file, and hiding them from the linked section would - // make anything filed inside one unreachable from there. + // Folders show in BOTH sections. val rows = files.entries.filter { it.isFolder || it.storage == files.section } if (rows.isEmpty()) { - val empty = if (files.section == LocalVault.Storage.VAULT) { - "Nothing in the vault here yet. Anything you add is encrypted on this phone." - } else { - "No linked files here. A link points at a file where it already is, " + - "nothing is copied, and nothing is encrypted until you send it." - } + val empty = + if (files.section == LocalVault.Storage.VAULT) { + "Nothing in the vault here yet. Anything you add is encrypted on this phone." + } else { + "No linked files here. A link points at a file where it already is, " + + "nothing is copied, and nothing is encrypted until you send it." + } Spacer(Modifier.height(Space.md)) Text(empty, fontSize = 13.sp, color = Palette.SecondaryText) } @@ -253,12 +232,14 @@ private fun ColumnScope.FilesBrowser( text = "Add a file", // Everything: the vault does not care what a file is. onClick = { pick.launch(arrayOf("*/*")) }, - modifier = Modifier.weight(1f), tag = "files-add", + modifier = Modifier.weight(1f), + tag = "files-add", ) KitSecondaryButton( text = "New folder", onClick = { namingFolder = true }, - modifier = Modifier.weight(1f), tag = "files-new-folder", + modifier = Modifier.weight(1f), + tag = "files-new-folder", ) } Spacer(Modifier.height(Space.sm)) @@ -266,20 +247,23 @@ private fun ColumnScope.FilesBrowser( KitSecondaryButton( text = if (files.selecting) "Done" else "Select", onClick = { model.filesToggleSelecting() }, - modifier = Modifier.weight(1f), tag = "files-select", + modifier = Modifier.weight(1f), + tag = "files-select", ) if (files.selecting) { KitSecondaryButton( text = "Delete ${files.selected.size}", onClick = { model.filesDeleteSelected() }, enabled = files.selected.isNotEmpty(), - modifier = Modifier.weight(1f), tag = "files-delete", + modifier = Modifier.weight(1f), + tag = "files-delete", ) } KitSecondaryButton( text = "Lock", onClick = { model.lockFiles() }, - modifier = Modifier.weight(1f), tag = "files-lock", + modifier = Modifier.weight(1f), + tag = "files-lock", ) } @@ -296,19 +280,25 @@ private fun ColumnScope.FilesBrowser( AddFileSheet( file = file, onCancel = { pending = null }, - onVault = { name, level -> pending = null; model.filesAdd(file.uri, name, level) }, - onLink = { name, level -> pending = null; model.filesReference(file.uri, name, level) }, + onVault = { name, level -> + pending = null + model.filesAdd(file.uri, name, level) + }, + onLink = { name, level -> + pending = null + model.filesReference(file.uri, name, level) + }, ) } } -private data class PendingFile(val uri: android.net.Uri, val name: String) +private data class PendingFile( + val uri: android.net.Uri, + val name: String, +) /** - * Two sections, not one mixed list. The vault and the links are not - * equally protected, one is ciphertext in this app's container, the other is - * somebody's own file with a note attached, and a list that interleaved them - * would be a list where "encrypted" is invisible. + * Two sections, not one mixed list. */ @Composable private fun ColumnScope.FilesSectionSwitch( @@ -341,14 +331,23 @@ private fun ColumnScope.FilesBreadcrumb( modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), verticalAlignment = Alignment.CenterVertically, ) { - Text("Files", fontSize = 13.sp, color = Palette.Accent, - modifier = Modifier.clickable { model.filesGoTo(0) }.testTag("files-crumb-root")) + Text( + "Files", + fontSize = 13.sp, + color = Palette.Accent, + modifier = Modifier.clickable { model.filesGoTo(0) }.testTag("files-crumb-root"), + ) files.breadcrumb.forEachIndexed { index, crumb -> Text(" › ", fontSize = 13.sp, color = Palette.SecondaryText) Text( - crumb.name, fontSize = 13.sp, - color = if (index == files.breadcrumb.lastIndex) Palette.SecondaryText - else Palette.Accent, + crumb.name, + fontSize = 13.sp, + color = + if (index == files.breadcrumb.lastIndex) { + Palette.SecondaryText + } else { + Palette.Accent + }, modifier = Modifier.clickable { model.filesGoTo(index + 1) }, ) } @@ -362,13 +361,13 @@ private fun ColumnScope.FileRow( entry: LocalVault.Entry, ) { Row( - modifier = Modifier - .fillMaxWidth() - .clickable(enabled = entry.isFolder && !files.selecting) { - model.filesOpenFolder(entry) - } - .padding(vertical = Space.sm) - .testTag("file-row"), + modifier = + Modifier + .fillMaxWidth() + .clickable(enabled = entry.isFolder && !files.selecting) { + model.filesOpenFolder(entry) + }.padding(vertical = Space.sm) + .testTag("file-row"), verticalAlignment = Alignment.CenterVertically, ) { if (files.selecting) { @@ -381,32 +380,36 @@ private fun ColumnScope.FileRow( Column(Modifier.weight(1f)) { Text( // A row this phone cannot read is still SHOWN, with its - // classification. Hiding it would leave somebody unable to see - // that anything is there at all, and the label is not a secret; - // it has to be readable on a row you cannot open. + // classification. text = entry.name ?: "Encrypted for someone else", fontSize = 15.sp, fontWeight = if (entry.isFolder) FontWeight.Medium else FontWeight.Normal, color = if (entry.name == null) Palette.SecondaryText else MaterialTheme.colorScheme.onSurface, - maxLines = 1, overflow = TextOverflow.Ellipsis, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) Row(verticalAlignment = Alignment.CenterVertically) { Text( FileLevels.levelName(entry.label.classification), - fontSize = 11.sp, color = Palette.SecondaryText, + fontSize = 11.sp, + color = Palette.SecondaryText, ) if (entry.isMissing) { Text(" · file has moved", fontSize = 11.sp, color = Palette.Warning) } else if (!entry.isFolder && entry.byteCount > 0) { - Text(" · ${bytes(entry.byteCount)}", - fontSize = 11.sp, color = Palette.SecondaryText) + Text( + " · ${bytes(entry.byteCount)}", + fontSize = 11.sp, + color = Palette.SecondaryText, + ) } } } // Promoting a link is the one per-row action, and only where it means // something. - if (!files.selecting && entry.storage == LocalVault.Storage.REFERENCE - && !entry.isFolder && !entry.isMissing) { + if (!files.selecting && entry.storage == LocalVault.Storage.REFERENCE && + !entry.isFolder && !entry.isMissing + ) { KitSecondaryButton( text = "Encrypt", onClick = { model.filesEncrypt(entry.id) }, @@ -416,19 +419,18 @@ private fun ColumnScope.FileRow( } } -private fun bytes(count: Int): String = when { - count >= 1_000_000 -> "%.1f MB".format(count / 1_000_000.0) - count >= 1_000 -> "%.0f kB".format(count / 1_000.0) - else -> "$count bytes" -} +private fun bytes(count: Int): String = + when { + count >= 1_000_000 -> "%.1f MB".format(count / 1_000_000.0) + count >= 1_000 -> "%.0f kB".format(count / 1_000.0) + else -> "$count bytes" + } -// ---- Sheets -------------------------------------------------------------- +// ---- Sheets --------------------------------------------------------------. /** * The choice between copying and linking is made here, with both consequences - * written out, rather than by a default somebody discovers later. They are not - * two ways of doing one thing: one produces ciphertext this app owns, the other - * produces a pointer at a file it does not. + * written out, rather than by a default somebody discovers later. */ @Composable private fun ColumnScope.AddFileSheet( @@ -442,18 +444,21 @@ private fun ColumnScope.AddFileSheet( Spacer(Modifier.height(Space.md)) Column( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(Radius.small)) - .background(Palette.SurfaceAlt) - .padding(Space.md) - .testTag("files-add-sheet"), + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(Radius.small)) + .background(Palette.SurfaceAlt) + .padding(Space.md) + .testTag("files-add-sheet"), ) { Text("Add a file", fontWeight = FontWeight.Medium, fontSize = 16.sp) Spacer(Modifier.height(Space.sm)) OutlinedTextField( - value = name, onValueChange = { name = it }, - label = { Text("Name") }, singleLine = true, + value = name, + onValueChange = { name = it }, + label = { Text("Name") }, + singleLine = true, modifier = Modifier.fillMaxWidth().testTag("files-add-name"), ) Spacer(Modifier.height(Space.sm)) @@ -461,32 +466,40 @@ private fun ColumnScope.AddFileSheet( Spacer(Modifier.height(Space.md)) Text( - "Copy it in and encrypt it. A stolen phone or backup gives up " - + "nothing but ciphertext.", - fontSize = 12.sp, color = Palette.SecondaryText, + "Copy it in and encrypt it. A stolen phone or backup gives up " + + "nothing but ciphertext.", + fontSize = 12.sp, + color = Palette.SecondaryText, ) Spacer(Modifier.height(Space.xs)) KitPrimaryButton( text = "Copy in and encrypt", onClick = { onVault(name.trim().ifEmpty { file.name }, level) }, - modifier = Modifier.fillMaxWidth(), tag = "files-add-vault", + modifier = Modifier.fillMaxWidth(), + tag = "files-add-vault", ) Spacer(Modifier.height(Space.md)) Text( - "Or link to it where it is. Nothing is copied and nothing is " - + "encrypted until you send it, until then the classification " - + "is a note, not a lock.", - fontSize = 12.sp, color = Palette.SecondaryText, + "Or link to it where it is. Nothing is copied and nothing is " + + "encrypted until you send it, until then the classification " + + "is a note, not a lock.", + fontSize = 12.sp, + color = Palette.SecondaryText, ) Spacer(Modifier.height(Space.xs)) KitSecondaryButton( text = "Link to it instead", onClick = { onLink(name.trim().ifEmpty { file.name }, level) }, - modifier = Modifier.fillMaxWidth(), tag = "files-add-link", + modifier = Modifier.fillMaxWidth(), + tag = "files-add-link", ) Spacer(Modifier.height(Space.sm)) - KitSecondaryButton(text = "Cancel", onClick = onCancel, - modifier = Modifier.fillMaxWidth(), tag = "files-add-cancel") + KitSecondaryButton( + text = "Cancel", + onClick = onCancel, + modifier = Modifier.fillMaxWidth(), + tag = "files-add-cancel", + ) } } @@ -500,18 +513,21 @@ private fun ColumnScope.FolderNameSheet( Spacer(Modifier.height(Space.md)) Column( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(Radius.small)) - .background(Palette.SurfaceAlt) - .padding(Space.md) - .testTag("files-folder-sheet"), + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(Radius.small)) + .background(Palette.SurfaceAlt) + .padding(Space.md) + .testTag("files-folder-sheet"), ) { Text("New folder", fontWeight = FontWeight.Medium, fontSize = 16.sp) Spacer(Modifier.height(Space.sm)) OutlinedTextField( - value = name, onValueChange = { name = it }, - label = { Text("Name") }, singleLine = true, + value = name, + onValueChange = { name = it }, + label = { Text("Name") }, + singleLine = true, modifier = Modifier.fillMaxWidth().testTag("files-folder-name"), ) Spacer(Modifier.height(Space.sm)) @@ -520,25 +536,35 @@ private fun ColumnScope.FolderNameSheet( // Said here because the tree deliberately allows the opposite of what // people assume: a folder's level does not constrain what goes in it. Text( - "A folder's level doesn't limit what you put inside it, each file " - + "keeps its own.", - fontSize = 12.sp, color = Palette.SecondaryText, + "A folder's level doesn't limit what you put inside it, each file " + + "keeps its own.", + fontSize = 12.sp, + color = Palette.SecondaryText, ) Spacer(Modifier.height(Space.md)) Row(horizontalArrangement = Arrangement.spacedBy(Space.sm)) { - KitSecondaryButton(text = "Cancel", onClick = onCancel, - modifier = Modifier.weight(1f), tag = "files-folder-cancel") + KitSecondaryButton( + text = "Cancel", + onClick = onCancel, + modifier = Modifier.weight(1f), + tag = "files-folder-cancel", + ) KitPrimaryButton( - text = "Create", onClick = { onCreate(name, level) }, + text = "Create", + onClick = { onCreate(name, level) }, enabled = name.isNotBlank(), - modifier = Modifier.weight(1f), tag = "files-folder-create", + modifier = Modifier.weight(1f), + tag = "files-folder-create", ) } } } @Composable -private fun ColumnScope.LevelPicker(selected: Short, onPick: (Short) -> Unit) { +private fun ColumnScope.LevelPicker( + selected: Short, + onPick: (Short) -> Unit, +) { Text("Classification", fontSize = 12.sp, color = Palette.SecondaryText) Spacer(Modifier.height(Space.xs)) Row( @@ -547,7 +573,8 @@ private fun ColumnScope.LevelPicker(selected: Short, onPick: (Short) -> Unit) { ) { for ((ordinal, name) in FileLevels.LOCAL_LEVELS) { KitChip( - text = name, selected = ordinal == selected, + text = name, + selected = ordinal == selected, onClick = { onPick(ordinal) }, tag = "files-level-$ordinal", ) @@ -555,14 +582,10 @@ private fun ColumnScope.LevelPicker(selected: Short, onPick: (Short) -> Unit) { } } -// ---- Into and out of a conversation -------------------------------------- +// ---- Into and out of a conversation --------------------------------------. /** * What can be attached to a message. - * - * Two kinds, and they are not variations of one thing: a photo comes from - * the gallery and is re-encoded for sending; a document comes from the vault - * and carries a classification with it. */ @Composable fun ColumnScope.AttachPanel( @@ -571,36 +594,32 @@ fun ColumnScope.AttachPanel( onFiles: () -> Unit, ) { Column( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(Radius.small)) - .background(Palette.SurfaceAlt) - .padding(Space.md) - .testTag("attach-panel"), + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(Radius.small)) + .background(Palette.SurfaceAlt) + .padding(Space.md) + .testTag("attach-panel"), verticalArrangement = Arrangement.spacedBy(Space.sm), ) { - KitSecondaryButton(text = "Photo", onClick = onPhoto, - modifier = Modifier.fillMaxWidth(), tag = "attach-photo") + KitSecondaryButton( + text = "Photo", + onClick = onPhoto, + modifier = Modifier.fillMaxWidth(), + tag = "attach-photo", + ) KitSecondaryButton( text = if (model.filesUnlocked()) "From Files" else "From Files (locked)", onClick = onFiles, - modifier = Modifier.fillMaxWidth(), tag = "attach-files", + modifier = Modifier.fillMaxWidth(), + tag = "attach-files", ) } } /** * Choose a document from the vault, from inside a conversation. - * - * Sending is a downgrade, and the sheet says so before the tap. The - * content key travels in the same envelope as the message, so a classification - * does not stop the recipient opening it, it goes so their phone files it the - * same way. Every word here has to carry that, because the alternative is a - * person believing "Restricted" did something on the way. - * - * Never asks for the Files passphrase. A conversation that prompted for - * one would be a conversation that has started collecting one, and Files goes - * out of its way not to hold it. Locked says so and points at the Files screen. */ @Composable fun ColumnScope.PickFileToSendSheet( @@ -615,67 +634,76 @@ fun ColumnScope.PickFileToSendSheet( val entries = remember(unlocked) { if (unlocked) model.filesForSending() else emptyList() } Column( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(Radius.small)) - .background(Palette.SurfaceAlt) - .padding(Space.md) - .testTag("pick-file-sheet"), + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(Radius.small)) + .background(Palette.SurfaceAlt) + .padding(Space.md) + .testTag("pick-file-sheet"), ) { Text("Send a file", fontWeight = FontWeight.Medium, fontSize = 16.sp) Spacer(Modifier.height(Space.sm)) if (!unlocked) { Text( - "Files is locked. Open Files from your account and unlock it, " - + "then come back, your passphrase isn't asked for here.", - fontSize = 13.sp, color = Palette.SecondaryText, + "Files is locked. Open Files from your account and unlock it, " + + "then come back, your passphrase isn't asked for here.", + fontSize = 13.sp, + color = Palette.SecondaryText, ) } else if (entries.isEmpty()) { Text("Nothing in your files yet.", fontSize = 13.sp, color = Palette.SecondaryText) } else { Text( - "Whoever you send this to will be able to open it. The " - + "classification travels with the file so it's filed the " - + "same way on their phone, it doesn't stop them reading it.", - fontSize = 12.sp, color = Palette.SecondaryText, + "Whoever you send this to will be able to open it. The " + + "classification travels with the file so it's filed the " + + "same way on their phone, it doesn't stop them reading it.", + fontSize = 12.sp, + color = Palette.SecondaryText, ) Spacer(Modifier.height(Space.xs)) - // The photo button strips location; this one does not - // (android#17). A picture attached from the picker is re-encoded - // through ImagePrep and video through MediaScrub, so no metadata - // container survives. `Session.sendFile` passes the vault's bytes - // straight through, EXIF and all, correct for "send this file", - // and dangerous only because nothing said which path you were on. + // The photo button strips location; this one does not (android#17). Text( - "A file is sent exactly as it is, including anything inside " - + "it, like where a photo was taken. The camera button " - + "leaves that out; this doesn't.", - fontSize = 12.sp, color = Palette.SecondaryText, + "A file is sent exactly as it is, including anything inside " + + "it, like where a photo was taken. The camera button " + + "leaves that out; this doesn't.", + fontSize = 12.sp, + color = Palette.SecondaryText, ) Spacer(Modifier.height(Space.sm)) for (entry in entries) { Row( - modifier = Modifier - .fillMaxWidth() - .clickable { onPick(entry) } - .padding(vertical = Space.sm) - .testTag("pick-file-row"), + modifier = + Modifier + .fillMaxWidth() + .clickable { onPick(entry) } + .padding(vertical = Space.sm) + .testTag("pick-file-row"), verticalAlignment = Alignment.CenterVertically, ) { Column(Modifier.weight(1f)) { - Text(entry.name ?: "Unnamed", fontSize = 15.sp, - maxLines = 1, overflow = TextOverflow.Ellipsis) + Text( + entry.name ?: "Unnamed", + fontSize = 15.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) Text( FileLevels.levelName(entry.label.classification), - fontSize = 11.sp, color = Palette.SecondaryText, + fontSize = 11.sp, + color = Palette.SecondaryText, ) } } } } Spacer(Modifier.height(Space.sm)) - KitSecondaryButton(text = "Cancel", onClick = onCancel, - modifier = Modifier.fillMaxWidth(), tag = "pick-file-cancel") + KitSecondaryButton( + text = "Cancel", + onClick = onCancel, + modifier = Modifier.fillMaxWidth(), + tag = "pick-file-cancel", + ) } } diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/HistoryBackup.kt b/app/src/main/kotlin/io/flamenet/messenger/app/HistoryBackup.kt index 7ac8560..6d48285 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/HistoryBackup.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/HistoryBackup.kt @@ -33,10 +33,6 @@ import java.util.Locale /** * Export and import of encrypted history backups (D4). - * - * The relay keeps no history, so a new phone starts empty, this is the - * remedy, and the passphrase is deliberately the only key: the app stores - * nothing that could open the file, so neither can anyone who takes the file. */ @Composable fun HistoryBackupSection(model: MessengerViewModel) { @@ -49,32 +45,35 @@ fun HistoryBackupSection(model: MessengerViewModel) { var working by remember { mutableStateOf(false) } var status by remember { mutableStateOf(null) } - val saveLauncher = rememberLauncherForActivityResult( - ActivityResultContracts.CreateDocument("application/octet-stream") - ) { uri -> - val bytes = pendingExport - pendingExport = null - if (uri == null || bytes == null) return@rememberLauncherForActivityResult - status = runCatching { - context.contentResolver.openOutputStream(uri)?.use { it.write(bytes) } - ?: error("nothing to write to") - }.fold( - onSuccess = { "Backup saved. Keep the passphrase, it is the only key." }, - onFailure = { "Couldn't write the file: ${it.message}" }, - ) - } + val saveLauncher = + rememberLauncherForActivityResult( + ActivityResultContracts.CreateDocument("application/octet-stream"), + ) { uri -> + val bytes = pendingExport + pendingExport = null + if (uri == null || bytes == null) return@rememberLauncherForActivityResult + status = + runCatching { + context.contentResolver.openOutputStream(uri)?.use { it.write(bytes) } + ?: error("nothing to write to") + }.fold( + onSuccess = { "Backup saved. Keep the passphrase, it is the only key." }, + onFailure = { "Couldn't write the file: ${it.message}" }, + ) + } - val openLauncher = rememberLauncherForActivityResult( - ActivityResultContracts.OpenDocument() - ) { uri -> if (uri != null) pendingImport = uri } + val openLauncher = + rememberLauncherForActivityResult( + ActivityResultContracts.OpenDocument(), + ) { uri -> if (uri != null) pendingImport = uri } Text("History backup", style = MaterialTheme.typography.titleMedium) Text( - "Your history lives only on this phone, the server keeps none, so a " - + "new phone starts empty. An export is sealed with a passphrase you " - + "choose and can be imported on another Android phone signed into " - + "this account. Lose the passphrase and the backup cannot be " - + "opened, by anyone.", + "Your history lives only on this phone, the server keeps none, so a " + + "new phone starts empty. An export is sealed with a passphrase you " + + "choose and can be imported on another Android phone signed into " + + "this account. Lose the passphrase and the backup cannot be " + + "opened, by anyone.", fontSize = 12.sp, color = Palette.SecondaryText, ) @@ -96,16 +95,21 @@ fun HistoryBackupSection(model: MessengerViewModel) { } status?.let { Spacer(Modifier.height(Space.xs)) - Text(it, fontSize = 12.sp, color = Palette.SecondaryText, - modifier = Modifier.testTag("backup-status")) + Text( + it, + fontSize = 12.sp, + color = Palette.SecondaryText, + modifier = Modifier.testTag("backup-status"), + ) } if (showExportDialog) { PassphraseDialog( title = "Export history", - explainer = "This passphrase is the only key to the backup. It isn't " - + "stored anywhere, a forgotten passphrase means the backup can " - + "never be opened.", + explainer = + "This passphrase is the only key to the backup. It isn't " + + "stored anywhere, a forgotten passphrase means the backup can " + + "never be opened.", requireRepeat = true, busy = working, onDismiss = { if (!working) showExportDialog = false }, @@ -129,17 +133,19 @@ fun HistoryBackupSection(model: MessengerViewModel) { pendingImport?.let { uri -> PassphraseDialog( title = "Import history", - explainer = "Anything already on this phone stays, the backup is " - + "merged in, never a replacement.", + explainer = + "Anything already on this phone stays, the backup is " + + "merged in, never a replacement.", requireRepeat = false, busy = working, onDismiss = { if (!working) pendingImport = null }, ) { phrase -> working = true scope.launch { - val bytes = runCatching { - context.contentResolver.openInputStream(uri)?.use { it.readBytes() } - }.getOrNull() + val bytes = + runCatching { + context.contentResolver.openInputStream(uri)?.use { it.readBytes() } + }.getOrNull() if (bytes == null) { working = false pendingImport = null @@ -149,17 +155,19 @@ fun HistoryBackupSection(model: MessengerViewModel) { val outcome = model.importHistory(bytes, phrase.toCharArray()) working = false pendingImport = null - status = outcome.fold( - onSuccess = { counts -> - if (counts.messages + counts.stories == 0) - "Backup opened, everything in it was already here." - else - "Imported ${counts.messages} message${if (counts.messages == 1) "" else "s"}" + - " and ${counts.stories} " + - if (counts.stories == 1) "story." else "stories." - }, - onFailure = { it.message ?: "That backup couldn't be opened." }, - ) + status = + outcome.fold( + onSuccess = { counts -> + if (counts.messages + counts.stories == 0) { + "Backup opened, everything in it was already here." + } else { + "Imported ${counts.messages} message${if (counts.messages == 1) "" else "s"}" + + " and ${counts.stories} " + + if (counts.stories == 1) "story." else "stories." + } + }, + onFailure = { it.message ?: "That backup couldn't be opened." }, + ) } } } @@ -206,13 +214,19 @@ private fun PassphraseDialog( modifier = Modifier.fillMaxWidth().testTag("backup-passphrase-repeat"), ) if (mismatch) { - Text("The two passphrases don't match yet.", - fontSize = 12.sp, color = MaterialTheme.colorScheme.error) + Text( + "The two passphrases don't match yet.", + fontSize = 12.sp, + color = MaterialTheme.colorScheme.error, + ) } } if (phrase.isNotEmpty() && phrase.length < 6) { - Text("Use at least 6 characters.", - fontSize = 12.sp, color = Palette.SecondaryText) + Text( + "Use at least 6 characters.", + fontSize = 12.sp, + color = Palette.SecondaryText, + ) } } }, diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/ImagePrep.kt b/app/src/main/kotlin/io/flamenet/messenger/app/ImagePrep.kt index 93220f6..fa9b116 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ImagePrep.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ImagePrep.kt @@ -6,62 +6,51 @@ import java.io.ByteArrayOutputStream /** * Downscale and re-encode a picked photo before it is encrypted and uploaded. - * - * This app did none of this. The picker read the file and handed the - * ORIGINAL bytes straight to `sendImage`, so a modern phone camera, 12 to 50 - * megapixels, often 5-15 MB of JPEG, went at the relay's blob cap directly. - * Over the cap the upload is refused and the photo simply fails to send; under - * it, the recipient downloads tens of megabytes nobody asked for. iOS has - * downscaled since it shipped, so the two platforms disagreed about what - * sending a photo even means. - * - * There is a hard ceiling and it is the server's, so "full quality" cannot - * mean "whatever the camera produced". It means *as good as will fit*: start at - * the requested quality and step down until the bytes are under budget. A photo - * that fails to send is worse than a photo that is slightly softer. */ object ImagePrep { - /** * How much of the server's cap a prepared image may use. - * - * Deliberately under the relay's 10 MiB: the blob is encrypted before - * upload and the AEAD tag and framing ride on top of these bytes. Sizing to - * the exact cap produces a file that passes here and is refused there. */ const val BYTE_BUDGET = 9 * 1024 * 1024 private const val STANDARD_MAX_SIDE = 2048 private const val FULL_MAX_SIDE = 4096 - data class Prepared(val bytes: ByteArray, val mime: String, val width: Int, val height: Int) + data class Prepared( + val bytes: ByteArray, + val mime: String, + val width: Int, + val height: Int, + ) /** * @param fullQuality the user's "send full quality photos" setting. - * @return the prepared image, or null if the bytes are not a decodable image. + * @return the prepared image, or null if the bytes are not a decodable + * image. */ - fun prepare(source: ByteArray, fullQuality: Boolean): Prepared? { + fun prepare( + source: ByteArray, + fullQuality: Boolean, + ): Prepared? { val maxSide = if (fullQuality) FULL_MAX_SIDE else STANDARD_MAX_SIDE // Bounds first, so an enormous file is never fully decoded into memory - // just to find out how big it is. Decoding a 50MP JPEG at full size is - // ~200 MB of bitmap and is how a photo picker gets OOM-killed. + // just to find out how big it is. val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } BitmapFactory.decodeByteArray(source, 0, source.size, bounds) if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null val decoded = decodeScaled(source, bounds, maxSide) ?: return null - // Steps, best first. Each is a real fallback rather than a retry: for a - // very large picture quality alone will not reach the budget and the - // dimensions have to give as well. - val attempts = listOf( - maxSide to (if (fullQuality) 95 else 80), - maxSide to 70, - minOf(maxSide, 2048) to 70, - 1600 to 60, - 1024 to 50, - ) + // Steps, best first. + val attempts = + listOf( + maxSide to (if (fullQuality) 95 else 80), + maxSide to 70, + minOf(maxSide, 2048) to 70, + 1600 to 60, + 1024 to 50, + ) var last: Prepared? = null for ((side, quality) in attempts) { @@ -77,30 +66,19 @@ object ImagePrep { } } decoded.recycle() - // Every step was still too large. Returning the smallest is better than - // null: the caller surfaces the server's refusal, where null here would - // look like an unreadable file instead. + // Every step was still too large. return last } /** Decode with `inSampleSize`, so the full-size bitmap never exists. */ + /** * Decode bytes that came from somebody else, bounded. - * - * The render paths were calling `decodeByteArray` with no options at - * all. Sending was careful, bounds first, then a sampled decode, but - * the two places that draw an image RECEIVED from a peer decoded at full - * size. Inbound attachments are capped at 12 MiB, and 12 MiB of cleverly - * chosen JPEG is a few hundred megabytes of ARGB_8888 once expanded. A - * contact could send one image and crash the app every time the thread or - * the story was opened, a conversation wedged from the outside, with no - * way for the victim to get past it. - * - * Bounds are read first so an enormous picture is never fully decoded - * just to discover how big it is. That is the point; a size check on - * the byte count alone does not bound the decoded bitmap. */ - fun decodeBounded(source: ByteArray, maxSide: Int = STANDARD_MAX_SIDE): Bitmap? { + fun decodeBounded( + source: ByteArray, + maxSide: Int = STANDARD_MAX_SIDE, + ): Bitmap? { if (source.isEmpty()) return null val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } BitmapFactory.decodeByteArray(source, 0, source.size, bounds) @@ -108,7 +86,11 @@ object ImagePrep { return decodeScaled(source, bounds, maxSide) } - private fun decodeScaled(source: ByteArray, bounds: BitmapFactory.Options, maxSide: Int): Bitmap? { + private fun decodeScaled( + source: ByteArray, + bounds: BitmapFactory.Options, + maxSide: Int, + ): Bitmap? { var sample = 1 while (maxOf(bounds.outWidth, bounds.outHeight) / sample > maxSide * 2) sample *= 2 val opts = BitmapFactory.Options().apply { inSampleSize = sample } @@ -116,7 +98,10 @@ object ImagePrep { } /** Scale so the longest side is at most [maxSide]. Never enlarges. */ - private fun scaleTo(bitmap: Bitmap, maxSide: Int): Bitmap { + private fun scaleTo( + bitmap: Bitmap, + maxSide: Int, + ): Bitmap { val longest = maxOf(bitmap.width, bitmap.height) if (longest <= maxSide) return bitmap val ratio = maxSide.toDouble() / longest diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/KeystoreVault.kt b/app/src/main/kotlin/io/flamenet/messenger/app/KeystoreVault.kt index 64c9236..643cefa 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/KeystoreVault.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/KeystoreVault.kt @@ -11,73 +11,48 @@ import javax.crypto.SecretKey /** * The vault's key, held by the platform, and its records, held as files. - * - * The key is never seen by this process. `AndroidKeyStore` returns a - * handle; the bytes stay in hardware-backed storage where the OS keeps them. - * That is why [io.flamenet.e2e.Vault] works with a `SecretKey` rather than raw - * bytes, a design that needed the bytes would have to keep them somewhere, - * which is exactly what this avoids. - * - * The key does not leave this device, so neither does the vault. It is - * generated here on first launch and is not exportable or backupable, which is - * the Android equivalent of the iOS client's `ThisDeviceOnly` Keychain item. A - * consequence worth stating plainly: uninstalling the app destroys the key, - * and every stored conversation with it. That is the intended behaviour, - * "deleted means deleted", but it means the vault is not a backup, and the - * manifest turns `allowBackup` off so the records cannot be restored somewhere - * their key can never follow. */ object KeystoreVault { - private const val KEYSTORE = "AndroidKeyStore" private const val ALIAS = "flamenet.messenger.vault.v1" private const val DIRECTORY = "vault" /** * Is there a session worth receiving for? - * - * Both halves, matching `restore()`: a device with no credential cannot - * reach the relay, and a credential with no identity cannot be addressed. - * Either alone is a half-finished sign-in, not something to run a - * foreground service for. */ - fun hasSession(context: Context): Boolean = runCatching { - val v = open(context) - v.loadIdentity() != null && v.loadCredential() != null - }.getOrDefault(false) + fun hasSession(context: Context): Boolean = + runCatching { + val v = open(context) + v.loadIdentity() != null && v.loadCredential() != null + }.getOrDefault(false) fun open(context: Context): Vault = Vault(FileStorage(File(context.filesDir, DIRECTORY)), key()) /** * The vault key, generated on first use. - * - * No `setUserAuthenticationRequired`. Requiring a device unlock per use - * would sound stronger and would break background polling, the app cannot - * decrypt an incoming message while the screen is off, so mail silently - * stops arriving until the user opens the app. App lock is a separate - * feature at the UI layer, where it can be honest about what it protects. */ private fun key(): SecretKey { val store = KeyStore.getInstance(KEYSTORE).apply { load(null) } (store.getEntry(ALIAS, null) as? KeyStore.SecretKeyEntry)?.let { return it.secretKey } - return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE).apply { - init( - KeyGenParameterSpec.Builder( - ALIAS, - KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, + return KeyGenerator + .getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE) + .apply { + init( + KeyGenParameterSpec + .Builder( + ALIAS, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, + ).setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(256) + // The vault supplies its own nonce per record, and a + // repeated nonce under one key is the catastrophic AES-GCM + // failure. + .setRandomizedEncryptionRequired(false) + .build(), ) - .setBlockModes(KeyProperties.BLOCK_MODE_GCM) - .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) - .setKeySize(256) - // The vault supplies its own nonce per record, and a - // repeated nonce under one key is the catastrophic AES-GCM - // failure. Letting the caller provide it is only safe - // because that nonce is freshly random every write. - .setRandomizedEncryptionRequired(false) - .build() - ) - }.generateKey() + }.generateKey() } /** Erase the key itself. Sign-out, and the only way the records become unrecoverable. */ @@ -88,30 +63,31 @@ object KeystoreVault { /** * Records as files under `filesDir`, which is app-private storage. - * - * Record names contain a device id and are used as filenames, so they - * are encoded rather than trusted: a name with a `/` in it would otherwise - * write outside the vault directory. The *unencoded* name is what goes into - * the record's AAD, so the binding survives the encoding. */ - private class FileStorage(private val root: File) : Vault.Storage { - - init { root.mkdirs() } + private class FileStorage( + private val root: File, + ) : Vault.Storage { + init { + root.mkdirs() + } private fun fileFor(name: String) = - File(root, android.util.Base64.encodeToString( - name.toByteArray(Charsets.UTF_8), - android.util.Base64.URL_SAFE or android.util.Base64.NO_PADDING or android.util.Base64.NO_WRAP, - )) + File( + root, + android.util.Base64.encodeToString( + name.toByteArray(Charsets.UTF_8), + android.util.Base64.URL_SAFE or android.util.Base64.NO_PADDING or android.util.Base64.NO_WRAP, + ), + ) - override fun read(name: String): ByteArray? = - fileFor(name).takeIf { it.exists() }?.readBytes() + override fun read(name: String): ByteArray? = fileFor(name).takeIf { it.exists() }?.readBytes() - override fun write(name: String, bytes: ByteArray) { + override fun write( + name: String, + bytes: ByteArray, + ) { // Write beside and rename, so a kill mid-write leaves the previous - // record intact rather than a truncated one. A half-written session - // reads as tampering, which is the worst possible way to report - // "the phone ran out of battery". + // record intact rather than a truncated one. val target = fileFor(name) val staging = File(root, target.name + ".tmp") staging.writeBytes(bytes) @@ -121,10 +97,14 @@ object KeystoreVault { } } - override fun delete(name: String) { fileFor(name).delete() } + override fun delete(name: String) { + fileFor(name).delete() + } override fun names(): List = - root.listFiles().orEmpty() + root + .listFiles() + .orEmpty() .filterNot { it.name.endsWith(".tmp") } .mapNotNull { runCatching { diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/KitButtons.kt b/app/src/main/kotlin/io/flamenet/messenger/app/KitButtons.kt index 7146a9e..03b307f 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/KitButtons.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/KitButtons.kt @@ -22,21 +22,6 @@ import androidx.compose.ui.unit.sp /** * The kit's buttons. - * - * Because almost every action in this app was a bare `TextButton`, an - * orange word with nothing around it. Back, Call, Safety number, Record a - * voice note, Mark all read, Look up, Leave, and the four presence states were - * all the same undifferentiated orange text, so the app read as a page of - * links rather than as a set of controls. That is not an Android convention; - * it is the absence of one. - * - * The kit ships three: a filled primary, an outlined secondary and a grey - * tertiary. All 64 tall at radius 32, fully rounded, with a 14 medium label - * and 16 of horizontal padding. See `DESIGN.md`. - * - * Height 64 is the kit's, and it is deliberately large. Where a control has - * to sit inline next to text, use [KitChip] rather than shrinking one of these - * a 64pt button squeezed to 32 is neither the kit's button nor a chip. */ object KitButton { val height = 64.dp @@ -57,9 +42,10 @@ fun KitPrimaryButton( enabled = enabled, shape = RoundedCornerShape(KitButton.radius), contentPadding = PaddingValues(horizontal = 16.dp), - modifier = modifier - .height(KitButton.height) - .then(if (tag != null) Modifier.testTag(tag) else Modifier), + modifier = + modifier + .height(KitButton.height) + .then(if (tag != null) Modifier.testTag(tag) else Modifier), ) { Text(text, fontSize = 14.sp, fontWeight = FontWeight.Medium) } @@ -84,21 +70,18 @@ fun KitSecondaryButton( border = BorderStroke(1.dp, colour), colors = ButtonDefaults.outlinedButtonColors(contentColor = colour), contentPadding = PaddingValues(horizontal = 16.dp), - modifier = modifier - .height(KitButton.height) - .then(if (tag != null) Modifier.testTag(tag) else Modifier), + modifier = + modifier + .height(KitButton.height) + .then(if (tag != null) Modifier.testTag(tag) else Modifier), ) { Text(text, fontSize = 14.sp, fontWeight = FontWeight.Medium) } } /** - * A small pill for something that sits in a row of its peers, a presence - * state, a filter. Selected fills with the primary; the rest are the kit's - * neutral surface. - * - * The kit's own small pill (its "On" badge) is 28 tall; 36 here so a finger - * can hit it, which the kit's badge does not have to survive. + * A small pill for something that sits in a row of its peers, a presence state, + * a filter. */ @Composable fun KitChip( @@ -108,10 +91,7 @@ fun KitChip( modifier: Modifier = Modifier, tag: String? = null, /** - * Optional, and a real vector when present. The voice-note chip used to - * carry its picture as an emoji inside `text`, "Voice note", which - * renders in whatever the system emoji font is, at the text baseline, in - * full colour. Beside a drawn monochrome icon it reads as a sticker. + * Optional, and a real vector when present. */ icon: androidx.compose.ui.graphics.vector.ImageVector? = null, ) { @@ -119,15 +99,25 @@ fun KitChip( onClick = onClick, shape = RoundedCornerShape(18.dp), contentPadding = PaddingValues(horizontal = 14.dp), - colors = ButtonDefaults.buttonColors( - containerColor = if (selected) MaterialTheme.colorScheme.primary - else MaterialTheme.colorScheme.surfaceVariant, - contentColor = if (selected) MaterialTheme.colorScheme.onPrimary - else Palette.SecondaryText, - ), - modifier = modifier - .height(36.dp) - .then(if (tag != null) Modifier.testTag(tag) else Modifier), + colors = + ButtonDefaults.buttonColors( + containerColor = + if (selected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.surfaceVariant + }, + contentColor = + if (selected) { + MaterialTheme.colorScheme.onPrimary + } else { + Palette.SecondaryText + }, + ), + modifier = + modifier + .height(36.dp) + .then(if (tag != null) Modifier.testTag(tag) else Modifier), ) { if (icon != null) { Icon(icon, contentDescription = null, modifier = Modifier.size(18.dp)) diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/KitIcons.kt b/app/src/main/kotlin/io/flamenet/messenger/app/KitIcons.kt index 5a99903..530d0f0 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/KitIcons.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/KitIcons.kt @@ -8,34 +8,33 @@ import androidx.compose.material.icons.filled.ChatBubble import androidx.compose.material.icons.filled.Circle import androidx.compose.material.icons.outlined.AccountBalanceWallet import androidx.compose.material.icons.outlined.Add -import androidx.compose.material.icons.outlined.AddComment import androidx.compose.material.icons.outlined.AddCircleOutline +import androidx.compose.material.icons.outlined.AddComment import androidx.compose.material.icons.outlined.Block import androidx.compose.material.icons.outlined.Call import androidx.compose.material.icons.outlined.CallEnd import androidx.compose.material.icons.outlined.ChatBubbleOutline +import androidx.compose.material.icons.outlined.Circle import androidx.compose.material.icons.outlined.Close import androidx.compose.material.icons.outlined.DeleteOutline +import androidx.compose.material.icons.outlined.EmojiEmotions +import androidx.compose.material.icons.outlined.ErrorOutline import androidx.compose.material.icons.outlined.FolderOpen import androidx.compose.material.icons.outlined.GroupAdd import androidx.compose.material.icons.outlined.Home import androidx.compose.material.icons.outlined.Lock import androidx.compose.material.icons.outlined.Mic -import androidx.compose.material.icons.outlined.Lock import androidx.compose.material.icons.outlined.MicOff import androidx.compose.material.icons.outlined.MoreHoriz -import androidx.compose.material.icons.outlined.EmojiEmotions -import androidx.compose.material.icons.outlined.ErrorOutline -import androidx.compose.material.icons.outlined.Schedule -import androidx.compose.material.icons.outlined.StickyNote2 import androidx.compose.material.icons.outlined.NotificationsNone import androidx.compose.material.icons.outlined.PersonAdd import androidx.compose.material.icons.outlined.PersonOutline -import androidx.compose.material.icons.outlined.QrCodeScanner import androidx.compose.material.icons.outlined.PersonRemove -import androidx.compose.material.icons.outlined.Circle import androidx.compose.material.icons.outlined.PhotoCamera +import androidx.compose.material.icons.outlined.QrCodeScanner +import androidx.compose.material.icons.outlined.Schedule import androidx.compose.material.icons.outlined.Search +import androidx.compose.material.icons.outlined.StickyNote2 import androidx.compose.material.icons.outlined.Verified import androidx.compose.material.icons.outlined.Videocam import androidx.compose.material.icons.outlined.VolumeUp @@ -43,52 +42,35 @@ import androidx.compose.ui.graphics.vector.ImageVector /** * The app's icon vocabulary, in one place. - * - * Why this file exists. Every icon in this app used to be a text - * character or an emoji, `+`, `→`, `‹`, `⋯`, ``, ``, `⊞`. It was reported - * three times as "bare", "disorganized" and "a bunch of text links", and each - * time I adjusted spacing and type instead, because from the code the rows - * looked structurally right. They were: the kit's Messages screen and ours - * agree on the 24dp margin, the 48dp row on a 72dp pitch, the search pill and - * the story strip. What they did not agree on is that every piece of chrome in - * the kit is a drawn line icon and every piece of ours was a glyph in a text - * run. A `Text("→")` in a circle is not a send button, it is an arrow - * character, and no amount of layout work fixes that. - * - * `material-icons-extended` was already a dependency the whole time, and - * the app had zero `Icon(` calls and zero `material.icons` imports. The - * fix was never blocked on anything; it was blocked on looking at a render. - * - * Naming is by role, not by picture, `Kit.Send` rather than `Kit.PaperPlane`, - * so swapping the drawing later is one edit here and nothing at the call sites. */ object KitIcon { - // Chrome + // Chrome. val Back: ImageVector = Icons.AutoMirrored.Outlined.ArrowBackIos val More: ImageVector = Icons.Outlined.MoreHoriz val Close: ImageVector = Icons.Outlined.Close val Add: ImageVector = Icons.Outlined.Add val Search: ImageVector = Icons.Outlined.Search - // Tab bar. The active tab is the filled variant of the same shape, which - // is how the kit distinguishes it, not a different icon. + // Tab bar. val Chats: ImageVector = Icons.Outlined.ChatBubbleOutline val ChatsActive: ImageVector = Icons.Filled.ChatBubble // A circle, matching the iOS pill's `circle.dashed`, the two bars used to // disagree (a camera here), and the same tab deserves the same glyph. + /** The Home tab, roster, stories strip, and the Recent circles. */ val Home: ImageVector = Icons.Outlined.Home val Stories: ImageVector = Icons.Outlined.Circle val Alerts: ImageVector = Icons.Outlined.NotificationsNone val Account: ImageVector = Icons.Outlined.PersonOutline + // The two that joined the bar on 2026-09-07. iOS draws `folder.fill` and // `wallet.bifold.fill`; these are the Material shapes closest to them, so // the same destination reads the same on both phones. val Files: ImageVector = Icons.Outlined.FolderOpen val Wallet: ImageVector = Icons.Outlined.AccountBalanceWallet - // Composer + // Composer. val Attach: ImageVector = Icons.Outlined.AddCircleOutline val Camera: ImageVector = Icons.Outlined.PhotoCamera val Send: ImageVector = Icons.AutoMirrored.Outlined.Send @@ -97,18 +79,19 @@ object KitIcon { val Lock: ImageVector = Icons.Outlined.Lock val Sticker: ImageVector = Icons.Outlined.StickyNote2 - // Calls + // Calls. val Call: ImageVector = Icons.Outlined.Call val Video: ImageVector = Icons.Outlined.Videocam val CallEnd: ImageVector = Icons.Outlined.CallEnd val MicOff: ImageVector = Icons.Outlined.MicOff val Speaker: ImageVector = Icons.Outlined.VolumeUp - // Contact actions + // Contact actions. val NewGroup: ImageVector = Icons.Outlined.GroupAdd val NewChat: ImageVector = Icons.Outlined.AddComment val Scan: ImageVector = Icons.Outlined.QrCodeScanner val Block: ImageVector = Icons.Outlined.Block + /** Unblocking: the same row, the opposite verb. */ val PersonAdd: ImageVector = Icons.Outlined.PersonAdd val ClearChat: ImageVector = Icons.Outlined.DeleteOutline diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt index 46eb043..47ed96d 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt @@ -25,7 +25,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll -import io.flamenet.messenger.app.MessengerViewModel.Phase.Screen import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider @@ -61,47 +60,28 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import io.flamenet.messenger.app.MessengerViewModel.Phase.Screen /** - * `FragmentActivity`, not `ComponentActivity`, and that is not cosmetic: + * `FragmentActivity`, not `ComponentActivity`, and that is not cosmetic. * `BiometricPrompt` hosts a fragment, so on a plain `ComponentActivity` it - * silently cannot show, the callback never fires and the lock screen sits - * there looking broken. `FragmentActivity` extends `ComponentActivity`, so - * `setContent` and everything else is unaffected. + * silently cannot show, the callback never fires and the lock screen sits there + * looking broken. */ class MainActivity : androidx.fragment.app.FragmentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // Screenshots off, and out of the recents thumbnail. - // - // The second half is the one that matters and the one nobody thinks - // about: without FLAG_SECURE, Android captures whatever is on screen - // into the recent-apps preview and WRITES IT TO DISK. An open - // conversation therefore exists as a plaintext bitmap outside every - // protection this app has, the ratchet, the sealed history file, the - // ThisDeviceOnly key, none of which cover a screenshot the OS took of - // the rendered thread. - // - // iOS has had the equivalent since the app lock landed: it obscures - // the switcher snapshot on `scenePhase`. Android had nothing at all. - // Two platforms disagreeing about a security guarantee is how one of - // them ends up being the one that leaks. - // - // It also blocks screen recording and casting, - // and is not something to make optional: a setting here is a setting - // people leave off and then get surprised by. window.setFlags( android.view.WindowManager.LayoutParams.FLAG_SECURE, android.view.WindowManager.LayoutParams.FLAG_SECURE, ) - // Runtime permission on API 33+, which is this app's minSdk. Without - // it the service runs and every alert is dropped silently, no error, - // no banner, and nothing on screen to say why. Ask once here rather - // than discovering it as "notifications do not work". + // Runtime permission on API 33+, which is this app's minSdk. if (checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) - != android.content.pm.PackageManager.PERMISSION_GRANTED) { + != android.content.pm.PackageManager.PERMISSION_GRANTED + ) { requestPermissions(arrayOf(android.Manifest.permission.POST_NOTIFICATIONS), 1001) } @@ -110,10 +90,7 @@ class MainActivity : androidx.fragment.app.FragmentActivity() { setContent { MessengerTheme { Surface(modifier = Modifier.fillMaxSize()) { - // The gate is around Root, not inside it. Drawn over - // the top, the message list still renders underneath for a - // frame, and a frame is all a screenshot or a shoulder - // needs. Locked means not composed. + // The gate is around Root, not inside it. var locked by remember { mutableStateOf(lock.enabled) } if (locked) { @@ -121,7 +98,10 @@ class MainActivity : androidx.fragment.app.FragmentActivity() { onUnlock = { lock.authenticate( activity = this@MainActivity, - onSuccess = { lock.noteUnlocked(); locked = false }, + onSuccess = { + lock.noteUnlocked() + locked = false + }, onFailure = { /* the screen stays; it says why */ }, ) }, @@ -132,7 +112,10 @@ class MainActivity : androidx.fragment.app.FragmentActivity() { LaunchedEffect(Unit) { lock.authenticate( activity = this@MainActivity, - onSuccess = { lock.noteUnlocked(); locked = false }, + onSuccess = { + lock.noteUnlocked() + locked = false + }, onFailure = {}, ) } @@ -142,14 +125,22 @@ class MainActivity : androidx.fragment.app.FragmentActivity() { // Re-arm on the way out, subject to the grace period. DisposableEffect(Unit) { - val observer = androidx.lifecycle.LifecycleEventObserver { _, event -> - when (event) { - androidx.lifecycle.Lifecycle.Event.ON_STOP -> lock.noteLeft() - androidx.lifecycle.Lifecycle.Event.ON_START -> - if (lock.shouldLock()) locked = true - else -> Unit + val observer = + androidx.lifecycle.LifecycleEventObserver { _, event -> + when (event) { + androidx.lifecycle.Lifecycle.Event.ON_STOP -> { + lock.noteLeft() + } + + androidx.lifecycle.Lifecycle.Event.ON_START -> { + if (lock.shouldLock()) locked = true + } + + else -> { + Unit + } + } } - } lifecycle.addObserver(observer) onDispose { lifecycle.removeObserver(observer) } } @@ -158,10 +149,7 @@ class MainActivity : androidx.fragment.app.FragmentActivity() { } } - // The handover: exactly one poller at a time. The ViewModel polls - // while the app is on screen; the service polls while it is not. Two - // pollers would race for the same envelopes and double-notify, so this - // pair has to stay symmetrical, if one side moves, move the other. + // The handover: exactly one poller at a time. override fun onStart() { super.onStart() MessengerService.stop(this) @@ -169,9 +157,7 @@ class MainActivity : androidx.fragment.app.FragmentActivity() { override fun onStop() { super.onStop() - // Only when there is a session to receive for. Starting it signed - // out puts a permanent notice in the shade for an app doing nothing, - // and the service stops itself, but not before the user has seen it. + // Only when there is a session to receive for. if (KeystoreVault.hasSession(applicationContext)) MessengerService.start(this) } } @@ -185,7 +171,8 @@ private fun Root(model: MessengerViewModel = viewModel()) { // Scrollable, because a keyboard over a form needs somewhere to go. is MessengerViewModel.Phase.Starting, is MessengerViewModel.Phase.SignedOut, - is MessengerViewModel.Phase.VaultUnreadable -> + is MessengerViewModel.Phase.VaultUnreadable, + -> { Column( modifier = frame.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(16.dp), @@ -197,122 +184,90 @@ private fun Root(model: MessengerViewModel = viewModel()) { else -> Unit } } + } - // NOT scrollable. A `LazyColumn` inside a vertically scrolling - // Column is measured with infinite height and crashes outright, - // the lists below manage their own scrolling. - is MessengerViewModel.Phase.SignedIn -> + // NOT scrollable. + is MessengerViewModel.Phase.SignedIn -> { Column(modifier = frame, verticalArrangement = Arrangement.spacedBy(12.dp)) { - // Above the screen switch, not inside one branch. An - // incoming call has to be visible whatever screen somebody - // is on, and this used to live inside SignedIn, which the - // chat screen REPLACES. So a call arriving while you were - // reading a thread showed nothing at all. - // - // Worse, the same banner renders the first-call privacy - // prompt, and the only Call button is IN a chat. Tapping it - // set the prompt state, nothing was composed to show it, - // and the prompt gates placing the call, so calling could - // not be started at all. The button looked simply dead. - // A live call takes the screen. It used to be a - // banner above whatever you were on, a line of text and - // two text buttons for the single most attention-demanding - // thing this app does. The privacy prompt stays a banner - // deliberately: it is a question asked BEFORE a call - // exists, and a full screen for it would look like a call - // already happening. + // Above the screen switch, not inside one branch. CallBanner(model, current) StoryComposerDialog(model, current) - // Here, above the screen switch, for the same reason - // the call banner is. Nothing installs this app but the - // person holding the phone, so the one moment they can be - // told a fix exists is while they have it open, whatever - // screen that happens to be. + // Here, above the screen switch, for the same reason the + // call banner is. UpdateAvailableDialog(model.site?.baseUrl) if (LiveCallScreen(model, current)) return@Column val chat = current.openChat if (chat != null) { - ChatScreen(model, chat, current.error, current.buddies, current.avatars, - myUserId = current.userId, groups = current.groups) + ChatScreen( + model, + chat, + current.error, + current.buddies, + current.avatars, + myUserId = current.userId, + groups = current.groups, + ) } else { - // The content area is weighted, and the tab bar is - // not. These used to be plain siblings, so the tab bar - // sat immediately under whatever the screen had drawn: - // near the TOP of an empty Alerts tab, and pushed clean - // OFF the bottom by the Account tab, which is taller - // than the screen. Account also could not scroll, so the - // sign-out explanation was unreadable and there was no - // way back to another tab. - // - // Every screen scrolls, Contacts included. - // - // This used to exempt Contacts, because "a lazy list - // inside a vertically scrolling Column is measured with - // infinite height and crashes outright". True, but - // only for an UNBOUNDED lazy list, and the buddy list - // has carried `heightIn(max = 320.dp)` since it was put - // in its own card. The exemption outlived its reason. - // - // What it cost: Groups sits below the buddy list on - // this screen, so opening New group pushed the - // member checkboxes and the Create group button past - // the bottom of a screen that could not scroll. The - // name field was reachable and nothing else was, you - // could type a group name and then only Cancel. - // Measured on a 1080×2400 emulator: the field landed at - // y≈1879, the button past the tab bar at y≈2280, and a - // `uiautomator dump` found no scrollable container and - // no "Create group" node at all. - // - // Before exempting a screen here again, check what is - // BELOW the lazy list, not just the list itself. And - // keep the buddy list bounded, remove that height and - // this does crash. - // Tighter than it was. Every section already carries - // its own padding, so a 12dp gap between them on top of - // that left the list airy in a way the kit is not, its - // rows sit on a 72dp pitch with nothing extra between - // the groups of them. + // The content area is weighted, and the tab bar is not. Column( - modifier = Modifier - .weight(1f) - .verticalScroll(rememberScrollState()), + modifier = + Modifier + .weight(1f) + .verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(Space.sm), ) { when (current.screen) { - MessengerViewModel.Phase.Screen.CONTACTS -> SignedIn(model, current) - MessengerViewModel.Phase.Screen.MESSAGES -> + MessengerViewModel.Phase.Screen.CONTACTS -> { + SignedIn(model, current) + } + + MessengerViewModel.Phase.Screen.MESSAGES -> { MessagesScreen(model, current) - MessengerViewModel.Phase.Screen.CALLS -> + } + + MessengerViewModel.Phase.Screen.CALLS -> { CallsScreen(model, current) - MessengerViewModel.Phase.Screen.STORIES -> + } + + MessengerViewModel.Phase.Screen.STORIES -> { StoriesFeed(model, current) - MessengerViewModel.Phase.Screen.WALLET -> + } + + MessengerViewModel.Phase.Screen.WALLET -> { WalletScreen(model, current) + } + // Its own destination, not a section of the // wallet. iOS put them together once because // they share an unlock, true, and an - // implementation detail. What it produced was - // somebody pressing "wallet" and arriving at a - // file list. - MessengerViewModel.Phase.Screen.FILES -> + // implementation detail. + MessengerViewModel.Phase.Screen.FILES -> { FilesScreen(model, current) - MessengerViewModel.Phase.Screen.NOTIFICATIONS -> + } + + MessengerViewModel.Phase.Screen.NOTIFICATIONS -> { NotificationsScreen(model, current) - MessengerViewModel.Phase.Screen.ACCOUNT -> + } + + MessengerViewModel.Phase.Screen.ACCOUNT -> { AccountScreen(model, current) + } } } BottomNav(model, current) } } + } } } } @Composable -private fun ColumnScope.SignIn(model: MessengerViewModel, phase: MessengerViewModel.Phase.SignedOut) { +private fun ColumnScope.SignIn( + model: MessengerViewModel, + phase: MessengerViewModel.Phase.SignedOut, +) { val relay by model.relayInput.collectAsStateWithLifecycle() // Observed, not read once: the account mode is discovered from the relay a // moment after the address is typed, and the label has to catch up. @@ -326,27 +281,18 @@ private fun ColumnScope.SignIn(model: MessengerViewModel, phase: MessengerViewMo // Only a relay keeping its own accounts needs an address for reset mail. val needsEmail = registering && site?.accountProvider == SiteConfig.AccountProvider.LOCAL - // The kit's Welcome screen, which is a masthead and not a form - // heading. A logo mark, a 40pt greeting, and one line saying what this - // is, centred, with room above it. This was a 24pt product name jammed - // against a Server field, which is the layout of a login dialog rather - // than the first thing anyone sees of the app. See `DESIGN.md`. - // - // The kit's Welcome has no fields on it at all, it is a splash before - // a phone number is asked for. Flamenet does not ask for a phone number - // and has nothing to put on a second screen, so the masthead and the form - // share one. The typography and the centring are the kit's; the fields - // are ours. + // The kit's Welcome screen, which is a masthead and not a form heading. Spacer(Modifier.height(Space.xl)) Column( modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, ) { Box( - modifier = Modifier - .size(48.dp) - .clip(RoundedCornerShape(Radius.medium)) - .background(MaterialTheme.colorScheme.primary), + modifier = + Modifier + .size(48.dp) + .clip(RoundedCornerShape(Radius.medium)) + .background(MaterialTheme.colorScheme.primary), contentAlignment = Alignment.Center, ) { Text("F", fontSize = 24.sp, fontWeight = FontWeight.Bold, color = Color.White) @@ -355,8 +301,8 @@ private fun ColumnScope.SignIn(model: MessengerViewModel, phase: MessengerViewMo Text("Hi there!", fontSize = 40.sp, fontWeight = FontWeight.Bold) Spacer(Modifier.height(Space.sm)) Text( - "Flamenet Messenger. End-to-end encrypted, no phone number, and it " - + "runs on whichever server you point it at.", + "Flamenet Messenger. End-to-end encrypted, no phone number, and it " + + "runs on whichever server you point it at.", fontSize = 14.sp, color = Palette.SecondaryText, textAlign = TextAlign.Center, @@ -373,9 +319,7 @@ private fun ColumnScope.SignIn(model: MessengerViewModel, phase: MessengerViewMo modifier = Modifier.fillMaxWidth().testTag("relay"), ) - // Say which kind of server this is BEFORE the passphrase is typed. Whether - // the passphrase leaves the device differs between the two, and that is not - // something to disclose afterwards. + // Say which kind of server this is BEFORE the passphrase is typed. site?.let { Text( it.identityDescription, @@ -393,18 +337,16 @@ private fun ColumnScope.SignIn(model: MessengerViewModel, phase: MessengerViewMo modifier = Modifier.fillMaxWidth().testTag("handle"), ) - // Only a relay keeping its own accounts asks for this, it is how the - // relay sends a reset code, and it is the only party that ever sees it. - // The shared service does not take one at all, so asking for it there - // would be collecting something nobody needs. + // Only a relay keeping its own accounts asks for this, it is how the relay + // sends a reset code, and it is the only party that ever sees it. if (needsEmail) { OutlinedTextField( value = email, onValueChange = { email = it }, label = { Text("Email") }, singleLine = true, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Email, imeAction = ImeAction.Next), + keyboardOptions = + KeyboardOptions(keyboardType = KeyboardType.Email, imeAction = ImeAction.Next), modifier = Modifier.fillMaxWidth().testTag("email"), ) Text( @@ -419,22 +361,20 @@ private fun ColumnScope.SignIn(model: MessengerViewModel, phase: MessengerViewMo label = { Text("Passphrase") }, singleLine = true, visualTransformation = PasswordVisualTransformation(), - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Password, imeAction = ImeAction.Done), + keyboardOptions = + KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done), modifier = Modifier.fillMaxWidth().testTag("passphrase"), ) if (registering) { - // The age gate. Neutral, and stated honestly: the year is checked on - // this device to refuse an under-13 sign-up, then forgotten. Nothing - // about age is sent to any server. + // The age gate. OutlinedTextField( value = birthYear, onValueChange = { birthYear = it.filter(Char::isDigit).take(4) }, label = { Text("Year of birth") }, singleLine = true, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Number, imeAction = ImeAction.Done), + keyboardOptions = + KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Done), modifier = Modifier.fillMaxWidth().testTag("birth-year"), ) Text( @@ -442,8 +382,11 @@ private fun ColumnScope.SignIn(model: MessengerViewModel, phase: MessengerViewMo style = MaterialTheme.typography.bodySmall, ) ageError?.let { - Text(it, color = MaterialTheme.colorScheme.error, - modifier = Modifier.testTag("age-error")) + Text( + it, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.testTag("age-error"), + ) } } @@ -451,30 +394,37 @@ private fun ColumnScope.SignIn(model: MessengerViewModel, phase: MessengerViewMo Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.testTag("error")) } - // 240 wide and centred, which is the kit's primary button on this - // screen, not a full-width slab. A button that spans the phone reads as - // the bottom of a form; one that does not reads as a choice. + // 240 wide and centred, which is the kit's primary button on this screen, + // not a full-width slab. Spacer(Modifier.height(Space.md)) Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { Button( onClick = { if (registering) { - // The most conservative reading, a birthday not yet - // passed this year, because "maybe old enough" isn't. + // The most conservative reading, a birthday not yet passed + // this year, because "maybe old enough" isn't. val year = birthYear.toIntOrNull() ?: 0 - val age = java.util.Calendar.getInstance().get(java.util.Calendar.YEAR) - year + val age = + java.util.Calendar + .getInstance() + .get(java.util.Calendar.YEAR) - year if (year < 1900 || age < 14) { - ageError = if (year < 1900) "Enter your year of birth." - else "You must be at least 13 to use Flamenet Messenger." + ageError = + if (year < 1900) { + "Enter your year of birth." + } else { + "You must be at least 13 to use Flamenet Messenger." + } return@Button } ageError = null } model.signIn(handle.trim(), passphrase, register = registering, email = email.trim()) }, - enabled = !phase.busy && handle.isNotBlank() && passphrase.isNotBlank() && - (!needsEmail || email.isNotBlank()) && - (!registering || birthYear.length == 4), + enabled = + !phase.busy && handle.isNotBlank() && passphrase.isNotBlank() && + (!needsEmail || email.isNotBlank()) && + (!registering || birthYear.length == 4), shape = RoundedCornerShape(KitButton.radius), modifier = Modifier.width(240.dp).height(KitButton.height).testTag("submit"), ) { @@ -508,14 +458,12 @@ private fun ColumnScope.SignIn(model: MessengerViewModel, phase: MessengerViewMo /** * Password reset, in place rather than on another screen. - * - * The relay answers a reset request identically whatever the truth is, - * unknown handle, no address on file, an account that authenticates elsewhere. - * So the copy here says what was *done*, never what was *found*. "No such - * account" would undo that at the last step. */ @Composable -private fun ColumnScope.ResetPassword(model: MessengerViewModel, handle: String) { +private fun ColumnScope.ResetPassword( + model: MessengerViewModel, + handle: String, +) { var open by remember { mutableStateOf(false) } var code by remember { mutableStateOf("") } var fresh by remember { mutableStateOf("") } @@ -562,52 +510,31 @@ private fun ColumnScope.ResetPassword(model: MessengerViewModel, handle: String) /** * What the bar contains, as data rather than as a shape only Compose can see. - * - * Extracted so a test can read it. The bar was a hand-written run of - * `NavButton` calls, which meant nothing could assert what was in it, a - * destination could be dropped in a refactor and no test anywhere would go red. - * It has happened once already in spirit: Files and Wallet lived only inside - * the Account screen for weeks while iOS carried them in its bar, and the two - * apps disagreed with nothing to notice. */ object NavDestinations { - - data class Tab(val screen: MessengerViewModel.Phase.Screen, val label: String, val tag: String) + data class Tab( + val screen: MessengerViewModel.Phase.Screen, + val label: String, + val tag: String, + ) /** * The bar, in order. - * - * Order matches iOS, which puts Files and Wallet between Stories and - * Account. The same destination in the same place on both phones is most of - * what "the apps stay in sync" means to somebody holding one of each. - * - * `storiesHidden` is parental supervision: it removes the surface rather - * than showing an empty one. */ - fun tabs(storiesHidden: Boolean): List = listOfNotNull( - Tab(Screen.CONTACTS, "Home", "nav-contacts"), - Tab(Screen.MESSAGES, "Messages", "nav-messages"), - Tab(Screen.CALLS, "Calls", "nav-calls"), - Tab(Screen.STORIES, "Stories", "nav-stories").takeUnless { storiesHidden }, - Tab(Screen.FILES, "Files", "nav-files"), - Tab(Screen.WALLET, "Wallet", "nav-wallet"), - Tab(Screen.ACCOUNT, "Account", "nav-account"), - ) + fun tabs(storiesHidden: Boolean): List = + listOfNotNull( + Tab(Screen.CONTACTS, "Home", "nav-contacts"), + Tab(Screen.MESSAGES, "Messages", "nav-messages"), + Tab(Screen.CALLS, "Calls", "nav-calls"), + Tab(Screen.STORIES, "Stories", "nav-stories").takeUnless { storiesHidden }, + Tab(Screen.FILES, "Files", "nav-files"), + Tab(Screen.WALLET, "Wallet", "nav-wallet"), + Tab(Screen.ACCOUNT, "Account", "nav-account"), + ) } /** * The places to be, always visible. - * - * A tab bar rather than a menu: a capability hidden behind an overflow is one - * most users never find. That was the argument for putting the account here, - * and it is the same argument that brought Files and Wallet here on 2026-09-07 - * they were reachable only by scrolling most of the way down the account - * screen, which is indistinguishable from not shipping them. - * - * The kit's artboard is four icons and this is now seven. That is a - * deliberate departure, not an oversight: the earlier note here argued a wallet - * "does not earn the slot", and iOS had already decided otherwise. Two apps - * disagreeing about where a feature lives is worse than either answer. */ @Composable private fun BottomNav( @@ -615,17 +542,14 @@ private fun BottomNav( phase: MessengerViewModel.Phase.SignedIn, ) { val unreadNotes = phase.notifications.count { !it.read } - // A floating pill, not a full-width strip. The kit's tab bar is a - // 328x50 rounded rectangle inset from both edges, sitting inside an 84-tall - // band, it reads as a control resting on the page rather than as the - // bottom of the window. This is the single most recognisable thing about - // the design and the app had a plain row of text buttons. + // A floating pill, not a full-width strip. Row( - modifier = Modifier - .fillMaxWidth() - .heightIn(min = Kit.pillHeight) - .clip(RoundedCornerShape(Kit.pillRadius)) - .background(MaterialTheme.colorScheme.surfaceVariant), + modifier = + Modifier + .fillMaxWidth() + .heightIn(min = Kit.pillHeight) + .clip(RoundedCornerShape(Kit.pillRadius)) + .background(MaterialTheme.colorScheme.surfaceVariant), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceEvenly, ) { @@ -633,28 +557,32 @@ private fun BottomNav( val storiesHidden = ParentalControls.storiesHidden(androidx.compose.ui.platform.LocalContext.current) for (tab in NavDestinations.tabs(storiesHidden)) { - val badge = when (tab.screen) { - // The inbox badge lives on Messages. Home is the roster, this - // is where new mail is read. - Screen.MESSAGES -> phase.unread.size - // Missed calls SINCE the tab was last opened, it used to count - // every missed call in the log for ever, a badge nothing could - // clear. Opening the tab is what "seeing" them means. - Screen.CALLS -> model.missedCallsUnseen(phase) - // Alerts left the bar 2026-08-28 and iOS never had one; its - // unread count badges Account on both phones now. - Screen.ACCOUNT -> unreadNotes - else -> 0 - } - val (icon, active) = when (tab.screen) { - Screen.CONTACTS -> KitIcon.Home to KitIcon.Home - Screen.MESSAGES -> KitIcon.Chats to KitIcon.ChatsActive - Screen.CALLS -> KitIcon.Call to KitIcon.Call - Screen.STORIES -> KitIcon.Stories to KitIcon.Stories - Screen.FILES -> KitIcon.Files to KitIcon.Files - Screen.WALLET -> KitIcon.Wallet to KitIcon.Wallet - else -> KitIcon.Account to KitIcon.Account - } + val badge = + when (tab.screen) { + // The inbox badge lives on Messages. + Screen.MESSAGES -> phase.unread.size + + // Missed calls SINCE the tab was last opened, it used to count + // every missed call in the log for ever, a badge nothing could + // clear. + Screen.CALLS -> model.missedCallsUnseen(phase) + + // Alerts left the bar 2026-08-28 and iOS never had one; its + // unread count badges Account on both phones now. + Screen.ACCOUNT -> unreadNotes + + else -> 0 + } + val (icon, active) = + when (tab.screen) { + Screen.CONTACTS -> KitIcon.Home to KitIcon.Home + Screen.MESSAGES -> KitIcon.Chats to KitIcon.ChatsActive + Screen.CALLS -> KitIcon.Call to KitIcon.Call + Screen.STORIES -> KitIcon.Stories to KitIcon.Stories + Screen.FILES -> KitIcon.Files to KitIcon.Files + Screen.WALLET -> KitIcon.Wallet to KitIcon.Wallet + else -> KitIcon.Account to KitIcon.Account + } NavButton(model, phase, tab.screen, icon, active, tab.label, badge, tab.tag) } } @@ -662,16 +590,6 @@ private fun BottomNav( /** * One tab: a drawn icon, and a count when there is one. - * - * No label. The kit's bar is four icons and nothing else, the artboard - * is literally named "Bottom Bar/Without Label". This was four `TextButton`s - * reading "Contacts Stories Alerts Account", which is a row of links, and - * the counts were parenthesised into those labels: "Contacts (3)". - * - * The label survives as the accessibility name. Dropping the text from the - * screen must not drop it from VoiceOver/TalkBack, or the bar becomes four - * unnamed buttons, the version of this that shipped as icons-only with no - * `contentDescription` would be strictly worse than the text it replaced. */ @Composable private fun RowScope.NavButton( @@ -686,45 +604,45 @@ private fun RowScope.NavButton( ) { val selected = phase.screen == screen Box( - modifier = Modifier - .weight(1f) - .height(Kit.pillHeight) - // `navigateTo`, not `showScreen`. Wallet and Files each have to - // read the vault before they have anything to draw, so a bare - // `showScreen` lands somebody on an empty screen with nothing - // loading and no error, see the note on `navigateTo`. - .clickable { model.navigateTo(screen) } - .semantics { - contentDescription = - if (badge > 0) "$label, $badge unread" else label - } - .testTag(tag), + modifier = + Modifier + .weight(1f) + .height(Kit.pillHeight) + // `navigateTo`, not `showScreen`. + .clickable { model.navigateTo(screen) } + .semantics { + contentDescription = + if (badge > 0) "$label, $badge unread" else label + }.testTag(tag), contentAlignment = Alignment.Center, ) { Icon( if (selected) activeIcon else icon, contentDescription = null, - tint = if (selected) MaterialTheme.colorScheme.primary - else Palette.SecondaryText, + tint = + if (selected) { + MaterialTheme.colorScheme.primary + } else { + Palette.SecondaryText + }, modifier = Modifier.size(Kit.icon), ) if (badge > 0) { Box( - modifier = Modifier - .align(Alignment.TopEnd) - .padding(top = Space.md, end = Space.lg) - .size(Space.sm) - .clip(CircleShape) - .background(Palette.Pink), + modifier = + Modifier + .align(Alignment.TopEnd) + .padding(top = Space.md, end = Space.lg) + .size(Space.sm) + .clip(CircleShape) + .background(Palette.Pink), ) } } } /** - * Internal rather than private ONLY so the JVM screenshot suite can render - * it. `MainActivity` is its one real caller, the alternative was a screenshot - * harness that could photograph the app's parts and never a whole screen. + * Internal rather than private ONLY so the JVM screenshot suite can render it. */ @Composable internal fun ColumnScope.SignedIn( @@ -736,28 +654,24 @@ internal fun ColumnScope.SignedIn( var scanning by remember { mutableStateOf(false) } var scannedPrefill by remember { mutableStateOf("") } val scanContext = androidx.compose.ui.platform.LocalContext.current - val askCamera = androidx.activity.compose.rememberLauncherForActivityResult( - androidx.activity.result.contract.ActivityResultContracts.RequestPermission() - ) { _ -> - // Open either way, the dialog explains itself when access is refused. - scanning = true - } + val askCamera = + androidx.activity.compose.rememberLauncherForActivityResult( + androidx.activity.result.contract.ActivityResultContracts + .RequestPermission(), + ) { _ -> + // Open either way, the dialog explains itself when access is refused. + scanning = true + } - // The contact screen REPLACES the list rather than sitting under it, - // the same way an open chat does. A detail view stacked below a list is - // two screens fighting over one scroll. + // The contact screen REPLACES the list rather than sitting under it, the + // same way an open chat does. val profile = openProfile?.let { id -> phase.buddies.firstOrNull { it.userId == id } } if (profile != null) { ContactDetail(model, phase, profile) { openProfile = null } return } - // Home is two things now, on purpose: your people, and their - // stories. The conversation list, the groups and the search moved to - // the Messages tab, this screen is the kit's social front page: a - // horizontal row of contacts, then everyone's stories. Managing the - // roster stays here (the Add action, and a long-press on a circle for - // the contact's profile). + // Home is two things now, on purpose: your people, and their stories. Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, @@ -768,18 +682,22 @@ internal fun ColumnScope.SignedIn( fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f), ) - // Scan their Address screen, iOS's home has the same button. A scan - // fills the Add-contact field and stops; nothing is added unlooked-at. + // Scan their Address screen, iOS's home has the same button. BarAction( icon = KitIcon.Scan, label = "Scan an address", tag = "scan-address", onClick = { - val has = androidx.core.content.ContextCompat.checkSelfPermission( - scanContext, android.Manifest.permission.CAMERA - ) == android.content.pm.PackageManager.PERMISSION_GRANTED - if (has) scanning = true - else askCamera.launch(android.Manifest.permission.CAMERA) + val has = + androidx.core.content.ContextCompat.checkSelfPermission( + scanContext, + android.Manifest.permission.CAMERA, + ) == android.content.pm.PackageManager.PERMISSION_GRANTED + if (has) { + scanning = true + } else { + askCamera.launch(android.Manifest.permission.CAMERA) + } }, ) BarAction( @@ -796,11 +714,6 @@ internal fun ColumnScope.SignedIn( scanning = false parseScannedAddress(raw)?.let { card -> // Bind by the fnid, never the handle (audit LOW). - // The fnid is derived from the account key and certifies - // itself; a handle is a name the RELAY maps to an account, - // so resolving a scanned code by handle handed the relay - // the choice of who you just scanned. iOS always binds the - // address; this now matches it, same relay or not. scannedPrefill = "${'$'}{card.fnid}@${'$'}{card.relay}" addingContact = true } @@ -847,11 +760,6 @@ internal fun ColumnScope.SignedIn( /** * One icon-sized action on the trailing edge of the bar. - * - * Glyphs rather than vector assets, because this app ships no icon set and - * pulling one in for four buttons is a dependency for decoration. They are - * sized and spaced like the kit's 24dp icons and carry the real label for - * anyone who cannot see them. */ @Composable private fun BarAction( @@ -861,17 +769,16 @@ private fun BarAction( onClick: () -> Unit, ) { Box( - modifier = Modifier - .size(Kit.icon + Space.md) - .clip(CircleShape) - .clickable(onClick = onClick) - .semantics { contentDescription = label } - .testTag(tag), + modifier = + Modifier + .size(Kit.icon + Space.md) + .clip(CircleShape) + .clickable(onClick = onClick) + .semantics { contentDescription = label } + .testTag(tag), contentAlignment = Alignment.Center, ) { - // An `Icon`, not a glyph in a text run. See `KitIcons.kt`, the bar - // used to draw "⊞" and "+" as characters, which is most of why this - // screen read as text links rather than as chrome. + // An `Icon`, not a glyph in a text run. Icon( icon, contentDescription = null, @@ -881,7 +788,6 @@ private fun BarAction( } } - @Composable private fun ColumnScope.VaultUnreadable( model: MessengerViewModel, @@ -898,12 +804,11 @@ private fun ColumnScope.VaultUnreadable( "Starting fresh publishes a new device. Everyone you talk to will see " + "\"security keys changed\", so tell them to expect it.", ) - // `discardUnreadableVault`, NOT `signOut`. `signOut()` defaults to - // keeping the device, so it left the unreadable vault in place and the next - // restore came straight back here, the only button on a dead-end screen - // did nothing but redraw it. - Button(onClick = model::discardUnreadableVault, - modifier = Modifier.testTag("start-fresh")) { + // `discardUnreadableVault`, NOT `signOut`. + Button( + onClick = model::discardUnreadableVault, + modifier = Modifier.testTag("start-fresh"), + ) { Text("Erase and start fresh") } } diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MediaScrub.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MediaScrub.kt index 3c53e16..236a589 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MediaScrub.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MediaScrub.kt @@ -9,31 +9,12 @@ import java.nio.ByteBuffer /** * Strip everything from a video except the pictures and the sound. - * - * A clip from the camera roll says where it was shot. Android writes the - * capture location into the MP4 container, the `©xyz` / `location` atom, and - * the story path used to hand the picked file over byte for byte, so posting a - * video told every viewer the coordinates of wherever the person happened to - * be. Nothing on screen suggested it and no setting controlled it. Photos were - * already safe by accident, because [ImagePrep] re-encodes them through a - * bitmap and `Bitmap.compress` writes no EXIF; video had no equivalent step at - * all. - * - * A remux, not a transcode. The samples are copied across untouched, so - * there is no quality loss and no codec work, the container is rebuilt and the - * metadata simply is not carried over. That is cheap enough to do on every - * post, which matters: a scrub people wait for is a scrub somebody eventually - * makes optional. - * - * Rotation IS carried over, deliberately. It says which way up the phone - * was, not where it was, and dropping it plays every portrait clip on its side. */ object MediaScrub { - /** * @param source the picked video's bytes. - * @param rotationDegrees from `METADATA_KEY_VIDEO_ROTATION`, re-applied to - * the rebuilt file. + * @param rotationDegrees from `METADATA_KEY_VIDEO_ROTATION`, re-applied + * to the rebuilt file. * @return scrubbed bytes, or null if the file could not be rebuilt, the * caller must then refuse the post rather than fall back to the original. */ @@ -54,9 +35,7 @@ object MediaScrub { extractor.setDataSource(input.absolutePath) muxer = MediaMuxer(output.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4) - // Audio and video only. Anything else in the file, timed text, a - // sensor track, whatever a particular camera app decided to add, - // is exactly the kind of passenger this exists to leave behind. + // Audio and video only. val trackMap = HashMap() var bufferSize = DEFAULT_BUFFER for (i in 0 until extractor.trackCount) { @@ -87,9 +66,7 @@ object MediaScrub { info.offset = 0 info.size = size info.presentationTimeUs = extractor.sampleTime - // Mapped rather than assigned across. The extractor's - // flags and the codec's are different vocabularies that - // happen to agree on the value of "sync sample" today. + // Mapped rather than assigned across. info.flags = if (extractor.sampleFlags and MediaExtractor.SAMPLE_FLAG_SYNC != 0) { MediaCodec.BUFFER_FLAG_KEY_FRAME diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MediaSettings.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MediaSettings.kt index b5aa674..48dc21b 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MediaSettings.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MediaSettings.kt @@ -4,21 +4,14 @@ import android.content.Context /** * How photos are prepared before they are sent. - * - * Ordinary SharedPreferences, not the vault. This records a preference, not - * a secret, and the picker needs to read it on a path that must not depend on - * the encrypted store being open. */ -class MediaSettings(context: Context) { - +class MediaSettings( + context: Context, +) { private val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) /** * Send photos at the highest quality that will still fit. - * - * Not "uncompressed". The relay refuses a blob over its cap, so this - * raises the ceiling and [ImagePrep] steps down until the bytes fit, a - * photo that failed to send would be worse than a slightly softer one. */ var fullQuality: Boolean get() = prefs.getBoolean(FULL_QUALITY, false) @@ -26,18 +19,6 @@ class MediaSettings(context: Context) { /** * How long a story posted from this phone stays visible, in hours. - * - * A day was the only answer the app had. The lifetime was a - * constant, so the one thing people actually want to change about a story - * how long it lasts, was the one thing they could not. - * - * The choice is the SENDER's and rides inside the sealed envelope - * (`exp`, §6.6), so it does not ask the relay to keep a promise: every copy - * carries the expiry its poster chose. - * - * Clamped to the offered set on read. A value from a future build, or a - * corrupted preference, must not post a story that expires before anybody - * sees it, which is what a raw 0 would do. */ var storyLifetimeHours: Int get() { @@ -51,32 +32,16 @@ class MediaSettings(context: Context) { /** * Keep your own stories after they expire, in a private archive (ios#54). - * - * Off unless switched on, and an absent value reads as false. This - * keeps something the interface told everybody would disappear, so it - * cannot be the quiet default, and it only ever holds stories this - * account posted. */ var storyArchiveEnabled: Boolean get() = prefs.getBoolean("stories.archiveEnabled", false) - set(value) { prefs.edit().putBoolean("stories.archiveEnabled", value).apply() } + set(value) { + prefs.edit().putBoolean("stories.archiveEnabled", value).apply() + } /** * Refuse to send unless the relay can prove the recipient's keys appear in * the log it publishes. - * - * This lives here because it was not stored anywhere at all. It was - * a plain in-memory field on the trust object, set from the switch and - * never written down, so it reverted to off on every launch. A person who - * turned it on was protected until they next closed the app, and nothing - * told them it had lapsed, the switch simply read off again, and a - * setting that quietly disarms itself is worse than one that was never - * offered. iOS has always persisted this in UserDefaults; Android had - * nothing. - * - * Still off by default, deliberately. A relay older than the - * transparency log has no log to prove anything against, so enforcing it - * everywhere would refuse every peer on those deployments. */ var requireKeyLog: Boolean get() = prefs.getBoolean(REQUIRE_KEY_LOG, false) @@ -84,11 +49,6 @@ class MediaSettings(context: Context) { /** * Generate a preview card when sending a link. - * - * Fetching happens on THIS device before the message goes, so the site - * learns somebody is about to share that URL, from this address. The - * recipient never fetches anything (§6.12), this end does, and that is - * what the setting is for. */ var linkPreviews: Boolean get() = prefs.getBoolean(LINK_PREVIEWS, true) @@ -100,6 +60,7 @@ class MediaSettings(context: Context) { private const val FULL_QUALITY = "full_quality" private const val STORY_LIFETIME_HOURS = "stories.lifetimeHours" const val STORY_LIFETIME_DEFAULT = 24 + /** Same set as iOS, so the two apps offer the same thing. */ val STORY_LIFETIME_CHOICES = listOf(1, 6, 24, 72, 168) private const val LINK_PREVIEWS = "link_previews" @@ -107,25 +68,24 @@ class MediaSettings(context: Context) { /** * Refuse to send at all when the send cannot be sealed (§14.5, ios#16). - * - * Off by default, and that is the honest default rather than the lazy - * one. An unsealed send is still end-to-end encrypted; what leaks is that - * this account sent something to that one. Refusing turns a metadata leak - * into an undelivered message, which for most people is the worse outcome - * so the default degrades and SAYS SO. */ var requireSealedSending: Boolean get() = prefs.getBoolean("privacy.requireSealed", false) - set(value) { prefs.edit().putBoolean("privacy.requireSealed", value).apply() } + set(value) { + prefs.edit().putBoolean("privacy.requireSealed", value).apply() + } /** Counters for how often sealing degraded (ios#16). */ var sealingRecord: io.flamenet.e2e.SealingRecord - get() = io.flamenet.e2e.SealingRecord( - sealed = prefs.getInt("privacy.sealing.sealed", 0), - unsealed = prefs.getInt("privacy.sealing.unsealed", 0), - lastUnsealedAt = prefs.getLong("privacy.sealing.lastUnsealedAt", 0L)) + get() = + io.flamenet.e2e.SealingRecord( + sealed = prefs.getInt("privacy.sealing.sealed", 0), + unsealed = prefs.getInt("privacy.sealing.unsealed", 0), + lastUnsealedAt = prefs.getLong("privacy.sealing.lastUnsealedAt", 0L), + ) set(value) { - prefs.edit() + prefs + .edit() .putInt("privacy.sealing.sealed", value.sealed) .putInt("privacy.sealing.unsealed", value.unsealed) .putLong("privacy.sealing.lastUnsealedAt", value.lastUnsealedAt) diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MessagesScreen.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MessagesScreen.kt index 1a37083..ee72583 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessagesScreen.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessagesScreen.kt @@ -10,9 +10,9 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Divider import androidx.compose.material3.MaterialTheme @@ -22,8 +22,8 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -35,12 +35,8 @@ import androidx.compose.ui.unit.sp import io.flamenet.e2e.Vault /** - * The kit's `07 Messages-Listing` as its own tab: large title, search box, - * the Recent circles row, then a flat list of conversations. - * - * Conversations, not contacts. Home lists everybody and manages the - * roster; this screen is the inbox, people you talk to, unread first. Same - * split iOS ships, so the two apps stay one product. + * The kit's `07 Messages-Listing` as its own tab: large title, search box, the + * Recent circles row, then a flat list of conversations. */ @Composable fun ColumnScope.MessagesScreen( @@ -53,12 +49,14 @@ fun ColumnScope.MessagesScreen( var scanning by remember { mutableStateOf(false) } var scannedPrefill by remember { mutableStateOf("") } val scanContext = androidx.compose.ui.platform.LocalContext.current - val askCamera = androidx.activity.compose.rememberLauncherForActivityResult( - androidx.activity.result.contract.ActivityResultContracts.RequestPermission() - ) { _ -> - // Open either way, the dialog explains itself when access is refused. - scanning = true - } + val askCamera = + androidx.activity.compose.rememberLauncherForActivityResult( + androidx.activity.result.contract.ActivityResultContracts + .RequestPermission(), + ) { _ -> + // Open either way, the dialog explains itself when access is refused. + scanning = true + } // Starting a group sits on the Groups heading and a chat on the Chats // heading; the bar keeps the two ways a NEW person gets in, scan their @@ -77,26 +75,32 @@ fun ColumnScope.MessagesScreen( KitIcon.Scan, contentDescription = "Scan an address", tint = Palette.Accent, - modifier = Modifier - .size(24.dp) - .clickable { - val has = androidx.core.content.ContextCompat.checkSelfPermission( - scanContext, android.Manifest.permission.CAMERA - ) == android.content.pm.PackageManager.PERMISSION_GRANTED - if (has) scanning = true - else askCamera.launch(android.Manifest.permission.CAMERA) - } - .testTag("messages-scan"), + modifier = + Modifier + .size(24.dp) + .clickable { + val has = + androidx.core.content.ContextCompat.checkSelfPermission( + scanContext, + android.Manifest.permission.CAMERA, + ) == android.content.pm.PackageManager.PERMISSION_GRANTED + if (has) { + scanning = true + } else { + askCamera.launch(android.Manifest.permission.CAMERA) + } + }.testTag("messages-scan"), ) Spacer(Modifier.width(Space.md)) androidx.compose.material3.Icon( if (addingContact) KitIcon.Close else KitIcon.Add, contentDescription = "Add contact", tint = Palette.Accent, - modifier = Modifier - .size(26.dp) - .clickable { addingContact = !addingContact } - .testTag("messages-add-contact"), + modifier = + Modifier + .size(26.dp) + .clickable { addingContact = !addingContact } + .testTag("messages-add-contact"), ) } @@ -105,9 +109,14 @@ fun ColumnScope.MessagesScreen( onFound = { raw -> scanning = false parseScannedAddress(raw)?.let { card -> - scannedPrefill = if (card.handle != null && - card.relay.equals(phase.relay, ignoreCase = true) - ) card.handle else "${card.fnid}@${card.relay}" + scannedPrefill = + if (card.handle != null && + card.relay.equals(phase.relay, ignoreCase = true) + ) { + card.handle + } else { + "${card.fnid}@${card.relay}" + } addingContact = true } }, @@ -132,9 +141,7 @@ fun ColumnScope.MessagesScreen( ) Spacer(Modifier.height(Space.md)) - // Above the conversation list. Somebody searching for a word they - // remember is looking for the message, not the person, name matches are - // already covered by the list underneath. + // Above the conversation list. MessageHits(model, query.value) BuddyCirclesRow(model, phase) @@ -143,9 +150,6 @@ fun ColumnScope.MessagesScreen( Spacer(Modifier.height(Space.sm)) // The kit's listing holds every conversation, groups included. - // No label of our own above the groups: GroupsSection draws its own - // (and only when there is something to head). A second one here printed - // "GROUPS" twice. GroupsSection(model, phase, query.value, creatingGroup.value) { creatingGroup.value = it } Spacer(Modifier.height(Space.sm)) val showNewChat = remember { mutableStateOf(false) } @@ -158,8 +162,9 @@ fun ColumnScope.MessagesScreen( fontSize = 12.sp, fontWeight = FontWeight.Medium, color = Palette.SecondaryText, - letterSpacing = androidx.compose.ui.unit.TextUnit( - 0.8f, androidx.compose.ui.unit.TextUnitType.Sp), + letterSpacing = + androidx.compose.ui.unit + .TextUnit(0.8f, androidx.compose.ui.unit.TextUnitType.Sp), modifier = Modifier.weight(1f), ) // The way in, beside the label, exactly as New group sits beside its @@ -168,10 +173,11 @@ fun ColumnScope.MessagesScreen( KitIcon.NewChat, contentDescription = "New chat", tint = Palette.Accent, - modifier = Modifier - .size(22.dp) - .clickable { showNewChat.value = true } - .testTag("new-chat"), + modifier = + Modifier + .size(22.dp) + .clickable { showNewChat.value = true } + .testTag("new-chat"), ) } if (showNewChat.value) { @@ -184,17 +190,19 @@ fun ColumnScope.MessagesScreen( } else { Column { phase.buddies.forEach { buddy -> - val rowName = buddy.displayName.ifBlank { buddy.handle } - .ifBlank { "user ${buddy.userId}" } + val rowName = + buddy.displayName + .ifBlank { buddy.handle } + .ifBlank { "user ${buddy.userId}" } Row( - modifier = Modifier - .fillMaxWidth() - .clickable { - showNewChat.value = false - model.openChat(buddy.userId, rowName) - } - .padding(vertical = 10.dp) - .testTag("new-chat-${buddy.userId}"), + modifier = + Modifier + .fillMaxWidth() + .clickable { + showNewChat.value = false + model.openChat(buddy.userId, rowName) + }.padding(vertical = 10.dp) + .testTag("new-chat-${buddy.userId}"), verticalAlignment = Alignment.CenterVertically, ) { Avatar( @@ -217,23 +225,22 @@ fun ColumnScope.MessagesScreen( } }, confirmButton = { - androidx.compose.material3.TextButton( - onClick = { showNewChat.value = false }) { Text("Cancel") } + androidx.compose.material3.TextButton(onClick = { showNewChat.value = false }) { Text("Cancel") } }, ) } - val shown = phase.buddies - .filter { - query.value.isBlank() || - it.displayName.contains(query.value, true) || - it.handle.contains(query.value, true) - } - .sortedWith( - compareByDescending { - Vault.threadForUser(it.userId) in phase.unread - }.thenBy { it.displayName.ifBlank { it.handle } } - ) + val shown = + phase.buddies + .filter { + query.value.isBlank() || + it.displayName.contains(query.value, true) || + it.handle.contains(query.value, true) + }.sortedWith( + compareByDescending { + Vault.threadForUser(it.userId) in phase.unread + }.thenBy { it.displayName.ifBlank { it.handle } }, + ) if (shown.isEmpty()) { Text( @@ -250,15 +257,18 @@ fun ColumnScope.MessagesScreen( Column(modifier = Modifier.fillMaxWidth().testTag("conversations")) { shown.forEach { buddy -> - val name = buddy.displayName.ifBlank { buddy.handle } - .ifBlank { "user ${buddy.userId}" } + val name = + buddy.displayName + .ifBlank { buddy.handle } + .ifBlank { "user ${buddy.userId}" } val unread = Vault.threadForUser(buddy.userId) in phase.unread Row( - modifier = Modifier - .fillMaxWidth() - .clickable { model.openChat(buddy.userId, name) } - .padding(vertical = Kit.rowGap / 2) - .testTag("conversation-${buddy.userId}"), + modifier = + Modifier + .fillMaxWidth() + .clickable { model.openChat(buddy.userId, name) } + .padding(vertical = Kit.rowGap / 2) + .testTag("conversation-${buddy.userId}"), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Kit.gutter), ) { @@ -279,8 +289,12 @@ fun ColumnScope.MessagesScreen( Text( if (unread) "New messages" else buddy.presence, fontSize = 12.sp, - color = if (unread) MaterialTheme.colorScheme.primary - else Palette.SecondaryText, + color = + if (unread) { + MaterialTheme.colorScheme.primary + } else { + Palette.SecondaryText + }, ) } if (unread) UnreadDot() @@ -294,22 +308,22 @@ fun ColumnScope.MessagesScreen( @Composable private fun UnreadDot() { Box( - modifier = Modifier - .size(10.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.primary), + modifier = + Modifier + .size(10.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary), ) } /** * What a search found inside conversations, rather than in their names. - * - * Debounced. Each keystroke would otherwise decrypt every thread on the - * device, the cost the iPhone app does not pay, because it keeps the whole - * history in memory. */ @Composable -private fun MessageHits(model: MessengerViewModel, query: String) { +private fun MessageHits( + model: MessengerViewModel, + query: String, +) { var hits by remember { mutableStateOf>(emptyList()) } LaunchedEffect(query) { @@ -333,9 +347,10 @@ private fun MessageHits(model: MessengerViewModel, query: String) { ) for (hit in hits) { Column( - Modifier.fillMaxWidth() + Modifier + .fillMaxWidth() .padding(horizontal = Space.md, vertical = Space.sm) - .testTag("search-hit") + .testTag("search-hit"), ) { Text( hit.snippet, diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerService.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerService.kt index 23140e6..79faec6 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerService.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerService.kt @@ -24,31 +24,8 @@ import kotlinx.coroutines.launch /** * Keeps receiving while the app is not on screen. - * - * This is the only way to be alerted without a third party. iOS has one - * mechanism for waking a suspended app and it is Apple's; Android does not have - * that limit, a foreground service may hold a connection with nobody in the - * middle. That asymmetry is the whole reason this class exists, and it is worth - * being honest about on the privacy page rather than letting people assume the - * two platforms behave alike. - * - * The persistent notification is not optional and not a bug. Android - * requires a visible notice for a foreground service. Users will ask to remove - * it; removing it means the OS kills the connection. It is the price of not - * routing wake-ups through Google. - * - * Exactly one poller at a time. The ViewModel polls while the app is on - * screen and this service polls while it is not, started from `onStop`, - * stopped from `onStart`. Two pollers would race for the same envelopes and - * double-notify. If that handover ever changes, check both ends. - * - * Some OEMs (Xiaomi, Huawei, Samsung on aggressive battery settings) kill - * foreground services anyway. Nothing here can prevent that; the poll resumes - * when the app is next opened, and no message is lost because the relay holds - * it until the cursor advances. */ class MessengerService : Service() { - companion object { private const val ONGOING_ID = 1 private const val CHANNEL_ONGOING = "flamenet.ongoing" @@ -85,10 +62,13 @@ class MessengerService : Service() { createChannels() } - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + override fun onStartCommand( + intent: Intent?, + flags: Int, + startId: Int, + ): Int { // startForeground within a few seconds of onStartCommand or Android // kills the process with a ForegroundServiceDidNotStartInTimeException. - // Do it before any I/O, not after the session is built. startInForeground() if (loop?.isActive == true) return START_STICKY @@ -107,21 +87,30 @@ class MessengerService : Service() { } private fun startInForeground() { - val open = PendingIntent.getActivity( - this, 0, Intent(this, MainActivity::class.java), - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT) - val notice = NotificationCompat.Builder(this, CHANNEL_ONGOING) - .setContentTitle("Flamenet is receiving") - .setContentText("Messages arrive without Google or Apple in the middle.") - .setSmallIcon(android.R.drawable.stat_notify_chat) - .setOngoing(true) - .setPriority(NotificationCompat.PRIORITY_MIN) - .setContentIntent(open) - .build() + val open = + PendingIntent.getActivity( + this, + 0, + Intent(this, MainActivity::class.java), + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + val notice = + NotificationCompat + .Builder(this, CHANNEL_ONGOING) + .setContentTitle("Flamenet is receiving") + .setContentText("Messages arrive without Google or Apple in the middle.") + .setSmallIcon(android.R.drawable.stat_notify_chat) + .setOngoing(true) + .setPriority(NotificationCompat.PRIORITY_MIN) + .setContentIntent(open) + .build() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - startForeground(ONGOING_ID, notice, - ServiceInfo.FOREGROUND_SERVICE_TYPE_REMOTE_MESSAGING) + startForeground( + ONGOING_ID, + notice, + ServiceInfo.FOREGROUND_SERVICE_TYPE_REMOTE_MESSAGING, + ) } else { startForeground(ONGOING_ID, notice) } @@ -129,220 +118,215 @@ class MessengerService : Service() { /** * Rebuild the session from the vault rather than sharing the ViewModel's. - * - * The Activity may already be gone by the time this runs, that is the - * situation the service exists for, so there is nothing to share. The - * vault is the durable half and both sides read it the same way. */ - private fun openSession(): Session? = runCatching { - val vault: Vault = KeystoreVault.open(applicationContext) - val identity = vault.loadIdentity() ?: return null - val credential = vault.loadCredential() ?: return null - val relay = RelayClient( - credential.relayBaseUrl, credential.username, credential.appPassword) - relay.useRelayAuth(identity) - Session( - handle = credential.username, - userId = credential.userId, - identity = identity, - vault = vault, - relay = relay, - api = MessengerApi( - credential.relayBaseUrl, credential.username, credential.appPassword), - ) - }.getOrNull() + private fun openSession(): Session? = + runCatching { + val vault: Vault = KeystoreVault.open(applicationContext) + val identity = vault.loadIdentity() ?: return null + val credential = vault.loadCredential() ?: return null + val relay = + RelayClient(credential.relayBaseUrl, credential.username, credential.appPassword) + relay.useRelayAuth(identity) + Session( + handle = credential.username, + userId = credential.userId, + identity = identity, + vault = vault, + relay = relay, + api = + MessengerApi(credential.relayBaseUrl, credential.username, credential.appPassword), + ) + }.getOrNull() private suspend fun run() { - val s = openSession() ?: run { - // Nothing to receive for, signed out, or the vault is unreadable. - // Stop rather than sit in the notification shade doing nothing. - stopSelf() - return - } + val s = + openSession() ?: run { + // Nothing to receive for, signed out, or the vault is unreadable. + stopSelf() + return + } session = s - // The constant, not the literal: the wire value is "call-offer", - // and a bare "offer" matches nothing and rings for no call ever. + // The constant, not the literal: the wire value is "call-offer", and a + // bare "offer" matches nothing and rings for no call ever. s.onCallSignal = { fromUser, signal, sentAt -> when (signal.action) { io.flamenet.e2e.Content.CallSignal.OFFER -> { - // Park the offer BEFORE notifying. This session consumed - // the envelope, so the SDP in hand is the only copy, the - // UI answers from PendingIncomingCall or not at all. - // Offers old enough that the caller gave up are notified - // as missed, not parked as answerable. + // Park the offer BEFORE notifying. if (sentAt > 0 && - System.currentTimeMillis() - sentAt > CallManager.DIAL_TIMEOUT_MS) { + System.currentTimeMillis() - sentAt > CallManager.DIAL_TIMEOUT_MS + ) { runCatching { s.recordCall(fromUser, incoming = true, connected = false) } } else { PendingIncomingCall.park(fromUser, signal, sentAt) notifyCall(nameFor(fromUser, s)) } } + io.flamenet.e2e.Content.CallSignal.END -> { - // The caller hung up before anyone opened the app. An - // offer left parked would ring for a dead call, and so - // would the notification, which is now `ongoing` and will - // NOT go away on its own. + // The caller hung up before anyone opened the app. PendingIncomingCall.clear(signal.callId) clearCallNotification() } - else -> Unit + + else -> { + Unit + } } } while (true) { val touched = runCatching { s.receive() }.getOrDefault(emptySet()) if (touched.isNotEmpty()) notifyMessages(touched.size) - // Reply reminders fire from THIS loop, after `receive()`, and - // the order matters: an inbound message clears the reminder inside + // Reply reminders fire from THIS loop, after `receive()`, and the + // order matters: an inbound message clears the reminder inside // `Session.record`, so a reply that has just landed cancels its own - // reminder before it can go off. Checking first would fire a - // reminder for a conversation that was answered a moment ago, - // which is the one thing that would teach people to ignore them. + // reminder before it can go off. fireDueReplyReminders(s) - // An unanswered ring has to stop ringing. The notification is - // `ongoing`, so nothing dismisses it on its own: without this a - // call nobody picked up leaves a permanent "incoming call" on the - // lock screen, with an Answer button for a call that is long dead. + // An unanswered ring has to stop ringing. expireStaleRing() - // A short gap, not a poll interval: `receive()` is held open by - // the relay for WAIT_SECONDS, so this only paces a relay that - // answers immediately. + // A short gap, not a poll interval: `receive()` is held open by the + // relay for WAIT_SECONDS, so this only paces a relay that answers + // immediately. delay(500) } } /** * A caller's name, from the buddy list. - * - * Cached for the life of the service and refreshed only on a miss. This - * runs on a ringing phone, the user has seconds, so a name lookup must - * not wait on the network when it already knows the answer. */ private var names: Map = emptyMap() - private fun nameFor(userId: Long, s: Session): String { + private fun nameFor( + userId: Long, + s: Session, + ): String { names[userId]?.let { return it } - names = runCatching { - s.roster().associate { it.userId to it.displayName.ifBlank { it.handle } } - }.getOrDefault(names) + names = + runCatching { + s.roster().associate { it.userId to it.displayName.ifBlank { it.handle } } + }.getOrDefault(names) // Falls back to something answerable rather than a bare number. return names[userId] ?: "Someone" } - private fun notifyMessages(threads: Int, from: String? = null) { + private fun notifyMessages( + threads: Int, + from: String? = null, + ) { val settings = NotificationSettings(this) // The master switch and the per-kind one, both checked before a - // notification is built rather than after. A notification suppressed - // late still woke the screen. + // notification is built rather than after. if (!settings.enabled || !settings.newMessages) return - val open = PendingIntent.getActivity( - this, 1, Intent(this, MainActivity::class.java), - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT) - // Still no sender and no preview unless it was asked for. The - // notification is drawn on this device from locally decrypted - // plaintext, so it *could* always have carried both, but a lock screen - // is a shoulder-surfing surface, and the product's claim is that a - // message is for the two people in it. `NotificationSettings` decides; - // its default is the count-only behaviour that was here before, so - // nobody acquires a name on their lock screen by upgrading. + val open = + PendingIntent.getActivity( + this, + 1, + Intent(this, MainActivity::class.java), + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + // Still no sender and no preview unless it was asked for. val text = settings.messageText(threads, from) - notify(CHANNEL_MESSAGES, 2, text, NotificationCompat.PRIORITY_HIGH, open, - number = threads) + notify( + CHANNEL_MESSAGES, + 2, + text, + NotificationCompat.PRIORITY_HIGH, + open, + number = threads, + ) } /** * Notify about conversations nobody answered, and forget them. - * - * Cleared as it fires. A reminder is a one-shot: leaving the row would - * re-notify on the next cycle, half a second later, for ever. */ private fun fireDueReplyReminders(s: Session) { val due = runCatching { s.dueReplyReminders() }.getOrDefault(emptyMap()) if (due.isEmpty()) return due.keys.forEach { threadId -> runCatching { s.vault.saveReplyReminder(threadId, 0L) } } - val open = PendingIntent.getActivity( - this, 4, Intent(this, MainActivity::class.java), - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT) - // Same privacy rule as a message notification, and the same setting: + val open = + PendingIntent.getActivity( + this, + 4, + Intent(this, MainActivity::class.java), + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + // Same privacy rule as a message notification, and the same setting. // who you are waiting on is as sensitive as what they said. val settings = NotificationSettings(this) if (!settings.enabled || !settings.newMessages) return - // Only a one-to-one thread can be named. `threadForUser` writes - // `user:`, and groups and cross-relay peers use their own prefixes - // so anything else falls through to the count, which is the safe - // direction: a wrong name on a lock screen is worse than no name. - val only = due.keys.singleOrNull() - ?.removePrefix("user:")?.takeIf { it.all(Char::isDigit) } - ?.toLongOrNull() - ?.let { userId -> runCatching { nameFor(userId, s) }.getOrNull() } + // Only a one-to-one thread can be named. + val only = + due.keys + .singleOrNull() + ?.removePrefix("user:") + ?.takeIf { it.all(Char::isDigit) } + ?.toLongOrNull() + ?.let { userId -> runCatching { nameFor(userId, s) }.getOrNull() } val text = settings.replyReminderText(due.size, only) notify(CHANNEL_MESSAGES, 5, text, NotificationCompat.PRIORITY_DEFAULT, open) } /** * Ring, in a way that can be answered without opening the app first. - * - * A high-priority notification is a banner, not a ringing phone. The - * previous version was one: on a locked device it showed a line of text and - * the only way to act on it was to unlock, find the app, and open it, by - * which time the caller has given up. A call is a live event with seconds - * on the clock, so it needs a full-screen intent, which is what wakes a - * locked screen into something answerable. - * - * `CallStyle` and `CATEGORY_CALL` are not decoration. Android 14 grants - * USE_FULL_SCREEN_INTENT at install only to apps that ring like this; - * without them the permission is refused and the call silently degrades to - * the banner it used to be. - * - * The Answer button hands off through the SAME parked offer the app uses - * on launch. The SDP in hand is the only copy, this session consumed the - * envelope, so a second, separate answer path would be a second way to - * lose a call. */ private fun notifyCall(from: String) { - val full = PendingIntent.getActivity( - this, 2, - Intent(this, MainActivity::class.java) - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) - .putExtra(EXTRA_ANSWER_CALL, true), - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT) + val full = + PendingIntent.getActivity( + this, + 2, + Intent(this, MainActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) + .putExtra(EXTRA_ANSWER_CALL, true), + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) - val decline = PendingIntent.getBroadcast( - this, 6, Intent(this, CallActionReceiver::class.java).setAction(ACTION_DECLINE), - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT) + val decline = + PendingIntent.getBroadcast( + this, + 6, + Intent(this, CallActionReceiver::class.java).setAction(ACTION_DECLINE), + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) - val caller = androidx.core.app.Person.Builder().setName(from).setImportant(true).build() - val notice = NotificationCompat.Builder(this, CHANNEL_CALLS) - .setSmallIcon(android.R.drawable.stat_notify_chat) - .setCategory(NotificationCompat.CATEGORY_CALL) - .setPriority(NotificationCompat.PRIORITY_MAX) - // Ongoing: a ringing call is not something to swipe away by - // accident while reaching for the torch. - .setOngoing(true) - .setAutoCancel(false) - .setFullScreenIntent(full, true) - .setStyle(NotificationCompat.CallStyle.forIncomingCall(caller, decline, full)) - .build() + val caller = + androidx.core.app.Person + .Builder() + .setName(from) + .setImportant(true) + .build() + val notice = + NotificationCompat + .Builder(this, CHANNEL_CALLS) + .setSmallIcon(android.R.drawable.stat_notify_chat) + .setCategory(NotificationCompat.CATEGORY_CALL) + .setPriority(NotificationCompat.PRIORITY_MAX) + // Ongoing: a ringing call is not something to swipe away by + // accident while reaching for the torch. + .setOngoing(true) + .setAutoCancel(false) + .setFullScreenIntent(full, true) + .setStyle(NotificationCompat.CallStyle.forIncomingCall(caller, decline, full)) + .build() runCatching { - androidx.core.app.NotificationManagerCompat.from(this).notify(3, notice) + androidx.core.app.NotificationManagerCompat + .from(this) + .notify(3, notice) } } /** Stop ringing, the call was declined, or it ended on its own. */ private fun clearCallNotification() { - runCatching { androidx.core.app.NotificationManagerCompat.from(this).cancel(3) } + runCatching { + androidx.core.app.NotificationManagerCompat + .from(this) + .cancel(3) + } } /** * Stop ringing for an offer nobody answered in time. - * - * `PendingIncomingCall.take()` already refuses to hand over an offer - * older than the dial timeout, so the offer itself expires correctly, but - * the notification is a separate object and expires by nothing at all. - * Left alone it sits on the lock screen offering to answer a dead call. */ private fun expireStaleRing() { if (PendingIncomingCall.isStale()) { @@ -351,22 +335,29 @@ class MessengerService : Service() { } } - private fun notify(channel: String, id: Int, text: String, priority: Int, - open: PendingIntent, category: String? = null, number: Int = 0) { - val n = NotificationCompat.Builder(this, channel) - .setContentTitle("Flamenet") - .setContentText(text) - .setSmallIcon(android.R.drawable.stat_notify_chat) - .setPriority(priority) - .setAutoCancel(true) - .setContentIntent(open) - .also { b -> category?.let { b.setCategory(it) } } - // The launcher badge. A dot on every launcher; the count itself on - // launchers that show one. Same privacy rule as the text: a number, - // never a name or a preview. - .also { b -> if (number > 0) b.setNumber(number) } - .setBadgeIconType(NotificationCompat.BADGE_ICON_SMALL) - .build() + private fun notify( + channel: String, + id: Int, + text: String, + priority: Int, + open: PendingIntent, + category: String? = null, + number: Int = 0, + ) { + val n = + NotificationCompat + .Builder(this, channel) + .setContentTitle("Flamenet") + .setContentText(text) + .setSmallIcon(android.R.drawable.stat_notify_chat) + .setPriority(priority) + .setAutoCancel(true) + .setContentIntent(open) + .also { b -> category?.let { b.setCategory(it) } } + // The launcher badge. + .also { b -> if (number > 0) b.setNumber(number) } + .setBadgeIconType(NotificationCompat.BADGE_ICON_SMALL) + .build() runCatching { (getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager) .notify(id, n) @@ -376,20 +367,32 @@ class MessengerService : Service() { private fun createChannels() { val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager manager.createNotificationChannel( - NotificationChannel(CHANNEL_ONGOING, "Receiving", - NotificationManager.IMPORTANCE_MIN).apply { + NotificationChannel( + CHANNEL_ONGOING, + "Receiving", + NotificationManager.IMPORTANCE_MIN, + ).apply { description = "The notice Android requires while Flamenet is receiving." - // The always-on service notice must not put a dot on the icon: + // The always-on service notice must not put a dot on the icon. // a permanent badge reads as "you have unread mail", for ever. setShowBadge(false) - }) + }, + ) manager.createNotificationChannel( - NotificationChannel(CHANNEL_MESSAGES, "Messages", - NotificationManager.IMPORTANCE_HIGH).apply { setShowBadge(true) }) + NotificationChannel( + CHANNEL_MESSAGES, + "Messages", + NotificationManager.IMPORTANCE_HIGH, + ).apply { setShowBadge(true) }, + ) manager.createNotificationChannel( - NotificationChannel(CHANNEL_CALLS, "Calls", - NotificationManager.IMPORTANCE_HIGH).apply { + NotificationChannel( + CHANNEL_CALLS, + "Calls", + NotificationManager.IMPORTANCE_HIGH, + ).apply { setBypassDnd(false) - }) + }, + ) } } diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt index a16a274..ad745ff 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt @@ -3,62 +3,52 @@ package io.flamenet.messenger.app import android.app.Application import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope +import io.flamenet.client.AccountIdentity +import io.flamenet.client.AccountsClient import io.flamenet.client.ChainRpc +import io.flamenet.client.DeviceClock import io.flamenet.client.IdentityChangedException +import io.flamenet.client.IdentityTrust import io.flamenet.client.KeyLogForkException import io.flamenet.client.KeyNotLoggedException -import io.flamenet.client.DeviceClock -import io.flamenet.client.AccountsClient -import io.flamenet.client.RelayClient -import io.flamenet.client.RelayAccounts +import io.flamenet.client.LocalVault import io.flamenet.client.MessengerApi +import io.flamenet.client.RelayAccounts +import io.flamenet.client.RelayClient import io.flamenet.client.RelayException -import io.flamenet.e2e.Content import io.flamenet.e2e.AccountKdf +import io.flamenet.e2e.ContactCard +import io.flamenet.e2e.Content import io.flamenet.e2e.DeviceIdentity import io.flamenet.e2e.Ed25519 -import io.flamenet.client.AccountIdentity -import io.flamenet.e2e.ContactCard import io.flamenet.e2e.FNID +import io.flamenet.e2e.FilesIdentity import io.flamenet.e2e.HistoryExport +import io.flamenet.e2e.Label import io.flamenet.e2e.Vault +import io.flamenet.e2e.X25519 import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withTimeoutOrNull -import io.flamenet.client.IdentityTrust import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull import java.util.UUID -import io.flamenet.e2e.X25519 -import io.flamenet.e2e.Label -import io.flamenet.e2e.FilesIdentity -import io.flamenet.client.LocalVault /** * What the app is currently doing, and everything the screens read. - * - * Deliberately one state object rather than a scattering of flags. The iOS - * client's equivalent grew a `isLoading` next to an `error` next to a `phase`, - * and combinations that should be impossible, loading *and* signed out *and* - * showing an error, became reachable. */ private const val CALL_PRIVACY_ACKNOWLEDGED = "call.privacy.acknowledged" -class MessengerViewModel(app: Application) : AndroidViewModel(app) { - +class MessengerViewModel( + app: Application, +) : AndroidViewModel(app) { /** Photo quality and link-preview preferences. */ private val mediaSettings = MediaSettings(app) /** * The bridge, if this phone has one. - * - * Applied in the field initialiser, not in `init`. `restore()` runs - * from `init` and reaches the network almost immediately; on a network that - * blocks the relay, a first request that went out directly is a failed - * launch rather than a slow one. A field initialiser runs before the `init` - * block, which is early enough. */ private val obfs4Settings = Obfs4Settings(app).also { it.applyAtStartup() } @@ -66,17 +56,15 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { /** Reading the vault. Brief, but it decides which screen is first. */ data object Starting : Phase - data class SignedOut(val busy: Boolean = false, val error: String? = null) : Phase + data class SignedOut( + val busy: Boolean = false, + val error: String? = null, + ) : Phase data class SignedIn( val handle: String, /** * This account's own id. - * - * Needed because you are never in your own buddy list, so - * every "look the name up among the buddies" fallback rendered you - * as `user 31`, visible on the group row, which listed its members - * as "user 31, fnreviewbeta". */ val userId: Long, val deviceId: String, @@ -86,94 +74,44 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { /** What the Files screen draws. Null until it is opened. */ val files: FilesState? = null, val status: String, - /** * This account's global address, `fnid:...`, or null if it could * not be derived. - * - * Derived on this device, never issued. It is the public - * half of a key stretched from the passphrase, which is what lets - * it survive a relay disappearing, and what makes it the only - * thing somebody on another server can address. Null is a real - * state worth rendering honestly rather than hiding: without it - * this phone cannot be reached across a relay boundary at all. */ val fnid: String? = null, val buddies: List = emptyList(), - /** * Whether the contact list was actually FETCHED, as opposed to * defaulted to empty because the fetch failed. - * - * Both load sites swallow failures with - * `runCatching { … }.getOrDefault(emptyList())`, so a network - * hiccup, an expired token or a rate limit all produced an empty - * roster, and the UI then told the user "Nobody here yet", which - * is a claim about their account rather than about the request. - * The same swallow-and-look-empty shape hid a decode bug that made - * groups invisible on iOS for far longer. */ val buddiesLoaded: Boolean = false, /** * Thread ids currently snoozed. - * - * Recomputed rather than stored as a flag: a snooze expires by - * the clock passing, and nothing runs at that moment. A cached - * boolean would leave a conversation muted until something happened - * to refresh it. */ val snoozedThreads: Set = emptySet(), - /** * Conversations with people on other relays, most recent last. - * - * Without this a federated conversation is write-only. A - * remote peer is not in the roster, they have no account here and - * no user id, so a reply that arrives lands in a thread with - * nothing on any screen pointing at it. The address had to be - * retyped to get back in, which for a 52-character derived key is - * the same as unreachable. */ val remoteAddresses: List = emptyList(), - /** * The relays that hold mail for this account (§20.4). - * - * Your address survives a relay disappearing; your mailbox - * does not. Mail sitting at a relay that goes away is gone, and a - * contact holding only that relay's name has nowhere to write. A - * second home fixes both, and only if it was added first, because - * afterwards there is nothing left to publish a new record from. - * - * Empty means one mailbox, at the relay signed in to, which is the - * ordinary state and not a failure. */ val mailboxes: List = emptyList(), /** Mailboxes this device holds a credential for and can actually read. */ val readableMailboxes: Set = emptySet(), - /** Same question for the group list. See [buddiesLoaded]. */ val groupsLoaded: Boolean = false, val groups: List = emptyList(), val blocked: Set = emptySet(), val notifications: List = emptyList(), - /** * Profile pictures we hold, by user id, plus our own. - * - * References, not bytes. An avatar is an encrypted blob on - * the relay; these say where it is and how to open it. The bytes - * go through the same cache every other attachment uses, so a - * contact list does not hold a decrypted face per row. */ val avatars: Map = emptyMap(), - /** Last line per thread, so a row can say more than a name. */ val previews: Map = emptyMap(), - /** Calls placed and received, newest first. */ val calls: List = emptyList(), - /** Who may see this account's stories. */ val storyAudience: Session.StoryAudience = Session.StoryAudience(), val myAvatar: io.flamenet.e2e.Attachment.Ref? = null, @@ -190,9 +128,6 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { val callPrivacyPrompt: Long? = null, /** * A picked story image awaiting its caption and a deliberate Post. - * Picking used to post INSTANTLY, no preview, no caption, no way - * back from a fumbled tap, while iOS composed. One field at the - * shell level, so whichever screen picked it, the same sheet shows. */ val storyDraft: StoryDraft? = null, /** The relay's device roster for THIS account, the Devices card. */ @@ -209,20 +144,13 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { val requireKeyLog: Boolean = false, /** * The relay was caught serving two different logs. - * - * Sticky once set, and deliberately not clearable from the UI: a - * relay that forked does not become trustworthy because the next - * request looked fine, and a dismissible warning is one people - * dismiss. */ val forkDetected: Boolean = false, ) : Phase /** - * STORIES is new, and it is a tab rather than a strip because a - * feed needs a screen. The horizontal row on Messages stays as the - * entry point, that is where you see there IS something, but a - * card feed cannot live above a contact list without pushing it off. + * STORIES is new, and it is a tab rather than a strip because a feed + * needs a screen. */ enum class Screen { CONTACTS, MESSAGES, CALLS, STORIES, NOTIFICATIONS, ACCOUNT, WALLET, FILES } @@ -240,20 +168,12 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { /** * True when this relay has no TURN server, so a call that cannot * find a direct path will simply fail. - * - * Stated in the UI rather than left as a mystery: "calling did not - * connect" with no reason is the kind of failure people blame on - * the app for ever. */ val relayless: Boolean = false, ) /** * An open conversation. - * - * One type for both kinds: a thread is a thread, and the only - * difference is whether it is addressed to a person or a group. Two - * types would mean two of everything downstream. */ data class Chat( val threadId: String, @@ -264,31 +184,14 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { /** * `fnid@relay` when this conversation is with somebody on another * relay, null when it is not. - * - * A third case beside [peerId] and [group], not a variant of the - * first. A remote peer's user id belongs to their relay and means - * nothing here, so every path that dispatches on `peerId`, sending, - * calling, the safety number, the disappearing timer, has to see - * `null` and take the address branch instead of quietly using a row - * number that points at somebody else. */ val address: String? = null, /** * The message pinned in this conversation (§6.19), or null. - * - * An id, resolved against [lines] at draw time. Holding a copy - * of the message here would go stale the moment it was edited and - * would outlive it being deleted, a banner quoting words that are - * no longer in the thread. */ val pinnedMessageId: String? = null, /** * What we believe about the other side's identity keys (§19). - * - * Meaningless for a group and left [IdentityTrust.UNKNOWN] - * there: a group has many identities and one badge cannot speak - * for all of them. Showing a reassuring tick that covers some - * unknown subset of the members would be worse than showing none. */ /** Seconds elapsed while recording a voice note, or null when not. */ val recordingSeconds: Double? = null, @@ -297,11 +200,6 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { val safetyNumber: String? = null, /** * This conversation's disappearing-message timer, seconds, 0 = off. - * - * Shown on the thread rather than buried in a menu. A timer the - * user cannot see is one they cannot trust, and both sides of the - * conversation are deleting on it, so it is a fact about the room, - * not a preference. */ val disappearSeconds: Int = 0, /** When a reply reminder is set for this thread, epoch millis; 0 = none. */ @@ -312,14 +210,10 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { /** * The vault exists and will not open. - * - * Its own state, never folded into [SignedOut]. Treating it as - * signed-out sends the user to sign in, which mints a new device - * identity, and every contact then sees "security keys changed", the - * warning that is supposed to mean an attack. This screen has to say - * what happened and make the user choose. */ - data class VaultUnreadable(val detail: String) : Phase + data class VaultUnreadable( + val detail: String, + ) : Phase } private val _phase = MutableStateFlow(Phase.Starting) @@ -341,40 +235,34 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { /** * Setting the relay kicks off discovery. - * - * Debounced, and the result is discarded if the address changed while - * the request was in flight, otherwise a slow answer for a half-typed - * hostname arrives late and relabels a different server. */ fun setRelay(value: String) { _relayInput.value = value _site.value = SiteConfig.from(value) describeJob?.cancel() - describeJob = viewModelScope.launch { - kotlinx.coroutines.delay(400) - val config = SiteConfig.from(value) ?: return@launch - // Bounded. `describe` has no deadline of its own, and a - // host that accepts a connection and never answers would leave the - // sign-up form on "Checking what this server uses…" indefinitely. - // Fifteen seconds is far longer than any healthy relay needs. - val found = withTimeoutOrNull(RELAY_PROBE_TIMEOUT_MS) { - withContext(Dispatchers.IO) { - io.flamenet.client.RelayDiscovery.describe(config.baseUrl) + describeJob = + viewModelScope.launch { + kotlinx.coroutines.delay(400) + val config = SiteConfig.from(value) ?: return@launch + // Bounded. + val found = + withTimeoutOrNull(RELAY_PROBE_TIMEOUT_MS) { + withContext(Dispatchers.IO) { + io.flamenet.client.RelayDiscovery + .describe(config.baseUrl) + } + } + if (_relayInput.value != value) return@launch + if (found == null) { + // Say so rather than leaving the spinner-in-words up. + _site.value = config.copy(probeFailed = true) + return@launch } + // copy(), not a mutation: SiteConfig is a data class and the flow + // conflates on equality, so an in-place write is indistinguishable + // from the value already there and never reaches the UI. + _site.value = config.copy(discovered = found) } - if (_relayInput.value != value) return@launch - if (found == null) { - // Say so rather than leaving the spinner-in-words up. The - // commonest cause is an http:// address, which Android refuses - // outright, so the message names it. - _site.value = config.copy(probeFailed = true) - return@launch - } - // copy(), not a mutation: SiteConfig is a data class and the flow - // conflates on equality, so an in-place write is indistinguishable - // from the value already there and never reaches the UI. - _site.value = config.copy(discovered = found) - } } init { @@ -383,129 +271,109 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { } /** Open the vault. A device already registered here goes straight in. */ - private fun restore() = viewModelScope.launch { - val opened = withContext(Dispatchers.IO) { - runCatching { - val v = KeystoreVault.open(getApplication()) - vault = v - val stored = v.loadIdentity() - val credential = v.loadCredential() + private fun restore() = + viewModelScope.launch { + val opened = + withContext(Dispatchers.IO) { + runCatching { + val v = KeystoreVault.open(getApplication()) + vault = v + val stored = v.loadIdentity() + val credential = v.loadCredential() - // Both or neither. A device with no credential cannot reach the - // relay, and a credential with no device cannot be addressed, - // either half alone is a sign-in, not a restore. - if (stored == null || credential == null) { - stored to null - } else { - _relayInput.value = SiteConfig(credential.relayBaseUrl).host - val relay = RelayClient( - credential.relayBaseUrl, credential.username, credential.appPassword) - relay.useRelayAuth(stored) - session = Session( - handle = credential.username, - userId = credential.userId, - identity = stored, - vault = v, - relay = relay, - api = MessengerApi( - credential.relayBaseUrl, credential.username, credential.appPassword), - ) - session?.let { wireSealingPolicy(it) } - val fetchedBuddies = runCatching { session!!.roster() } - val fetchedGroups = runCatching { session!!.namedGroups() } - stored to Phase.SignedIn( - handle = credential.username, - userId = credential.userId, - deviceId = stored.deviceId, - relay = SiteConfig(credential.relayBaseUrl).host, - // From the vault, because a restore has no passphrase - // to derive it from. Without this the Account screen - // said "this device could not derive your address" - // after every relaunch, which was true and useless. - fnid = credential.fnid, - status = "Signed in on this device.", - buddies = fetchedBuddies.getOrDefault(emptyList()), - buddiesLoaded = fetchedBuddies.isSuccess, - avatars = session?.avatars( - fetchedBuddies.getOrDefault(emptyList()).map { b -> b.userId } - ) ?: emptyMap(), - previews = session?.previews( - fetchedBuddies.getOrDefault(emptyList()).map { b -> b.userId } - ) ?: emptyMap(), - calls = session?.callLog() ?: emptyList(), - storyAudience = session?.storyAudience() ?: Session.StoryAudience(), - myAvatar = session?.myAvatar(), - groups = fetchedGroups.getOrDefault(emptyList()), - groupsLoaded = fetchedGroups.isSuccess, - ) + // Both or neither. + if (stored == null || credential == null) { + stored to null + } else { + _relayInput.value = SiteConfig(credential.relayBaseUrl).host + val relay = + RelayClient(credential.relayBaseUrl, credential.username, credential.appPassword) + relay.useRelayAuth(stored) + session = + Session( + handle = credential.username, + userId = credential.userId, + identity = stored, + vault = v, + relay = relay, + api = + MessengerApi(credential.relayBaseUrl, credential.username, credential.appPassword), + ) + session?.let { wireSealingPolicy(it) } + val fetchedBuddies = runCatching { session!!.roster() } + val fetchedGroups = runCatching { session!!.namedGroups() } + stored to + Phase.SignedIn( + handle = credential.username, + userId = credential.userId, + deviceId = stored.deviceId, + relay = SiteConfig(credential.relayBaseUrl).host, + // From the vault, because a restore has no passphrase + // to derive it from. + fnid = credential.fnid, + status = "Signed in on this device.", + buddies = fetchedBuddies.getOrDefault(emptyList()), + buddiesLoaded = fetchedBuddies.isSuccess, + avatars = + session?.avatars( + fetchedBuddies.getOrDefault(emptyList()).map { b -> b.userId }, + ) ?: emptyMap(), + previews = + session?.previews( + fetchedBuddies.getOrDefault(emptyList()).map { b -> b.userId }, + ) ?: emptyMap(), + calls = session?.callLog() ?: emptyList(), + storyAudience = session?.storyAudience() ?: Session.StoryAudience(), + myAvatar = session?.myAvatar(), + groups = fetchedGroups.getOrDefault(emptyList()), + groupsLoaded = fetchedGroups.isSuccess, + ) + } + } } - } + opened.fold( + onSuccess = { restored -> + identity = restored.first + _phase.value = restored.second ?: Phase.SignedOut() + if (restored.second != null) { + startPolling() + republishIfMissingFromTheLog() + } + }, + onFailure = { failure -> + _phase.value = + Phase.VaultUnreadable(failure.message ?: failure::class.simpleName.orEmpty()) + }, + ) } - opened.fold( - onSuccess = { restored -> - identity = restored.first - _phase.value = restored.second ?: Phase.SignedOut() - if (restored.second != null) { - startPolling() - republishIfMissingFromTheLog() - } - }, - onFailure = { failure -> - _phase.value = Phase.VaultUnreadable( - failure.message ?: failure::class.simpleName.orEmpty()) - }, - ) - } /** - * Put this device back in the transparency log if it is not in it (android#6). - * - * A device registers exactly once, at sign-in, and never again, so a - * key-log append that did not land is permanent. `signIn` calls - * `registerDevice`; `restore` did not. If the append failed for any reason - * a transient write, a relay mid-restart, the device stays registered - * with no leaf, and every peer that enforces the log then refuses to send - * to it *forever*, with an error that reads as an accusation: - * "the keys offered by this contact are not in the public log." - * - * Relaunching did not help, which is the cruel part: the one action a - * person would try was the one that could not fix it. - * - * Checks first, republishes only if the leaf is absent. Registering - * unconditionally on every launch would publish twenty fresh prekeys each - * time and churn the log for nothing, iOS's `registerDeviceIfNeeded` tops - * up only when low for the same reason. - * - * Silent on failure, deliberately. This is a repair for a state almost - * nobody is in; a relay too old for `/e2e/log/device` or simply down must - * not produce an error about a problem the person does not have. + * Put this device back in the transparency log if it is not in it + * (android#6). */ - private fun republishIfMissingFromTheLog() = viewModelScope.launch { - withContext(Dispatchers.IO) { - runCatching { - val device = identity ?: return@runCatching - val relay = session?.relay ?: return@runCatching - val entries = relay.logDeviceEntries(device.deviceId) - .optJSONArray("entries") - if (entries != null && entries.length() > 0) return@runCatching + private fun republishIfMissingFromTheLog() = + viewModelScope.launch { + withContext(Dispatchers.IO) { + runCatching { + val device = identity ?: return@runCatching + val relay = session?.relay ?: return@runCatching + val entries = + relay + .logDeviceEntries(device.deviceId) + .optJSONArray("entries") + if (entries != null && entries.length() > 0) return@runCatching - relay.registerDevice( - device, - device.generateOneTimePreKeys(20), - device.generatePQOneTimePreKeys(20), - ) + relay.registerDevice( + device, + device.generateOneTimePreKeys(20), + device.generatePQOneTimePreKeys(20), + ) + } } } - } /** * Forget whatever the last sign-in attempt said. - * - * Switching between signing in and creating an account has to do - * this. The message belongs to the mode it was produced in, "that - * handle is already taken" is advice about signing up, and it stayed on - * screen after the person took that advice and switched to signing in, - * where it reads as a refusal of the thing they are now trying to do. */ fun clearAuthError() { val current = _phase.value @@ -514,39 +382,32 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { } } - fun signIn(handle: String, passphrase: String, register: Boolean, email: String = "") { - val config = site ?: run { - _phase.value = Phase.SignedOut(error = "That doesn't look like a server address.") - return - } + fun signIn( + handle: String, + passphrase: String, + register: Boolean, + email: String = "", + ) { + val config = + site ?: run { + _phase.value = Phase.SignedOut(error = "That doesn't look like a server address.") + return + } _phase.value = Phase.SignedOut(busy = true) viewModelScope.launch { // A sign-in that cannot finish has to SAY so (android#19). - // The failure path below was always here and always correct, it - // reports anything `authenticate` throws. What it could not report - // was a call that never returns: observed 2026-09-05, four minutes - // on an unchanged screen, no error, no spinner that resolves, with - // the requests demonstrably going out. Nothing was thrown, so - // nothing was shown. - // - // A person reads that as the app being broken, and they are not - // wrong. Whatever stalls underneath, a socket with no read - // timeout, a service that accepts and never answers, the screen - // owes them a sentence. - // - // Sixty seconds, not ten. Sign-in derives a key from the - // passphrase and then talks to two services, and an emulator or an - // old handset is legitimately slow at the first part. This is a - // backstop for "never", not a latency budget. - val result = withTimeoutOrNull(SIGN_IN_TIMEOUT_MS) { - withContext(Dispatchers.IO) { - runCatching { authenticate(config, handle, passphrase, register, email) } - } - } ?: Result.failure( - IllegalStateException( - "The server did not answer. It may be down, or the address may be " - + "wrong, check it and try again.")) + val result = + withTimeoutOrNull(SIGN_IN_TIMEOUT_MS) { + withContext(Dispatchers.IO) { + runCatching { authenticate(config, handle, passphrase, register, email) } + } + } ?: Result.failure( + IllegalStateException( + "The server did not answer. It may be down, or the address may be " + + "wrong, check it and try again.", + ), + ) result.fold( onSuccess = { @@ -560,9 +421,6 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { /** * Sign in, then make sure this phone has a device published. - * - * The identity is minted once and reused. Registering a fresh one on - * every sign-in is the failure mode this whole vault exists to prevent. */ private fun authenticate( config: SiteConfig, @@ -573,134 +431,99 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { ): Phase { val relayAccounts = RelayAccounts(config.baseUrl) // Set by the shared branch below, which gets it free from the stretch - // it already runs. Left null for a relay-local account, which derives - // its own further down. + // it already runs. var sharedSeed: ByteArray? = null - val account = when (config.accountProvider) { - SiteConfig.AccountProvider.SHARED -> { - // The passphrase is stretched on this device and only the - // derived secret reaches the account service. The token that - // comes back is then exchanged for a relay session, the relay - // learns a product-scoped pseudonym and not the handle. - val accounts = AccountsClient(config.accountServiceUrl) - if (register) accounts.register(handle, passphrase) - val session = accounts.signIn(handle, passphrase) - sharedSeed = session.accountKeySeed - relayAccounts.sharedSession(session.token, handle) - } - // A relay-local account authenticates against the relay itself, - // which is what "this server keeps its own accounts" means. The - // relay returns a long-lived credential; the password is not kept. - SiteConfig.AccountProvider.LOCAL -> - if (register) { - relayAccounts.signUp(handle, email.ifBlank { "$handle@example.invalid" }, passphrase) - } else { - relayAccounts.signIn(handle, passphrase) + val account = + when (config.accountProvider) { + SiteConfig.AccountProvider.SHARED -> { + // The passphrase is stretched on this device and only the + // derived secret reaches the account service. + val accounts = AccountsClient(config.accountServiceUrl) + if (register) accounts.register(handle, passphrase) + val session = accounts.signIn(handle, passphrase) + sharedSeed = session.accountKeySeed + relayAccounts.sharedSession(session.token, handle) } - } + + // A relay-local account authenticates against the relay itself, + // which is what "this server keeps its own accounts" means. + SiteConfig.AccountProvider.LOCAL -> { + if (register) { + relayAccounts.signUp(handle, email.ifBlank { "$handle@example.invalid" }, passphrase) + } else { + relayAccounts.signIn(handle, passphrase) + } + } + } val credential = account.appPassword // The address, derived here because this is the only moment the - // passphrase exists. Android could encode an address and had no way - // to produce its own, so the Account screen could not show one and - // nobody on another relay could be given one, the federation work was - // iOS-only in practice. A shared account gets the seed back from the - // one stretch sign-in already paid for; a relay-local account has no - // stored salt by design, so the salt is a pure function of the handle. - // - // `runCatching`: an address is worth having and nothing else on this - // screen depends on it. A device too old for Ed25519 from a raw seed - // should still sign in. - val accountKeySeed: ByteArray? = when (config.accountProvider) { - SiteConfig.AccountProvider.SHARED -> sharedSeed - SiteConfig.AccountProvider.LOCAL -> runCatching { - AccountKdf.accountKeySeed( - passphrase, AccountKdf.localAccountSalt(account.username)) - }.getOrNull() - } - val fnid = accountKeySeed?.let { - runCatching { FNID.address(Ed25519.fromSeed(it).publicKey) }.getOrNull() - } + // passphrase exists. + val accountKeySeed: ByteArray? = + when (config.accountProvider) { + SiteConfig.AccountProvider.SHARED -> { + sharedSeed + } + + SiteConfig.AccountProvider.LOCAL -> { + runCatching { + AccountKdf.accountKeySeed(passphrase, AccountKdf.localAccountSalt(account.username)) + }.getOrNull() + } + } + val fnid = + accountKeySeed?.let { + runCatching { FNID.address(Ed25519.fromSeed(it).publicKey) }.getOrNull() + } val vault = vault ?: error("the vault is not open") - // The stored identity FIRST, and this line is why signing out and - // back in used to cost every contact a prompt. Sign-in only ever - // consulted the in-memory field, and sign-out clears it, so it minted - // a fresh identity and overwrote the vault's, making this phone a brand - // new device on every sign-in. Contacts saw a safety-number change, - // sends were refused until they accepted it, and the relay's public key - // log had not yet caught up with the key being offered, so even after - // accepting, the next send failed on "the keys offered by this contact - // are not in the public log". - // - // It was harmless right up until it wasn't: sign-out used to destroy - // the vault, so there was never a stored identity to find and minting - // one was correct. Keeping the device across a plain sign-out is what - // turned this into the bug it always looked like. - val device = identity - ?: vault.loadIdentity()?.also { identity = it } - ?: DeviceIdentity.create().also { - vault.saveIdentity(it) - identity = it - } + // The stored identity FIRST, and this line is why signing out and back + // in used to cost every contact a prompt. + val device = + identity + ?: vault.loadIdentity()?.also { identity = it } + ?: DeviceIdentity.create().also { + vault.saveIdentity(it) + identity = it + } val relay = RelayClient(config.baseUrl, username = account.username, appPassword = credential) relay.useRelayAuth(device) - // Publishing prekeys is what makes this phone reachable. A device that - // registers without them can send and can never be started with. + // Publishing prekeys is what makes this phone reachable. val preKeys = device.generateOneTimePreKeys(20) - // The same size as the classical pool, not a smaller one. Both - // drain at one key per new session, so a shorter post-quantum pool - // empties first and pushes every later session onto the last-resort key - // every one of them then sharing a single KEM key, silently. + // The same size as the classical pool, not a smaller one. val pqPreKeys = device.generatePQOneTimePreKeys(20) relay.registerDevice(device, preKeys, pqPreKeys) vault.saveIdentity(device) - // Register the address, don't just derive it. Deriving it makes - // it ours; claiming is what makes us *reachable* at it. A peer relay - // resolving `fnid@here` asks this relay, and until the claim lands the - // answer is "no such address", so every account had an address it - // could show on a card and nobody could send to. - // - // `runCatching`, like the derivation above: a relay that is down, - // too old to have the route, or simply slow must not keep somebody out - // of their own messages. It is idempotent server-side and retried on - // the next sign-in, so losing one attempt costs one launch. + // Register the address, don't just derive it. if (accountKeySeed != null) { runCatching { AccountIdentity.claim(relay, accountKeySeed, account.userId) } } - vault.saveCredential(Vault.Credential( - relayBaseUrl = config.baseUrl, - username = account.username, - appPassword = credential, - userId = account.userId, - fnid = fnid, - )) - - session = Session( - handle = account.username, - userId = account.userId, - identity = device, - vault = vault, - relay = relay, - api = io.flamenet.client.MessengerApi(config.baseUrl, account.username, credential), + vault.saveCredential( + Vault.Credential( + relayBaseUrl = config.baseUrl, + username = account.username, + appPassword = credential, + userId = account.userId, + fnid = fnid, + ), ) + + session = + Session( + handle = account.username, + userId = account.userId, + identity = device, + vault = vault, + relay = relay, + api = io.flamenet.client.MessengerApi(config.baseUrl, account.username, credential), + ) session?.let { wireSealingPolicy(it) } - // Our return address, before the first federated send. A relay - // that accepts a federated envelope records no sender, and our user id - // means nothing on it, so without this a message crosses the boundary, - // decrypts on their phone, and cannot be replied to. - // - // Through `ContactCard.canonical` rather than string-built: the - // recipient parses it with the same rules, and an address that would - // not survive the trip is better left unset than sealed. Half an - // address is worse than none. - // Held for this run only: it is what signs a mailbox record, and a - // relay must never be able to sign one on the account's behalf. + // Our return address, before the first federated send. this.accountKeySeed = accountKeySeed session!!.conversations.myAddress = fnid?.let { ContactCard.canonical("$it@${config.host}") } @@ -708,11 +531,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { val fetchedBuddies = runCatching { session!!.roster() } val fetchedGroups = runCatching { session!!.namedGroups() } - // Restore the strict key-log requirement the user chose. Without - // this it reverted to off on every launch: the switch was read from a - // field that only ever existed in memory, so somebody who turned it on - // was protected until they next closed the app and was never told it - // had lapsed. + // Restore the strict key-log requirement the user chose. val strictKeyLog = MediaSettings(getApplication()).requireKeyLog session!!.conversations.trust.requireKeyLog = strictKeyLog @@ -723,20 +542,19 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { relay = config.host, fnid = fnid, status = "Registered with ${preKeys.size} one-time prekeys published.", - // The switch has to read what is actually enforced. Restoring - // the trust object without this would leave the toggle showing off - // while sends were being refused, which is the same confusion in - // the opposite direction. + // The switch has to read what is actually enforced. requireKeyLog = strictKeyLog, remoteAddresses = session!!.remoteAddresses(), buddies = fetchedBuddies.getOrDefault(emptyList()), buddiesLoaded = fetchedBuddies.isSuccess, - avatars = session?.avatars( - fetchedBuddies.getOrDefault(emptyList()).map { b -> b.userId } - ) ?: emptyMap(), - previews = session?.previews( - fetchedBuddies.getOrDefault(emptyList()).map { b -> b.userId } - ) ?: emptyMap(), + avatars = + session?.avatars( + fetchedBuddies.getOrDefault(emptyList()).map { b -> b.userId }, + ) ?: emptyMap(), + previews = + session?.previews( + fetchedBuddies.getOrDefault(emptyList()).map { b -> b.userId }, + ) ?: emptyMap(), calls = session?.callLog() ?: emptyList(), myAvatar = session?.myAvatar(), groups = fetchedGroups.getOrDefault(emptyList()), @@ -744,7 +562,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { ) } - // -- Contacts and conversations ------------------------------------------ + // -- Contacts and conversations ------------------------------------------. private fun signedIn(): Phase.SignedIn? = _phase.value as? Phase.SignedIn @@ -752,67 +570,65 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { signedIn()?.let { _phase.value = block(it) } } - fun refreshBuddies() = viewModelScope.launch { - val current = session ?: return@launch - refreshPresence() - // Snoozes expire by the clock passing and nothing runs at that moment, - // so the set is recomputed whenever the list is drawn rather than - // cached, a stale flag leaves a conversation muted past its deadline. - refreshSnoozed() - val fetched = withContext(Dispatchers.IO) { - runCatching { current.roster() } - // Pin each contact's global address the first time we see them. - // Done here rather than at send time because it needs the - // handle, which the buddy list has and a user id does not, and - // because a lookup on the send path is a lookup that can fail - // while somebody is trying to say something. - .onSuccess { list -> - list.forEach { buddy -> - runCatching { - current.conversations.trust.rememberIdentity(buddy.userId, buddy.handle) + fun refreshBuddies() = + viewModelScope.launch { + val current = session ?: return@launch + refreshPresence() + // Snoozes expire by the clock passing and nothing runs at that moment, + // so the set is recomputed whenever the list is drawn rather than + // cached, a stale flag leaves a conversation muted past its deadline. + refreshSnoozed() + val fetched = + withContext(Dispatchers.IO) { + runCatching { current.roster() } + // Pin each contact's global address the first time we see them. + .onSuccess { list -> + list.forEach { buddy -> + runCatching { + current.conversations.trust.rememberIdentity(buddy.userId, buddy.handle) + } + } } + } + fetched + .onSuccess { list -> update { it.copy(buddies = list, error = null) } } + .onFailure { failure -> update { it.copy(error = describe(failure)) } } + } + + fun addBuddy(username: String) = + viewModelScope.launch { + val current = session ?: return@launch + update { it.copy(busy = true, error = null) } + val added = + withContext(Dispatchers.IO) { + runCatching { + // Off-server roster (P1): the add is this device's to make, the + // relay only resolved the handle, and that lookup was + // transient. + if (current.rosterOffServer()) { + val found = + signedIn()?.lookedUp + ?: throw IllegalStateException("Look the person up first.") + current.rosterAddLocal(found.userId, found.handle, found.displayName) + } else { + current.api.addBuddy(username) + } + current.roster() } } + added + .onSuccess { list -> update { it.copy(busy = false, buddies = list) } } + .onFailure { failure -> update { it.copy(busy = false, error = describe(failure)) } } } - fetched.onSuccess { list -> update { it.copy(buddies = list, error = null) } } - .onFailure { failure -> update { it.copy(error = describe(failure)) } } - } - fun addBuddy(username: String) = viewModelScope.launch { - val current = session ?: return@launch - update { it.copy(busy = true, error = null) } - val added = withContext(Dispatchers.IO) { - runCatching { - // Off-server roster (P1): the add is this device's to make, - // the relay only resolved the handle, and that lookup was - // transient. The resolved profile came from lookupMember. - if (current.rosterOffServer()) { - val found = signedIn()?.lookedUp - ?: throw IllegalStateException("Look the person up first.") - current.rosterAddLocal(found.userId, found.handle, found.displayName) - } else { - current.api.addBuddy(username) - } - current.roster() - } - } - added.onSuccess { list -> update { it.copy(busy = false, buddies = list) } } - .onFailure { failure -> update { it.copy(busy = false, error = describe(failure)) } } - } - - fun openChat(peerId: Long, peerName: String) { + fun openChat( + peerId: Long, + peerName: String, + ) { val current = session ?: return val thread = Vault.threadForUser(peerId) - // Hand them our picture here, not when it was chosen. An avatar - // reaches a contact inside the ratchet, so somebody added AFTER it was - // set would never have been told, they would see initials for ever - // and there would be nothing on any screen to explain why. Opening a - // conversation is the first moment there is certainly a session to - // send down. - // - // Off the main thread and failure-tolerant: this is a courtesy, and - // it must never be the reason a chat does not open. + // Hand them our picture here, not when it was chosen. viewModelScope.launch { withContext(Dispatchers.IO) { runCatching { current.shareAvatarIfNeeded(peerId) } @@ -820,19 +636,20 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { } update { it.copy( - openChat = Phase.Chat( - thread, peerName, current.history(thread), peerId = peerId, - trust = current.conversations.trust.state(peerId), - safetyNumber = current.conversations.trust.safetyNumber(peerId), - disappearSeconds = current.conversations.disappearSeconds(peerId), - replyReminderAt = current.replyReminder(peerId), - scheduled = current.scheduledFor(peerId), - pinnedMessageId = current.pinnedMessage(thread), - ), - // Opening the thread is what marks it read. Nothing is sent to - // the relay for this, read state is local, because a read - // receipt is a disclosure and this product does not make it by - // default. + openChat = + Phase.Chat( + thread, + peerName, + current.history(thread), + peerId = peerId, + trust = current.conversations.trust.state(peerId), + safetyNumber = current.conversations.trust.safetyNumber(peerId), + disappearSeconds = current.conversations.disappearSeconds(peerId), + replyReminderAt = current.replyReminder(peerId), + scheduled = current.scheduledFor(peerId), + pinnedMessageId = current.pinnedMessage(thread), + ), + // Opening the thread is what marks it read. unread = it.unread - thread, ) } @@ -840,24 +657,20 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { /** * Open a conversation with somebody on another relay. - * - * Nothing is fetched here, and nothing is verified. A remote thread - * exists as soon as an address has been typed, exactly as a local one - * exists before the first message, the address is checked when a message - * is actually sent, where a refusal has something to say to the person who - * caused it. Reaching across a relay boundary to validate a paste would - * also tell that relay somebody was thinking about one of its users. - * - * A malformed address opens nothing rather than opening a thread that can - * never send. `ContactCard.decode` is the one place that decides. */ fun openAddressChat(text: String) { val current = session ?: return - val card = ContactCard.decode(text) ?: run { - update { it.copy(error = "That is not an address. It looks like " + - "fnid:… followed by @ and the relay's hostname.") } - return - } + val card = + ContactCard.decode(text) ?: run { + update { + it.copy( + error = + "That is not an address. It looks like " + + "fnid:… followed by @ and the relay's hostname.", + ) + } + return + } if (card.address == current.conversations.myAddress) { update { it.copy(error = "That is your own address.") } return @@ -865,72 +678,67 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { val thread = Vault.threadForAddress(card.address) update { it.copy( - openChat = Phase.Chat( - thread, card.address, current.history(thread), address = card.address, - pinnedMessageId = current.pinnedMessage(thread)), + openChat = + Phase.Chat( + thread, + card.address, + current.history(thread), + address = card.address, + pinnedMessageId = current.pinnedMessage(thread), + ), unread = it.unread - thread, error = null, ) } } - // -- Mailboxes (§20.4) --------------------------------------------------- + // -- Mailboxes (§20.4) ---------------------------------------------------. /** * The passphrase, held only while a mailbox is being added. - * - * Not stored, and cleared the moment the attempt finishes. It is needed - * because signing in at another relay is a real sign-in there, and it is - * asked for on that screen rather than kept from the original one, - * a passphrase held in memory for the life of the app is a copy of the - * account's secret with no expiry. */ private fun homingFor(current: Session): io.flamenet.client.MailboxHoming? { val relay = signedIn()?.relay ?: return null - return io.flamenet.client.MailboxHoming( - current.vault, current.identity, relay, current.relay) + return io.flamenet.client.MailboxHoming(current.vault, current.identity, relay, current.relay) } - fun loadMailboxes() = viewModelScope.launch { - val current = session ?: return@launch - val fnid = signedIn()?.fnid ?: return@launch - val published = withContext(Dispatchers.IO) { - // Verified, never displayed as served. A relay handing back a - // shortened list is the exact failure multi-homing exists to - // survive, and showing it unchecked would make this screen a place - // that quietly confirms the attack. - runCatching { - val record = current.relay.homes(fnid) ?: return@runCatching null - // And not older than one already seen. An old record is - // signed, published before a second home was added, - // so a relay wanting to be somebody's only mailbox does not - // have to forge anything; it just serves the record it saw - // first. The signature check alone accepts that, which is what - // the serial exists to stop. - val floor = current.vault.homeSerial(fnid) - val relays = io.flamenet.client.AccountIdentity - .verifiedRelays(record, floor = floor) - // Advanced only after the record was ACCEPTED. Recording the - // serial of a record that failed verification would let a - // hostile relay raise the floor with a forgery and lock out - // every later record. - io.flamenet.client.AccountIdentity.verifiedSerial(record) - ?.let { if (relays != null) current.vault.saveHomeSerial(fnid, it) } - relays - }.getOrNull() + fun loadMailboxes() = + viewModelScope.launch { + val current = session ?: return@launch + val fnid = signedIn()?.fnid ?: return@launch + val published = + withContext(Dispatchers.IO) { + // Verified, never displayed as served. + runCatching { + val record = current.relay.homes(fnid) ?: return@runCatching null + // And not older than one already seen. + val floor = current.vault.homeSerial(fnid) + val relays = + io.flamenet.client.AccountIdentity + .verifiedRelays(record, floor = floor) + // Advanced only after the record was ACCEPTED. + io.flamenet.client.AccountIdentity + .verifiedSerial(record) + ?.let { if (relays != null) current.vault.saveHomeSerial(fnid, it) } + relays + }.getOrNull() + } + val readable = + withContext(Dispatchers.IO) { + runCatching { homingFor(current)?.known() ?: emptyList() }.getOrDefault(emptyList()) + } + update { + it.copy( + mailboxes = published ?: emptyList(), + readableMailboxes = readable.toSet(), + ) + } } - val readable = withContext(Dispatchers.IO) { - runCatching { homingFor(current)?.known() ?: emptyList() }.getOrDefault(emptyList()) - } - update { - it.copy( - mailboxes = published ?: emptyList(), - readableMailboxes = readable.toSet(), - ) - } - } - fun addMailbox(relay: String, passphrase: String) = viewModelScope.launch { + fun addMailbox( + relay: String, + passphrase: String, + ) = viewModelScope.launch { val current = session ?: return@launch val state = signedIn() ?: return@launch val fnid = state.fnid @@ -938,50 +746,54 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { if (fnid == null || seed == null) { // The account key exists only in the session that stretched the // passphrase, so a restored one has none, and there is nothing to - // sign a mailbox record with. Said plainly rather than failing at - // the relay with something nobody can act on. - update { it.copy(error = - "Adding a mailbox needs the passphrase you signed in with. " + - "Sign out and back in, then try again.") } + // sign a mailbox record with. + update { + it.copy( + error = + "Adding a mailbox needs the passphrase you signed in with. " + + "Sign out and back in, then try again.", + ) + } return@launch } update { it.copy(busy = true, error = null) } - val outcome = withContext(Dispatchers.IO) { - runCatching { - homingFor(current)!!.add( - relay, state.handle, passphrase, seed, fnid, state.mailboxes) + val outcome = + withContext(Dispatchers.IO) { + runCatching { + homingFor(current)!!.add(relay, state.handle, passphrase, seed, fnid, state.mailboxes) + } } - } update { it.copy(busy = false, error = outcome.exceptionOrNull()?.let(::describe)) } loadMailboxes() } - fun removeMailbox(relay: String) = viewModelScope.launch { - val current = session ?: return@launch - val state = signedIn() ?: return@launch - val fnid = state.fnid - val seed = accountKeySeed - if (fnid == null || seed == null) { - update { it.copy(error = - "Changing your mailboxes needs the passphrase you signed in with. " + - "Sign out and back in, then try again.") } - return@launch + fun removeMailbox(relay: String) = + viewModelScope.launch { + val current = session ?: return@launch + val state = signedIn() ?: return@launch + val fnid = state.fnid + val seed = accountKeySeed + if (fnid == null || seed == null) { + update { + it.copy( + error = + "Changing your mailboxes needs the passphrase you signed in with. " + + "Sign out and back in, then try again.", + ) + } + return@launch + } + update { it.copy(busy = true, error = null) } + val outcome = + withContext(Dispatchers.IO) { + runCatching { homingFor(current)!!.remove(relay, seed, fnid, state.mailboxes) } + } + update { it.copy(busy = false, error = outcome.exceptionOrNull()?.let(::describe)) } + loadMailboxes() } - update { it.copy(busy = true, error = null) } - val outcome = withContext(Dispatchers.IO) { - runCatching { homingFor(current)!!.remove(relay, seed, fnid, state.mailboxes) } - } - update { it.copy(busy = false, error = outcome.exceptionOrNull()?.let(::describe)) } - loadMailboxes() - } /** * The account key seed, for as long as this app is running. - * - * In memory only. It is derivable from the passphrase at any time, so - * persisting it would add a stealable copy of a secret without adding any - * capability, and it is what signs the mailbox record, which is the one - * thing a relay must never be able to do on the account's behalf. */ @Volatile private var accountKeySeed: ByteArray? = null @@ -990,29 +802,27 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { val thread = Vault.threadForGroup(group.id) update { it.copy( - openChat = Phase.Chat(thread, group.name, current.history(thread), group = group, - pinnedMessageId = current.pinnedMessage(thread)), + openChat = + Phase.Chat( + thread, + group.name, + current.history(thread), + group = group, + pinnedMessageId = current.pinnedMessage(thread), + ), unread = it.unread - thread, ) } } - // -- Identity trust (§19) ------------------------------------------------ + // -- Identity trust (§19) ------------------------------------------------. /** * Record that two people compared safety numbers out of band. - * - * There is no way to do this "in the app" and there should not be. If a - * channel existed for the two devices to confirm each other through the - * relay, the relay could answer for both, which is exactly the party the - * comparison is meant to exclude. */ + /** * Bridge lines contacts have offered, with a name against each (§6.19). - * - * Resolved to a display name here rather than stored with one. A name - * changes; the offer is keyed on the account it came from, so a contact who - * renames themselves does not turn into a second, anonymous offer. */ data class BridgeOfferView( val fromUser: Long, @@ -1023,17 +833,19 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { fun bridgeOffers(): List { val current = session ?: return emptyList() - val buddies = (_phase.value as? Phase.SignedIn)?.buddies.orEmpty() - .associateBy { it.userId } + val buddies = + (_phase.value as? Phase.SignedIn) + ?.buddies + .orEmpty() + .associateBy { it.userId } return current.conversations.bridgeOffers().map { offer -> BridgeOfferView( fromUser = offer.fromUser, - // Falls back to the id rather than to "someone". A row you - // cannot attribute is one you cannot make a decision about, and - // an id is at least checkable against the conversation list. - fromName = buddies[offer.fromUser]?.displayName?.takeIf { it.isNotBlank() } - ?: buddies[offer.fromUser]?.handle - ?: "account ${offer.fromUser}", + // Falls back to the id rather than to "someone". + fromName = + buddies[offer.fromUser]?.displayName?.takeIf { it.isNotBlank() } + ?: buddies[offer.fromUser]?.handle + ?: "account ${offer.fromUser}", line = offer.line, at = offer.at, ) @@ -1041,74 +853,68 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { } /** Forget an offer. Called whether it was accepted or turned down. */ - fun dismissBridgeOffer(fromUser: Long) = viewModelScope.launch { - val current = session ?: return@launch - withContext(Dispatchers.IO) { current.conversations.dismissBridgeOffer(fromUser) } - } + fun dismissBridgeOffer(fromUser: Long) = + viewModelScope.launch { + val current = session ?: return@launch + withContext(Dispatchers.IO) { current.conversations.dismissBridgeOffer(fromUser) } + } /** * Hand a contact the bridge this phone is using. - * - * Refuses when nothing is configured rather than sending an empty line. - * "Share" on a screen with no bridge set is a button that should not have - * been enabled, and sending blankly would put an un-actionable row on - * somebody else's settings screen. */ - fun shareBridge(toUserId: Long) = viewModelScope.launch { - val current = session ?: return@launch - val line = Obfs4Settings(getApplication()).bridgeLine ?: return@launch - withContext(Dispatchers.IO) { current.conversations.shareBridge(toUserId, line) } - } + fun shareBridge(toUserId: Long) = + viewModelScope.launch { + val current = session ?: return@launch + val line = Obfs4Settings(getApplication()).bridgeLine ?: return@launch + withContext(Dispatchers.IO) { current.conversations.shareBridge(toUserId, line) } + } - fun setVerified(peerId: Long, verified: Boolean) = viewModelScope.launch { + fun setVerified( + peerId: Long, + verified: Boolean, + ) = viewModelScope.launch { val current = session ?: return@launch withContext(Dispatchers.IO) { current.conversations.trust.setVerified(peerId, verified) } - update { - it.copy(openChat = it.openChat?.takeIf { c -> c.peerId == peerId } - ?.copy(trust = current.conversations.trust.state(peerId)) ?: it.openChat) - } - } - - /** - * Accept that someone's identity really did change. - * - * Re-pins what the relay now serves and drops the verified tick, because - * the previous comparison was about a key nobody uses any more. - */ - fun acceptIdentityChange(peerId: Long) = viewModelScope.launch { - val current = session ?: return@launch - // The handle, so the address is re-resolved rather than kept. If what - // changed was the address, keeping the old one would leave every later - // check recomputing against a name this account no longer publishes - // under, and honest keys would stay refused for ever. - val handle = signedIn()?.buddies?.firstOrNull { it.userId == peerId }?.handle - val outcome = withContext(Dispatchers.IO) { - runCatching { current.conversations.trust.acceptIdentityChange(peerId, handle) } - } - outcome.onFailure { failure -> update { it.copy(error = describe(failure)) } } update { it.copy( - error = if (outcome.isSuccess) null else it.error, - openChat = it.openChat?.takeIf { c -> c.peerId == peerId }?.copy( - trust = current.conversations.trust.state(peerId), - safetyNumber = current.conversations.trust.safetyNumber(peerId), - ) ?: it.openChat, + openChat = + it.openChat + ?.takeIf { c -> c.peerId == peerId } + ?.copy(trust = current.conversations.trust.state(peerId)) ?: it.openChat, ) } } + /** + * Accept that someone's identity really did change. + */ + fun acceptIdentityChange(peerId: Long) = + viewModelScope.launch { + val current = session ?: return@launch + // The handle, so the address is re-resolved rather than kept. + val handle = signedIn()?.buddies?.firstOrNull { it.userId == peerId }?.handle + val outcome = + withContext(Dispatchers.IO) { + runCatching { current.conversations.trust.acceptIdentityChange(peerId, handle) } + } + outcome.onFailure { failure -> update { it.copy(error = describe(failure)) } } + update { + it.copy( + error = if (outcome.isSuccess) null else it.error, + openChat = + it.openChat?.takeIf { c -> c.peerId == peerId }?.copy( + trust = current.conversations.trust.state(peerId), + safetyNumber = current.conversations.trust.safetyNumber(peerId), + ) ?: it.openChat, + ) + } + } + /** * Turn key-log enforcement on or off. - * - * Off by default, and that is not laziness. A relay older than the - * transparency log has no log to prove anything against, so enforcing it - * everywhere would refuse every peer on those deployments, a worse - * failure than the one being prevented, and one the user cannot fix. - * Pinning is always on regardless; this is the stricter, opt-in half. */ fun setRequireKeyLog(required: Boolean) { - // Written down as well as applied. See MediaSettings.requireKeyLog for - // why: this used to be memory-only and silently lapsed on restart. + // Written down as well as applied. MediaSettings(getApplication()).requireKeyLog = required session?.conversations?.trust?.requireKeyLog = required update { it.copy(requireKeyLog = required) } @@ -1116,99 +922,91 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { /** * Replace the delivery key, cutting off anyone holding the old one. - * - * Blunt on purpose and worth saying so in the UI: every contact loses - * the ability to reach us sealed until the next time we write to them. It - * is the only lever §14.4 provides against a sealed spammer, because there - * is no sender to block. */ - fun rotateDeliveryKey() = viewModelScope.launch { - val current = session ?: return@launch - val outcome = withContext(Dispatchers.IO) { - runCatching { current.conversations.rotateDeliveryKey() } + fun rotateDeliveryKey() = + viewModelScope.launch { + val current = session ?: return@launch + val outcome = + withContext(Dispatchers.IO) { + runCatching { current.conversations.rotateDeliveryKey() } + } + update { + it.copy( + error = + outcome.exceptionOrNull()?.let(::describe) + ?: "Delivery key replaced. Contacts can reach you sealed again " + + "once you next message them.", + ) + } } - update { - it.copy(error = outcome.exceptionOrNull()?.let(::describe) - ?: "Delivery key replaced. Contacts can reach you sealed again " - + "once you next message them.") - } - } - // -- Groups -------------------------------------------------------------- + // -- Groups --------------------------------------------------------------. - fun refreshGroups() = viewModelScope.launch { - val current = session ?: return@launch - val fetched = withContext(Dispatchers.IO) { - runCatching { current.namedGroups() } + fun refreshGroups() = + viewModelScope.launch { + val current = session ?: return@launch + val fetched = + withContext(Dispatchers.IO) { + runCatching { current.namedGroups() } + } + fetched + .onSuccess { list -> update { it.copy(groups = list, error = null) } } + .onFailure { failure -> update { it.copy(error = describe(failure)) } } } - fetched.onSuccess { list -> update { it.copy(groups = list, error = null) } } - .onFailure { failure -> update { it.copy(error = describe(failure)) } } - } /** * Create a group and tell its members what it is called. - * - * The name goes to the members, never to the relay, `createGroup` takes - * only the roster. Announcing is best-effort: a member who is offline picks - * the name up from the first message instead, because every group envelope - * carries it. */ - fun createGroup(name: String, memberIds: List) = viewModelScope.launch { + fun createGroup( + name: String, + memberIds: List, + ) = viewModelScope.launch { val current = session ?: return@launch update { it.copy(busy = true, error = null) } - val made = withContext(Dispatchers.IO) { - runCatching { - // Sealed, always. The relay used to be handed one - // readable `gid -> user_id` row per member; it is now handed a - // blob it has no key for and one opaque tag each. The name and - // the tags reach the members over their pairwise ratchets, so - // `createSealedGroup` does its own announcing. - current.createSealedGroup(name, memberIds) - current.namedGroups() + val made = + withContext(Dispatchers.IO) { + runCatching { + // Sealed, always. + current.createSealedGroup(name, memberIds) + current.namedGroups() + } } - } - made.onSuccess { list -> update { it.copy(busy = false, groups = list) } } + made + .onSuccess { list -> update { it.copy(busy = false, groups = list) } } .onFailure { failure -> update { it.copy(busy = false, error = describe(failure)) } } } /** * Rename a group, and tell the other members. - * - * The only way a group gets its name back after the relay stopped - * holding one. Names were fixed at creation and served by the relay for - * ever, so nothing needed this. Now the server has dropped them, every - * group that existed before the change reads as the placeholder on every - * phone, and there is nobody left to ask what it used to be called, the - * relay deleted its copy and no client had ever kept one. Without a rename - * those groups are called "Group" permanently. - * - * Stored first, announced second. The name is ours the moment we set it, - * so a member who cannot be reached now does not stop us seeing the group - * under the name we chose; they get it from the next message, which carries - * it. */ - fun renameGroup(groupId: String, name: String) = viewModelScope.launch { + fun renameGroup( + groupId: String, + name: String, + ) = viewModelScope.launch { val current = session ?: return@launch val trimmed = name.trim() if (trimmed.isEmpty()) return@launch - val done = withContext(Dispatchers.IO) { - runCatching { - current.nameGroup(groupId, trimmed) - val members = current.namedGroups().firstOrNull { it.id == groupId }?.memberIds - if (members != null) current.announceGroupName(groupId, members) - current.namedGroups() + val done = + withContext(Dispatchers.IO) { + runCatching { + current.nameGroup(groupId, trimmed) + val members = current.namedGroups().firstOrNull { it.id == groupId }?.memberIds + if (members != null) current.announceGroupName(groupId, members) + current.namedGroups() + } } - } - done.onSuccess { list -> - update { state -> - state.copy( - groups = list, - openChat = state.openChat?.let { chat -> - chat.copy(group = list.firstOrNull { g -> g.id == groupId } ?: chat.group) - }, - ) - } - }.onFailure { failure -> update { it.copy(error = describe(failure)) } } + done + .onSuccess { list -> + update { state -> + state.copy( + groups = list, + openChat = + state.openChat?.let { chat -> + chat.copy(group = list.firstOrNull { g -> g.id == groupId } ?: chat.group) + }, + ) + } + }.onFailure { failure -> update { it.copy(error = describe(failure)) } } } /** Whether this group's roster is sealed. For offering the upgrade. */ @@ -1216,10 +1014,6 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { /** * Whether to offer sealing this group: we own it and it is not sealed yet. - * - * The check that MATTERS is in `Session.upgradeGroupToSealed`, and the - * relay refuses a roster it has no signature for regardless. This exists so - * a button that could only fail is not shown. */ fun canSealGroup(group: io.flamenet.client.RelayClient.Group): Boolean { val phase = signedIn() ?: return false @@ -1228,26 +1022,33 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { /** * Replace the relay's readable roster for an old group with one it cannot - * open. Owner only; the relay refuses it from anybody else. + * open. */ - fun sealGroup(groupId: String) = viewModelScope.launch { - val current = session ?: return@launch - update { it.copy(busy = true, error = null) } - val done = withContext(Dispatchers.IO) { - runCatching { - current.upgradeGroupToSealed(groupId) - current.namedGroups() - } - } - done.onSuccess { list -> update { it.copy(busy = false, groups = list) } } - .onFailure { failure -> update { it.copy(busy = false, error = describe(failure)) } } - } - - fun updateGroupMembers(groupId: String, add: List, remove: List) = + fun sealGroup(groupId: String) = viewModelScope.launch { val current = session ?: return@launch update { it.copy(busy = true, error = null) } - val done = withContext(Dispatchers.IO) { + val done = + withContext(Dispatchers.IO) { + runCatching { + current.upgradeGroupToSealed(groupId) + current.namedGroups() + } + } + done + .onSuccess { list -> update { it.copy(busy = false, groups = list) } } + .onFailure { failure -> update { it.copy(busy = false, error = describe(failure)) } } + } + + fun updateGroupMembers( + groupId: String, + add: List, + remove: List, + ) = viewModelScope.launch { + val current = session ?: return@launch + update { it.copy(busy = true, error = null) } + val done = + withContext(Dispatchers.IO) { runCatching { current.updateMembers(groupId, add, remove) // Somebody just added has never been told the name, and @@ -1256,48 +1057,46 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { current.namedGroups() } } - done.onSuccess { list -> + done + .onSuccess { list -> update { state -> state.copy( - busy = false, groups = list, - openChat = state.openChat?.let { chat -> - chat.copy(group = list.firstOrNull { g -> g.id == groupId } ?: chat.group) - }, + busy = false, + groups = list, + openChat = + state.openChat?.let { chat -> + chat.copy(group = list.firstOrNull { g -> g.id == groupId } ?: chat.group) + }, ) } }.onFailure { failure -> update { it.copy(busy = false, error = describe(failure)) } } - } - - /** - * Leaving also clears the local thread. Staying would leave a - * conversation nobody can add to and no way to remove it, a dead thread - * the user cannot get rid of. - */ - fun leaveGroup(groupId: String) = viewModelScope.launch { - val current = session ?: return@launch - update { it.copy(busy = true, error = null) } - val done = withContext(Dispatchers.IO) { - runCatching { - current.leaveAnyGroup(groupId) - current.clearHistory(Vault.threadForGroup(groupId)) - // The name goes with it. It only ever lived here because the - // group did, and the server has no copy to fall back on. - current.vault.clearGroupName(groupId) - current.namedGroups() - } - } - done.onSuccess { list -> - update { it.copy(busy = false, groups = list, openChat = null) } - }.onFailure { failure -> update { it.copy(busy = false, error = describe(failure)) } } } + /** + * Leaving also clears the local thread. + */ + fun leaveGroup(groupId: String) = + viewModelScope.launch { + val current = session ?: return@launch + update { it.copy(busy = true, error = null) } + val done = + withContext(Dispatchers.IO) { + runCatching { + current.leaveAnyGroup(groupId) + current.clearHistory(Vault.threadForGroup(groupId)) + // The name goes with it. + current.vault.clearGroupName(groupId) + current.namedGroups() + } + } + done + .onSuccess { list -> + update { it.copy(busy = false, groups = list, openChat = null) } + }.onFailure { failure -> update { it.copy(busy = false, error = describe(failure)) } } + } + /** * What the wallet screen draws. - * - * Balance and history are nullable and that is the point. Null means - * "not asked yet, or the node did not answer", which the screen renders as - * an em dash, never as zero. A wallet that shows 0.00 when it could not - * reach a node has told somebody their money is gone. */ data class WalletState( val exists: Boolean = false, @@ -1315,166 +1114,183 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { fun closeChat() = update { it.copy(openChat = null) } - // -- Wallet --------------------------------------------------------------- + // -- Wallet + // ---------------------------------------------------------------. - private fun wordlist() = io.flamenet.e2e.Bip39.englishWordlist() + private fun wordlist() = + io.flamenet.e2e.Bip39 + .englishWordlist() - private fun walletUpdate(change: (WalletState) -> WalletState) = update { - it.copy(wallet = change(it.wallet ?: WalletState())) - } + private fun walletUpdate(change: (WalletState) -> WalletState) = + update { + it.copy(wallet = change(it.wallet ?: WalletState())) + } /** Read what is stored, without touching the network. */ - fun openWallet() = viewModelScope.launch { - showScreen(Phase.Screen.WALLET) - val store = vault ?: return@launch - val entropy = withContext(Dispatchers.IO) { - runCatching { store.walletEntropy() }.getOrNull() + fun openWallet() = + viewModelScope.launch { + showScreen(Phase.Screen.WALLET) + val store = vault ?: return@launch + val entropy = + withContext(Dispatchers.IO) { + runCatching { store.walletEntropy() }.getOrNull() + } + if (entropy == null) { + walletUpdate { it.copy(exists = false, endpoint = chainEndpoint()) } + return@launch + } + val address = + withContext(Dispatchers.IO) { + runCatching { + io.flamenet.e2e.Wallet + .account(entropy, wordlist(), store.walletNextIndex().toLong()) + .receive(store.walletNextIndex().toLong()) + .address + }.getOrNull() + } + walletUpdate { + it.copy(exists = true, address = address, endpoint = chainEndpoint(), problem = null) + } + refreshWallet() } - if (entropy == null) { - walletUpdate { it.copy(exists = false, endpoint = chainEndpoint()) } - return@launch - } - val address = withContext(Dispatchers.IO) { - runCatching { - io.flamenet.e2e.Wallet.account(entropy, wordlist(), store.walletNextIndex().toLong()) - .receive(store.walletNextIndex().toLong()).address - }.getOrNull() - } - walletUpdate { - it.copy(exists = true, address = address, endpoint = chainEndpoint(), problem = null) - } - refreshWallet() - } private fun chainEndpoint() = WalletChain.endpoint(getApplication()) /** * Make a wallet, and show the phrase once. - * - * The phrase is shown and not stored anywhere it can be read back - * casually. It is in the returned state for as long as the screen holds - * it, and the record on disk is the ENTROPY, the words are regenerated - * from it when somebody deliberately asks. Somebody who never writes them - * down and later erases the device has nothing, which is what a recovery - * phrase means and has to be said at this moment rather than afterwards. */ - fun createWallet() = viewModelScope.launch { - val store = vault ?: return@launch - walletUpdate { it.copy(busy = true, problem = null) } - val made = withContext(Dispatchers.IO) { - runCatching { - val created = io.flamenet.e2e.Wallet.create(wordlist()) - store.saveWalletEntropy(created.entropy) - created - } + fun createWallet() = + viewModelScope.launch { + val store = vault ?: return@launch + walletUpdate { it.copy(busy = true, problem = null) } + val made = + withContext(Dispatchers.IO) { + runCatching { + val created = + io.flamenet.e2e.Wallet + .create(wordlist()) + store.saveWalletEntropy(created.entropy) + created + } + } + made + .onSuccess { created -> + walletUpdate { it.copy(busy = false, exists = true, phrase = created.phrase) } + openWallet() + }.onFailure { failure -> + walletUpdate { it.copy(busy = false, problem = failure.message) } + } } - made.onSuccess { created -> - walletUpdate { it.copy(busy = false, exists = true, phrase = created.phrase) } - openWallet() - }.onFailure { failure -> - walletUpdate { it.copy(busy = false, problem = failure.message) } - } - } /** * Adopt a phrase typed in from somewhere else. - * - * Validated before anything is stored. A phrase that fails its checksum - * is refused here rather than saved and discovered later, when the wallet - * it names turns out to be empty and nobody can say why. */ - fun importWallet(phrase: String) = viewModelScope.launch { - val store = vault ?: return@launch - walletUpdate { it.copy(busy = true, problem = null) } - val done = withContext(Dispatchers.IO) { - runCatching { store.saveWalletEntropy(io.flamenet.e2e.Wallet.import(phrase, wordlist())) } + fun importWallet(phrase: String) = + viewModelScope.launch { + val store = vault ?: return@launch + walletUpdate { it.copy(busy = true, problem = null) } + val done = + withContext(Dispatchers.IO) { + runCatching { + store.saveWalletEntropy( + io.flamenet.e2e.Wallet + .import(phrase, wordlist()), + ) + } + } + done + .onSuccess { + walletUpdate { it.copy(busy = false, exists = true) } + openWallet() + }.onFailure { failure -> + walletUpdate { it.copy(busy = false, problem = failure.message) } + } } - done.onSuccess { - walletUpdate { it.copy(busy = false, exists = true) } - openWallet() - }.onFailure { failure -> - walletUpdate { it.copy(busy = false, problem = failure.message) } - } - } /** Show the words again, for somebody writing them down properly. */ - fun revealWalletPhrase() = viewModelScope.launch { - val store = vault ?: return@launch - val phrase = withContext(Dispatchers.IO) { - runCatching { - store.walletEntropy()?.let { io.flamenet.e2e.Wallet.phrase(it, wordlist()) } - }.getOrNull() + fun revealWalletPhrase() = + viewModelScope.launch { + val store = vault ?: return@launch + val phrase = + withContext(Dispatchers.IO) { + runCatching { + store.walletEntropy()?.let { + io.flamenet.e2e.Wallet + .phrase(it, wordlist()) + } + }.getOrNull() + } + walletUpdate { it.copy(phrase = phrase) } } - walletUpdate { it.copy(phrase = phrase) } - } fun hideWalletPhrase() = walletUpdate { it.copy(phrase = null) } /** * Ask the node what the address holds and what has happened to it. - * - * A failure leaves the balance NULL, never zero. The screen draws an - * em dash for null. Showing 0.00 because a node did not answer tells - * somebody their money is gone, which is the single worst thing this screen - * can say. */ - fun refreshWallet() = viewModelScope.launch { - val address = _phase.value.let { it as? Phase.SignedIn }?.wallet?.address - ?: return@launch - walletUpdate { it.copy(busy = true, problem = null) } - val endpoint = chainEndpoint() - val read = withContext(Dispatchers.IO) { - runCatching { - val rpc = ChainRpc(endpoint) - rpc.balance(address) to rpc.history(address) - } + fun refreshWallet() = + viewModelScope.launch { + val address = + _phase.value + .let { it as? Phase.SignedIn } + ?.wallet + ?.address + ?: return@launch + walletUpdate { it.copy(busy = true, problem = null) } + val endpoint = chainEndpoint() + val read = + withContext(Dispatchers.IO) { + runCatching { + val rpc = ChainRpc(endpoint) + rpc.balance(address) to rpc.history(address) + } + } + read + .onSuccess { (lamports, history) -> + walletUpdate { + it.copy(busy = false, lamports = lamports, history = history, problem = null) + } + }.onFailure { failure -> + walletUpdate { + it.copy( + busy = false, + lamports = null, + history = null, + problem = failure.message ?: "That node did not answer.", + ) + } + } } - read.onSuccess { (lamports, history) -> - walletUpdate { - it.copy(busy = false, lamports = lamports, history = history, problem = null) - } - }.onFailure { failure -> - walletUpdate { - it.copy(busy = false, lamports = null, history = null, - problem = failure.message ?: "That node did not answer.") - } - } - } /** * A fresh address to be paid at. - * - * The counter advances, which is the whole privacy argument. Reusing - * one address lets anybody watching a public chain group every payment - * somebody ever received. The chain will not forget, so this is the only - * lever there is. */ - fun nextReceiveAddress() = viewModelScope.launch { - val store = vault ?: return@launch - val next = withContext(Dispatchers.IO) { - runCatching { - val entropy = store.walletEntropy() ?: return@runCatching null - val index = store.walletNextIndex().toLong() - val address = io.flamenet.e2e.Wallet.account(entropy, wordlist(), index) - .receive(index).address - store.saveWalletNextIndex((index + 1).toInt()) - address - }.getOrNull() - } ?: return@launch - walletUpdate { it.copy(address = next, lamports = null, history = null) } - refreshWallet() - } + fun nextReceiveAddress() = + viewModelScope.launch { + val store = vault ?: return@launch + val next = + withContext(Dispatchers.IO) { + runCatching { + val entropy = store.walletEntropy() ?: return@runCatching null + val index = store.walletNextIndex().toLong() + val address = + io.flamenet.e2e.Wallet + .account(entropy, wordlist(), index) + .receive(index) + .address + store.saveWalletNextIndex((index + 1).toInt()) + address + }.getOrNull() + } ?: return@launch + walletUpdate { it.copy(address = next, lamports = null, history = null) } + refreshWallet() + } - - // -- Files ---------------------------------------------------------------- + // -- Files + // ----------------------------------------------------------------. /** * What the Files screen draws. - * - * `unlocked` is not "the passphrase was right once". The identity - * lives in [filesVault] for as long as the screen is open and is dropped on - * lock, so this flag and that field are always set together, and neither is - * a cache of the other. */ data class FilesState( /** Whether Files has ever been set up on this phone. */ @@ -1484,10 +1300,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { /** Deepest last. Empty means the top of the tree. */ val breadcrumb: List = emptyList(), /** - * Which of the two ways of holding a file is being looked at. They - * are separate sections rather than one mixed list because they are not - * equally protected, and a list that mixed them would be a list where - * "encrypted" is invisible. + * Which of the two ways of holding a file is being looked at. */ val section: LocalVault.Storage = LocalVault.Storage.VAULT, val selecting: Boolean = false, @@ -1500,29 +1313,24 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { val parentId: UUID? get() = breadcrumb.lastOrNull()?.id } - data class Crumb(val id: UUID, val name: String) + data class Crumb( + val id: UUID, + val name: String, + ) /** * The four levels the local vault offers, and no compartments. - * - * Matches iOS's `Vocabularies.local` exactly, ordinals included. The - * number is what travels with a file, the word is only how it is drawn, - * so a client that renumbered these would file somebody's Restricted - * document as Internal on the other phone, with no error anywhere. */ + /** * The open vault, held outside the state. - * - * Not in [FilesState]. It holds the unlocked identity key, and state - * objects get logged, compared and copied into recompositions, none of - * which should ever have a private key in it. Dropping this field IS the - * lock. */ private var filesVault: LocalVault? = null - private fun filesUpdate(change: (FilesState) -> FilesState) = update { - it.copy(files = change(it.files ?: FilesState())) - } + private fun filesUpdate(change: (FilesState) -> FilesState) = + update { + it.copy(files = change(it.files ?: FilesState())) + } private fun safReferences() = SafReferences(getApplication()) @@ -1530,100 +1338,104 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { java.io.File(java.io.File(getApplication().filesDir, "files"), handle) /** Show the screen, and say whether Files has been set up here. */ - fun openFiles() = viewModelScope.launch { - showScreen(Phase.Screen.FILES) - val store = vault ?: return@launch - val exists = withContext(Dispatchers.IO) { - runCatching { store.filesIdentityRecord() != null }.getOrDefault(false) + fun openFiles() = + viewModelScope.launch { + showScreen(Phase.Screen.FILES) + val store = vault ?: return@launch + val exists = + withContext(Dispatchers.IO) { + runCatching { store.filesIdentityRecord() != null }.getOrDefault(false) + } + filesUpdate { + it.copy(hasIdentity = exists, unlocked = filesVault != null, problem = null) + } + if (filesVault != null) refreshFiles() } - filesUpdate { - it.copy(hasIdentity = exists, unlocked = filesVault != null, problem = null) - } - if (filesVault != null) refreshFiles() - } /** * Set Files up for the first time. - * - * A passphrase this app does not keep. Messenger deliberately holds - * no copy of the account passphrase, and the Files identity is protected the - * same way, which is what makes a borrowed, unlocked phone still not open - * somebody's files. It is a real cost: forget it and every vaulted file is - * unreadable, because the label keys are derived from the identity and exist - * nowhere else. The screen has to say that here, once, rather than let it be - * discovered. */ - fun createFilesIdentity(passphrase: String) = viewModelScope.launch { - val store = vault ?: return@launch - val phase = signedIn() ?: return@launch - if (passphrase.length < 8) { - filesUpdate { it.copy(problem = "Use at least 8 characters.") } - return@launch - } - filesUpdate { it.copy(busy = "Setting up…", problem = null) } - val made = withContext(Dispatchers.IO) { - runCatching { - // A fresh X25519 key, not one derived from the passphrase. - // Deriving it would mean changing the passphrase changes the - // identity, and with it every label key, which would leave - // every existing file unreadable. The record is re-wrapped on a - // change; the key underneath never moves. - val raw = FilesIdentity.rawFromPkcs8( - X25519.generate().private.encoded) - store.saveFilesIdentityRecord(FilesIdentity.protect(raw, passphrase)) - openVaultFor(phase.handle, raw) + fun createFilesIdentity(passphrase: String) = + viewModelScope.launch { + val store = vault ?: return@launch + val phase = signedIn() ?: return@launch + if (passphrase.length < 8) { + filesUpdate { it.copy(problem = "Use at least 8 characters.") } + return@launch } - } - made.fold( - onSuccess = { - filesUpdate { - it.copy(hasIdentity = true, unlocked = true, busy = null, - notice = "Your files are set up. If you forget this " - + "passphrase there is no way back into them.") + filesUpdate { it.copy(busy = "Setting up…", problem = null) } + val made = + withContext(Dispatchers.IO) { + runCatching { + // A fresh X25519 key, not one derived from the passphrase. + val raw = + FilesIdentity.rawFromPkcs8(X25519.generate().private.encoded) + store.saveFilesIdentityRecord(FilesIdentity.protect(raw, passphrase)) + openVaultFor(phase.handle, raw) + } } - refreshFiles() - }, - onFailure = { e -> - filesUpdate { it.copy(busy = null, problem = e.message ?: "That didn't work.") } - }, - ) - } - - fun unlockFiles(passphrase: String) = viewModelScope.launch { - val store = vault ?: return@launch - val phase = signedIn() ?: return@launch - filesUpdate { it.copy(busy = "Unlocking…", problem = null) } - val opened = withContext(Dispatchers.IO) { - runCatching { - val record = store.filesIdentityRecord() - ?: throw IllegalStateException("Files aren't set up on this phone yet.") - openVaultFor(phase.handle, FilesIdentity.unlock(record, passphrase).rawPrivate) - } + made.fold( + onSuccess = { + filesUpdate { + it.copy( + hasIdentity = true, + unlocked = true, + busy = null, + notice = + "Your files are set up. If you forget this " + + "passphrase there is no way back into them.", + ) + } + refreshFiles() + }, + onFailure = { e -> + filesUpdate { it.copy(busy = null, problem = e.message ?: "That didn't work.") } + }, + ) } - opened.fold( - onSuccess = { - filesUpdate { it.copy(unlocked = true, busy = null, problem = null) } - refreshFiles() - }, - onFailure = { e -> - filesUpdate { - it.copy(busy = null, unlocked = false, - problem = e.message ?: "That passphrase didn't work.") - } - }, - ) - } - private fun openVaultFor(handle: String, rawPrivate: ByteArray) { + fun unlockFiles(passphrase: String) = + viewModelScope.launch { + val store = vault ?: return@launch + val phase = signedIn() ?: return@launch + filesUpdate { it.copy(busy = "Unlocking…", problem = null) } + val opened = + withContext(Dispatchers.IO) { + runCatching { + val record = + store.filesIdentityRecord() + ?: throw IllegalStateException("Files aren't set up on this phone yet.") + openVaultFor(phase.handle, FilesIdentity.unlock(record, passphrase).rawPrivate) + } + } + opened.fold( + onSuccess = { + filesUpdate { it.copy(unlocked = true, busy = null, problem = null) } + refreshFiles() + }, + onFailure = { e -> + filesUpdate { + it.copy( + busy = null, + unlocked = false, + problem = e.message ?: "That passphrase didn't work.", + ) + } + }, + ) + } + + private fun openVaultFor( + handle: String, + rawPrivate: ByteArray, + ) { val opened = LocalVault(filesRoot(handle), rawPrivate, safReferences()) opened.load() filesVault = opened } /** - * Locking drops the identity, it does not hide a screen. Anything - * short of that leaves the key in memory behind a boolean, which is a lock - * that a screenshot of the state object defeats. + * Locking drops the identity, it does not hide a screen. */ fun lockFiles() { filesVault = null @@ -1632,20 +1444,24 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { } } - fun refreshFiles() = viewModelScope.launch { - val open = filesVault ?: return@launch - val current = signedIn()?.files ?: FilesState() - val rows = withContext(Dispatchers.IO) { - runCatching { open.list(current.parentId) }.getOrDefault(emptyList()) + fun refreshFiles() = + viewModelScope.launch { + val open = filesVault ?: return@launch + val current = signedIn()?.files ?: FilesState() + val rows = + withContext(Dispatchers.IO) { + runCatching { open.list(current.parentId) }.getOrDefault(emptyList()) + } + filesUpdate { it.copy(entries = rows) } } - filesUpdate { it.copy(entries = rows) } - } fun filesOpenFolder(entry: LocalVault.Entry) { filesUpdate { it.copy( breadcrumb = it.breadcrumb + Crumb(entry.id, entry.name ?: "Folder"), - selecting = false, selected = emptySet(), notice = null, + selecting = false, + selected = emptySet(), + notice = null, ) } refreshFiles() @@ -1653,16 +1469,24 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { fun filesGoUp() { filesUpdate { - it.copy(breadcrumb = it.breadcrumb.dropLast(1), - selecting = false, selected = emptySet(), notice = null) + it.copy( + breadcrumb = it.breadcrumb.dropLast(1), + selecting = false, + selected = emptySet(), + notice = null, + ) } refreshFiles() } fun filesGoTo(depth: Int) { filesUpdate { - it.copy(breadcrumb = it.breadcrumb.take(depth), - selecting = false, selected = emptySet(), notice = null) + it.copy( + breadcrumb = it.breadcrumb.take(depth), + selecting = false, + selected = emptySet(), + notice = null, + ) } refreshFiles() } @@ -1682,131 +1506,141 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { } /** Copy a file in and encrypt it. */ - fun filesAdd(uri: android.net.Uri, name: String, classification: Short) = - viewModelScope.launch { - val open = filesVault ?: return@launch - val parent = signedIn()?.files?.parentId - filesUpdate { it.copy(busy = "Encrypting…", problem = null, notice = null) } - val done = withContext(Dispatchers.IO) { + fun filesAdd( + uri: android.net.Uri, + name: String, + classification: Short, + ) = viewModelScope.launch { + val open = filesVault ?: return@launch + val parent = signedIn()?.files?.parentId + filesUpdate { it.copy(busy = "Encrypting…", problem = null, notice = null) } + val done = + withContext(Dispatchers.IO) { runCatching { - val bytes = getApplication().contentResolver - .openInputStream(uri)?.use { it.readBytes() } - ?: throw IllegalStateException("That file couldn't be read.") + val bytes = + getApplication() + .contentResolver + .openInputStream(uri) + ?.use { it.readBytes() } + ?: throw IllegalStateException("That file couldn't be read.") open.add(bytes, name, Label(classification), parent) } } - finishFilesWork(done, "Encrypted and filed.") - } + finishFilesWork(done, "Encrypted and filed.") + } /** * Link a file that stays where it is. - * - * Refuses when the read grant cannot be persisted. A row made from a - * one-shot URI works for the rest of the session and is broken on the next - * launch, and whoever added it watched it work, which is the worst shape a - * bug can have. Saying so and offering to copy it in instead is the honest - * answer. */ - fun filesReference(uri: android.net.Uri, name: String, classification: Short) = - viewModelScope.launch { - val open = filesVault ?: return@launch - val parent = signedIn()?.files?.parentId - val saf = safReferences() - filesUpdate { it.copy(busy = "Linking…", problem = null, notice = null) } - val done = withContext(Dispatchers.IO) { + fun filesReference( + uri: android.net.Uri, + name: String, + classification: Short, + ) = viewModelScope.launch { + val open = filesVault ?: return@launch + val parent = signedIn()?.files?.parentId + val saf = safReferences() + filesUpdate { it.copy(busy = "Linking…", problem = null, notice = null) } + val done = + withContext(Dispatchers.IO) { runCatching { if (!saf.takePersistablePermission(uri)) { throw IllegalStateException( - "This app can't keep access to that file, so the link would " - + "stop working next time. Copy it into the vault instead.") + "This app can't keep access to that file, so the link would " + + "stop working next time. Copy it into the vault instead.", + ) } open.reference(uri.toString(), name, Label(classification), parent) } } - finishFilesWork(done, "Linked. Nothing was copied.") - } + finishFilesWork(done, "Linked. Nothing was copied.") + } - fun filesCreateFolder(name: String, classification: Short) = viewModelScope.launch { + fun filesCreateFolder( + name: String, + classification: Short, + ) = viewModelScope.launch { val open = filesVault ?: return@launch val parent = signedIn()?.files?.parentId if (name.isBlank()) { filesUpdate { it.copy(problem = "Give the folder a name.") } return@launch } - val done = withContext(Dispatchers.IO) { - runCatching { open.createFolder(name.trim(), Label(classification), parent) } - } + val done = + withContext(Dispatchers.IO) { + runCatching { open.createFolder(name.trim(), Label(classification), parent) } + } finishFilesWork(done, null) } /** * Promote a link into an encrypted copy. - * - * The person's own file is left exactly where it is. Deleting it is - * theirs to decide, and the notice says so rather than doing it quietly. */ - fun filesEncrypt(id: UUID) = viewModelScope.launch { - val open = filesVault ?: return@launch - filesUpdate { it.copy(busy = "Encrypting…", problem = null, notice = null) } - val done = withContext(Dispatchers.IO) { runCatching { open.encrypt(id) } } - done.fold( - onSuccess = { - filesUpdate { - it.copy(busy = null, section = LocalVault.Storage.VAULT, - notice = "Encrypted into the vault. Your original file is " - + "still where you left it.") - } - refreshFiles() - }, - onFailure = { e -> - filesUpdate { it.copy(busy = null, problem = e.message ?: "That didn't work.") } - }, - ) - } + fun filesEncrypt(id: UUID) = + viewModelScope.launch { + val open = filesVault ?: return@launch + filesUpdate { it.copy(busy = "Encrypting…", problem = null, notice = null) } + val done = withContext(Dispatchers.IO) { runCatching { open.encrypt(id) } } + done.fold( + onSuccess = { + filesUpdate { + it.copy( + busy = null, + section = LocalVault.Storage.VAULT, + notice = + "Encrypted into the vault. Your original file is " + + "still where you left it.", + ) + } + refreshFiles() + }, + onFailure = { e -> + filesUpdate { it.copy(busy = null, problem = e.message ?: "That didn't work.") } + }, + ) + } /** * Delete what is selected, and say what actually happened. - * - * The report is not decoration. Selecting eleven things and having - * nine go is a data-loss-shaped event in the other direction: the two that - * survived are the ones somebody now believes are gone. */ - fun filesDeleteSelected() = viewModelScope.launch { - val open = filesVault ?: return@launch - val chosen = signedIn()?.files?.selected.orEmpty() - if (chosen.isEmpty()) return@launch - filesUpdate { it.copy(busy = "Deleting…", problem = null, notice = null) } - val done = withContext(Dispatchers.IO) { runCatching { open.remove(chosen.toList()) } } - done.fold( - onSuccess = { result -> - filesUpdate { - it.copy( - busy = null, selecting = false, selected = emptySet(), - notice = if (result.isCompletelyDone) null else { - val kept = result.keptNonEmpty.size - "Deleted ${result.removed.size}. " + - (if (kept == 1) "One folder was kept" else "$kept folders were kept") + - " because there's still something inside that you didn't select." - }, - ) - } - refreshFiles() - }, - onFailure = { e -> - filesUpdate { it.copy(busy = null, problem = e.message ?: "That didn't work.") } - }, - ) - } + fun filesDeleteSelected() = + viewModelScope.launch { + val open = filesVault ?: return@launch + val chosen = signedIn()?.files?.selected.orEmpty() + if (chosen.isEmpty()) return@launch + filesUpdate { it.copy(busy = "Deleting…", problem = null, notice = null) } + val done = withContext(Dispatchers.IO) { runCatching { open.remove(chosen.toList()) } } + done.fold( + onSuccess = { result -> + filesUpdate { + it.copy( + busy = null, + selecting = false, + selected = emptySet(), + notice = + if (result.isCompletelyDone) { + null + } else { + val kept = result.keptNonEmpty.size + "Deleted ${result.removed.size}. " + + (if (kept == 1) "One folder was kept" else "$kept folders were kept") + + " because there's still something inside that you didn't select." + }, + ) + } + refreshFiles() + }, + onFailure = { e -> + filesUpdate { it.copy(busy = null, problem = e.message ?: "That didn't work.") } + }, + ) + } - // -- Files, into and out of a conversation -------------------------------- + // -- Files, into and out of a conversation + // --------------------------------. /** * Whether a conversation can offer to send from Files right now. - * - * A chat never asks for the Files passphrase. A conversation that - * prompted for one would be a conversation that has started collecting one, - * and Files goes out of its way not to hold it. Locked means the picker says - * so and points at the Files screen. */ fun filesUnlocked(): Boolean = filesVault != null @@ -1816,97 +1650,108 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { /** * Send a document from the vault into this conversation. - * - * This is a downgrade, and the screen says so before the tap rather - * than after. The content key goes in the same envelope, so the - * classification does not stop the recipient reading it, it travels so - * their phone files it the same way. Nothing here refuses on the label, - * because nothing here could: refusing in a client whose user holds the - * keys is a suggestion, and one dressed as a control is worse than none. */ - fun sendFileFromVault(entry: LocalVault.Entry, chat: Phase.Chat) = - viewModelScope.launch { - val open = filesVault ?: return@launch - val live = session ?: return@launch - filesUpdate { it.copy(busy = "Sending…", problem = null, notice = null) } - val done = withContext(Dispatchers.IO) { + fun sendFileFromVault( + entry: LocalVault.Entry, + chat: Phase.Chat, + ) = viewModelScope.launch { + val open = filesVault ?: return@launch + val live = session ?: return@launch + filesUpdate { it.copy(busy = "Sending…", problem = null, notice = null) } + val done = + withContext(Dispatchers.IO) { runCatching { val bytes = open.contents(entry.node) val name = entry.name ?: "file" val label = entry.label val group = chat.group if (group != null) { - live.sendGroupFile(group, bytes, name, - label.classification.toInt(), label.compartments.sorted()) + live.sendGroupFile( + group, + bytes, + name, + label.classification.toInt(), + label.compartments.sorted(), + ) } else { - live.sendFile(chat.peerId ?: return@runCatching, - bytes, name, - label.classification.toInt(), label.compartments.sorted()) + live.sendFile( + chat.peerId ?: return@runCatching, + bytes, + name, + label.classification.toInt(), + label.compartments.sorted(), + ) } } } - done.fold( - onSuccess = { - filesUpdate { it.copy(busy = null) } - update { - it.copy(openChat = it.openChat?.copy(lines = live.history(chat.threadId))) - } - }, - onFailure = { e -> - filesUpdate { - it.copy(busy = null, - problem = "Couldn't send that file. ${e.message ?: ""}".trim()) - } - }, - ) - } - - /** - * Save a received document into the vault. - * - * Always an encrypted copy, never a link. There is no file on this - * phone to point at, the bytes arrived over the wire, so the two storage - * modes are not a choice here. - * - * Filed under the label it arrived with, not under one this device - * chose. Re-labelling somebody else's document on receipt would quietly - * launder its classification, and the number is the only thing that survived - * the trip. - */ - fun saveAttachmentToFiles(ref: io.flamenet.e2e.Attachment.Ref) = viewModelScope.launch { - val open = filesVault - if (open == null) { - filesUpdate { - it.copy(problem = "Files is locked. Open Files and unlock it, then " - + "come back, your passphrase isn't asked for here.") - } - return@launch - } - val live = session ?: return@launch - filesUpdate { it.copy(busy = "Saving…", problem = null, notice = null) } - val done = withContext(Dispatchers.IO) { - runCatching { - val bytes = live.attachmentBytes(ref, getApplication().cacheDir) - val name = ref.filename.ifEmpty { "Received file" } - open.add(bytes, name, - Label(ref.classification.toShort(), ref.compartments.toSet())) - name - } - } done.fold( - onSuccess = { name -> filesUpdate { it.copy(busy = null, notice = "Saved $name to Files.") } }, + onSuccess = { + filesUpdate { it.copy(busy = null) } + update { + it.copy(openChat = it.openChat?.copy(lines = live.history(chat.threadId))) + } + }, onFailure = { e -> filesUpdate { - it.copy(busy = null, - problem = "Couldn't save that file. ${e.message ?: ""}".trim()) + it.copy( + busy = null, + problem = "Couldn't send that file. ${e.message ?: ""}".trim(), + ) } }, ) } + /** + * Save a received document into the vault. + */ + fun saveAttachmentToFiles(ref: io.flamenet.e2e.Attachment.Ref) = + viewModelScope.launch { + val open = filesVault + if (open == null) { + filesUpdate { + it.copy( + problem = + "Files is locked. Open Files and unlock it, then " + + "come back, your passphrase isn't asked for here.", + ) + } + return@launch + } + val live = session ?: return@launch + filesUpdate { it.copy(busy = "Saving…", problem = null, notice = null) } + val done = + withContext(Dispatchers.IO) { + runCatching { + val bytes = live.attachmentBytes(ref, getApplication().cacheDir) + val name = ref.filename.ifEmpty { "Received file" } + open.add( + bytes, + name, + Label(ref.classification.toShort(), ref.compartments.toSet()), + ) + name + } + } + done.fold( + onSuccess = { name -> filesUpdate { it.copy(busy = null, notice = "Saved $name to Files.") } }, + onFailure = { e -> + filesUpdate { + it.copy( + busy = null, + problem = "Couldn't save that file. ${e.message ?: ""}".trim(), + ) + } + }, + ) + } + fun filesClearMessages() = filesUpdate { it.copy(problem = null, notice = null) } - private fun finishFilesWork(result: Result<*>, notice: String?) { + private fun finishFilesWork( + result: Result<*>, + notice: String?, + ) { result.fold( onSuccess = { filesUpdate { it.copy(busy = null, notice = notice) } @@ -1926,135 +1771,136 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { /** * Go to a screen the way a person arriving at it expects. - * - * Not the same as `showScreen`, and the difference is not cosmetic. - * Wallet and Files each have to read something off the vault before they - * have anything to draw, and `showScreen` alone would land somebody on an - * empty wallet with no error and nothing loading. Notifications has the - * same shape, the screen exists, the list is stale until it is fetched. - * - * Every caller that moves between destinations should use this rather - * than `showScreen`. `showScreen` stays public because the loaders - * themselves call it, but a navigation that skips the loader is a screen - * that looks broken for reasons the screen cannot explain. */ fun navigateTo(screen: Phase.Screen) { when (screen) { - Phase.Screen.WALLET -> openWallet() - Phase.Screen.FILES -> openFiles() + Phase.Screen.WALLET -> { + openWallet() + } + + Phase.Screen.FILES -> { + openFiles() + } + Phase.Screen.NOTIFICATIONS -> { showScreen(screen) refreshNotifications() } - else -> showScreen(screen) + + else -> { + showScreen(screen) + } } } - // -- Contacts: the rest of the CRUD -------------------------------------- + // -- Contacts: the rest of the CRUD --------------------------------------. - fun removeBuddy(userId: Long) = act { current -> - // The sealed copy first: in off-server mode it IS the roster, and in - // mirror mode a remove must not resurrect on the next fetch. - current.rosterRemoveLocal(userId) - if (!current.rosterOffServer()) current.api.removeBuddy(userId) - current.roster() - }.let { } + fun removeBuddy(userId: Long) = + act { current -> + // The sealed copy first: in off-server mode it IS the roster, and in + // mirror mode a remove must not resurrect on the next fetch. + current.rosterRemoveLocal(userId) + if (!current.rosterOffServer()) current.api.removeBuddy(userId) + current.roster() + }.let { } /** P1's one-way door, from Settings. */ - fun takeRosterOffServer() = viewModelScope.launch { - val current = session ?: return@launch - update { it.copy(busy = true, error = null) } - val done = withContext(Dispatchers.IO) { runCatching { current.forgetRosterOnServer() } } - done.onSuccess { - update { it.copy(busy = false, - status = "Contact list removed from the server. It lives in this device's encrypted store now.") } - }.onFailure { failure -> - update { it.copy(busy = false, error = describe(failure)) } + fun takeRosterOffServer() = + viewModelScope.launch { + val current = session ?: return@launch + update { it.copy(busy = true, error = null) } + val done = withContext(Dispatchers.IO) { runCatching { current.forgetRosterOnServer() } } + done + .onSuccess { + update { + it.copy( + busy = false, + status = "Contact list removed from the server. It lives in this device's encrypted store now.", + ) + } + }.onFailure { failure -> + update { it.copy(busy = false, error = describe(failure)) } + } } - } fun rosterIsOffServer(): Boolean = session?.rosterOffServer() ?: false - fun setBlocked(userId: Long, blocked: Boolean) = viewModelScope.launch { + fun setBlocked( + userId: Long, + blocked: Boolean, + ) = viewModelScope.launch { val current = session ?: return@launch update { it.copy(busy = true, error = null) } - val done = withContext(Dispatchers.IO) { - runCatching { - current.api.setBlocked(userId, blocked) - // And take their sealed capabilities with it. The relay - // cannot enforce a block against a sealed sender, the request - // carries no sender to compare against a block list, and the - // send route says so itself, so without this, blocking stops - // the identified path and nothing else, and a blocked person's - // sealed messages are dropped by this app after decryption - // rather than refused at the door. - // - // One of two doors, and this shuts one. They keep the shared - // delivery key of §14.4, which the relay also cannot attribute, - // until it is rotated, and rotating costs every other contact - // a redistribution. Best-effort and after the block has landed: - // the block is the safety action, and failing it because a - // follow-up failed would leave somebody unblocked who believes - // otherwise. - if (blocked) { - runCatching { current.conversations.revokeDeliveryTokens(userId) } + val done = + withContext(Dispatchers.IO) { + runCatching { + current.api.setBlocked(userId, blocked) + // And take their sealed capabilities with it. + if (blocked) { + runCatching { current.conversations.revokeDeliveryTokens(userId) } + } + current.api.blocked().toSet() to current.roster() } - current.api.blocked().toSet() to current.roster() } - } - done.onSuccess { (blockedIds, list) -> - update { it.copy(busy = false, blocked = blockedIds, buddies = list) } - }.onFailure { failure -> update { it.copy(busy = false, error = describe(failure)) } } + done + .onSuccess { (blockedIds, list) -> + update { it.copy(busy = false, blocked = blockedIds, buddies = list) } + }.onFailure { failure -> update { it.copy(busy = false, error = describe(failure)) } } } /** - * Clears the thread and keeps the session. Dropping the ratchet as well - * would make the next message from that person undecryptable, a "clear - * chat" that quietly breaks the chat. + * Clears the thread and keeps the session. */ - fun clearHistory(threadId: String) = viewModelScope.launch { - val current = session ?: return@launch - withContext(Dispatchers.IO) { current.clearHistory(threadId) } - update { - it.copy(openChat = it.openChat?.takeIf { chat -> chat.threadId == threadId } - ?.copy(lines = emptyList()) ?: it.openChat) + fun clearHistory(threadId: String) = + viewModelScope.launch { + val current = session ?: return@launch + withContext(Dispatchers.IO) { current.clearHistory(threadId) } + update { + it.copy( + openChat = + it.openChat + ?.takeIf { chat -> chat.threadId == threadId } + ?.copy(lines = emptyList()) ?: it.openChat, + ) + } } - } /** Delete one message from this device (delete for me). */ - fun deleteMessage(line: Session.Line) = viewModelScope.launch { - val current = session ?: return@launch - val threadId = signedIn()?.openChat?.threadId ?: return@launch - withContext(Dispatchers.IO) { - current.deleteMessage(threadId, line.at, line.mine, line.text) + fun deleteMessage(line: Session.Line) = + viewModelScope.launch { + val current = session ?: return@launch + val threadId = signedIn()?.openChat?.threadId ?: return@launch + withContext(Dispatchers.IO) { + current.deleteMessage(threadId, line.at, line.mine, line.text) + } + update { + it.copy(openChat = it.openChat?.copy(lines = current.history(threadId))) + } } - update { - it.copy(openChat = it.openChat?.copy(lines = current.history(threadId))) - } - } /** * A preview for the first link in [text], when the user wants them. - * - * Runs on the caller's IO context, never the main thread. It is a - * network fetch, and a preview must never be able to delay a send from the - * user's point of view, let alone block the UI. */ private fun linkPreviewFor(text: String): io.flamenet.e2e.Content.LinkPreview? { if (!mediaSettings.linkPreviews) return null - val url = io.flamenet.client.LinkPreviewFetcher.firstUrl(text) ?: return null - return io.flamenet.client.LinkPreviewFetcher.fetch(url) + val url = + io.flamenet.client.LinkPreviewFetcher + .firstUrl(text) ?: return null + return io.flamenet.client.LinkPreviewFetcher + .fetch(url) } - // -- Scheduled send ------------------------------------------------------- + // -- Scheduled send + // -------------------------------------------------------. /** * Queue the composer's contents for [seconds] from now. - * - * Clears the draft, like an ordinary send. Leaving the text in place - * after scheduling is how somebody sends the same message twice. */ - fun scheduleDraft(peerId: Long, text: String, seconds: Int) = viewModelScope.launch { + fun scheduleDraft( + peerId: Long, + text: String, + seconds: Int, + ) = viewModelScope.launch { val current = session ?: return@launch val body = text.trim() if (body.isEmpty() || seconds <= 0) return@launch @@ -2063,28 +1909,28 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { refreshScheduled(peerId) } - fun cancelSoonestScheduled(peerId: Long) = viewModelScope.launch { - val current = session ?: return@launch - withContext(Dispatchers.IO) { current.cancelSoonestScheduled(peerId) } - refreshScheduled(peerId) - } + fun cancelSoonestScheduled(peerId: Long) = + viewModelScope.launch { + val current = session ?: return@launch + withContext(Dispatchers.IO) { current.cancelSoonestScheduled(peerId) } + refreshScheduled(peerId) + } fun refreshScheduled(peerId: Long) { val current = session ?: return update { it.copy(openChat = it.openChat?.copy(scheduled = current.scheduledFor(peerId))) } } - // -- Attention management: snooze and reply reminders --------------------- + // -- Attention management: snooze and reply reminders + // ---------------------. /** * Snooze a conversation for [seconds]; 0 wakes it now. - * - * Local to this device, deliberately. "I am ignoring this person until - * Tuesday" is a statement about a relationship, and the relay has no - * business holding it, the cost is that a snooze does not follow you to a - * new device, which is the better half of that trade. */ - fun snooze(peerId: Long, seconds: Int) = viewModelScope.launch { + fun snooze( + peerId: Long, + seconds: Int, + ) = viewModelScope.launch { val current = session ?: return@launch val until = if (seconds <= 0) 0L else System.currentTimeMillis() + seconds * 1000L withContext(Dispatchers.IO) { current.snooze(peerId, until) } @@ -2092,21 +1938,19 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { } /** Recompute which threads are snoozed right now. */ + /** * Load the presence the relay actually holds for us. - * - * Without this the chip is a guess. The state defaulted to "online" - * and nothing ever read the real value, so the screen showed whatever the - * default was rather than what contacts could see, including after a - * relaunch, when "appear offline" had been chosen and forgotten. */ - fun refreshPresence() = viewModelScope.launch { - val current = session ?: return@launch - val stored = withContext(Dispatchers.IO) { - runCatching { current.api.currentStatus() }.getOrNull() - } ?: return@launch - update { it.copy(presence = displayPresence(stored)) } - } + fun refreshPresence() = + viewModelScope.launch { + val current = session ?: return@launch + val stored = + withContext(Dispatchers.IO) { + runCatching { current.api.currentStatus() }.getOrNull() + } ?: return@launch + update { it.copy(presence = displayPresence(stored)) } + } fun refreshSnoozed() { val current = session ?: return @@ -2115,238 +1959,261 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { /** * Ask to be reminded if this conversation goes unanswered for [seconds]. - * - * The reminder is cancelled by a reply ARRIVING, in `Session.record`, - * not merely by firing. One that still goes off after they answered teaches - * people to ignore reminders. */ - fun setReplyReminder(peerId: Long, seconds: Int) = viewModelScope.launch { + fun setReplyReminder( + peerId: Long, + seconds: Int, + ) = viewModelScope.launch { val current = session ?: return@launch val at = if (seconds <= 0) 0L else System.currentTimeMillis() + seconds * 1000L withContext(Dispatchers.IO) { current.setReplyReminder(peerId, at) } update { it.copy(openChat = it.openChat?.copy(replyReminderAt = at)) } } - // -- Disappearing messages (§6.11) --------------------------------------- + // -- Disappearing messages (§6.11) ---------------------------------------. /** * Set the timer for the open conversation, on both sides. - * - * The peer is told inside the ratchet and adopts it. That is what makes - * this worth offering: a timer only this device honoured would leave the - * user believing their words were gone from a phone still holding them. */ - fun setDisappearing(seconds: Int) = viewModelScope.launch { - val current = session ?: return@launch - val chat = signedIn()?.openChat ?: return@launch - val peerId = chat.peerId ?: return@launch - val done = withContext(Dispatchers.IO) { - runCatching { current.conversations.setDisappearing(peerId, seconds) } - } - update { state -> - state.copy( - openChat = state.openChat?.copy( - disappearSeconds = current.conversations.disappearSeconds(peerId), - // Applying the timer expires anything already past it, so - // the thread has to be re-read rather than left on screen. - lines = current.history(chat.threadId), - ), - error = done.exceptionOrNull()?.let(::describe), - ) - } - } - - // -- Presence ------------------------------------------------------------ - - /** - * Choose a presence state. - * - * Shows what the relay stored, not what we asked for. This used to - * do `it.copy(presence = status)` on success, and the relay used to answer - * 200 for statuses it did not recognise while storing nothing. `busy` and - * `offline` were both in that set, so tapping either lit the chip and - * changed nothing: someone who chose "offline" stayed visible to every - * contact with no way to find out. Both ends are fixed; this half stays - * correct even if a relay ever behaves that way again. - * - * `offline` is stored as `invisible`, the two are the same choice under - * different names, so the reply is mapped back to this screen's vocabulary - * or no chip would appear selected. - */ - fun setPresence(status: String) = viewModelScope.launch { - val current = session ?: return@launch - val done = withContext(Dispatchers.IO) { runCatching { current.api.setStatus(status) } } - done.onSuccess { stored -> - update { it.copy(presence = displayPresence(stored), error = null) } - }.onFailure { failure -> update { it.copy(error = describe(failure)) } } - } - - /** - * Accept a correspondent the app adopted on the user's behalf. - * - * Refreshes the roster rather than patching the row locally: `accepted` - * is the relay's fact, and a second device needs to see the same answer. - */ - fun acceptCorrespondent(userId: Long) = viewModelScope.launch { - val current = session ?: return@launch - val done = withContext(Dispatchers.IO) { - runCatching { current.api.acceptConversation(userId) } - } - done.onSuccess { refreshBuddies() } - .onFailure { failure -> update { it.copy(error = describe(failure)) } } - } - - /** Relay vocabulary -> the words on the presence chips. */ - internal fun displayPresence(stored: String): String = - if (stored == "invisible") "offline" else stored - - - // -- Notifications ------------------------------------------------------- - - fun refreshNotifications() = viewModelScope.launch { - val current = session ?: return@launch - val fetched = withContext(Dispatchers.IO) { runCatching { current.api.notifications() } } - fetched.onSuccess { list -> update { it.copy(notifications = list, error = null) } } - .onFailure { failure -> update { it.copy(error = describe(failure)) } } - } - - fun markNotificationRead(id: Long) = viewModelScope.launch { - val current = session ?: return@launch - withContext(Dispatchers.IO) { runCatching { current.api.markNotificationRead(id) } } - refreshNotifications() - } - - fun markAllNotificationsRead() = viewModelScope.launch { - val current = session ?: return@launch - withContext(Dispatchers.IO) { runCatching { current.api.markAllNotificationsRead() } } - refreshNotifications() - } - - // -- The account --------------------------------------------------------- - - fun updateDisplayName(name: String) = viewModelScope.launch { - val current = session ?: return@launch - val done = withContext(Dispatchers.IO) { - runCatching { current.api.updateDisplayName(name) } - } - done.onSuccess { update { it.copy(error = null, status = "Display name updated.") } } - .onFailure { failure -> update { it.copy(error = describe(failure)) } } - } - - /** - * Changing the password invalidates this device's credential too, that - * is what changing it means. The app therefore signs out rather than - * carrying on and 401-ing on the next request for no visible reason. - */ - fun updatePassword(password: String) = viewModelScope.launch { - val current = session ?: return@launch - val done = withContext(Dispatchers.IO) { runCatching { current.api.updatePassword(password) } } - done.onSuccess { signOut() } - .onFailure { failure -> update { it.copy(error = describe(failure)) } } - } - - /** Deletes the account on the relay AND erases this phone. Irreversible. */ - fun deleteAccount() = viewModelScope.launch { - val current = session ?: return@launch - val done = withContext(Dispatchers.IO) { runCatching { current.api.deleteAccount() } } - done.onSuccess { signOut(erasingDevice = true) } - .onFailure { failure -> update { it.copy(error = describe(failure)) } } - } - - // -- Password reset (signed out) ----------------------------------------- - - /** - * The relay answers identically whatever the truth is, unknown handle, - * no address on file, an account that authenticates elsewhere. So this - * cannot report whether it worked, and the UI must not pretend it can. - */ - fun requestPasswordReset(handle: String) = viewModelScope.launch { - val config = site ?: return@launch - val done = withContext(Dispatchers.IO) { - runCatching { io.flamenet.client.RelayAccounts(config.baseUrl).requestPasswordReset(handle) } - } - _phase.value = Phase.SignedOut( - error = done.fold( - onSuccess = { "If that account exists and has an address on file, a code is on its way." }, - onFailure = { describe(it) }, - ), - ) - } - - fun confirmPasswordReset(handle: String, code: String, newPassword: String) = - viewModelScope.launch { - val config = site ?: return@launch - val done = withContext(Dispatchers.IO) { - runCatching { - io.flamenet.client.RelayAccounts(config.baseUrl) - .confirmPasswordReset(handle, code, newPassword) - } - } - _phase.value = Phase.SignedOut( - error = done.fold( - onSuccess = { "Password changed. Sign in with the new one." }, - onFailure = { describe(it) }, - ), - ) - } - - // -- Attachments --------------------------------------------------------- - - /** - * Set a profile picture and tell every contact. - * - * Reports partial delivery rather than swallowing it. "Sent to 3 of 5" - * is the truth when two contacts have a changed identity key, and silence - * would let somebody believe a picture had gone out when it had not. - */ - fun setAvatar(bytes: ByteArray, mime: String, width: Int, height: Int) = - viewModelScope.launch { - val current = session ?: return@launch - val ids = signedIn()?.buddies?.map { it.userId } ?: emptyList() - update { it.copy(busy = true, error = null) } - val done = withContext(Dispatchers.IO) { - runCatching { current.setAvatar(bytes, mime, width, height, ids) } - } - done.fold( - onSuccess = { posted -> - update { - it.copy( - busy = false, - myAvatar = posted.ref, - error = if (posted.missed > 0) { - "Picture saved, but ${posted.missed} of ${ids.size} contacts " + - "could not be told, check their keys." - } else null, - ) - } - }, - onFailure = { failure -> update { it.copy(busy = false, error = describe(failure)) } }, - ) - } - - /** Drop our picture. Contacts keep the last one they were told about. */ - fun clearAvatar() = viewModelScope.launch { - val current = session ?: return@launch - withContext(Dispatchers.IO) { runCatching { current.vault.saveMyAvatar(null) } } - update { it.copy(myAvatar = null) } - } - - fun sendImage(bytes: ByteArray, mime: String, width: Int, height: Int) = + fun setDisappearing(seconds: Int) = viewModelScope.launch { val current = session ?: return@launch val chat = signedIn()?.openChat ?: return@launch - update { it.copy(busy = true, error = null) } - val done = withContext(Dispatchers.IO) { + val peerId = chat.peerId ?: return@launch + val done = + withContext(Dispatchers.IO) { + runCatching { current.conversations.setDisappearing(peerId, seconds) } + } + update { state -> + state.copy( + openChat = + state.openChat?.copy( + disappearSeconds = current.conversations.disappearSeconds(peerId), + // Applying the timer expires anything already past it, so + // the thread has to be re-read rather than left on screen. + lines = current.history(chat.threadId), + ), + error = done.exceptionOrNull()?.let(::describe), + ) + } + } + + // -- Presence ------------------------------------------------------------. + + /** + * Choose a presence state. + */ + fun setPresence(status: String) = + viewModelScope.launch { + val current = session ?: return@launch + val done = withContext(Dispatchers.IO) { runCatching { current.api.setStatus(status) } } + done + .onSuccess { stored -> + update { it.copy(presence = displayPresence(stored), error = null) } + }.onFailure { failure -> update { it.copy(error = describe(failure)) } } + } + + /** + * Accept a correspondent the app adopted on the user's behalf. + */ + fun acceptCorrespondent(userId: Long) = + viewModelScope.launch { + val current = session ?: return@launch + val done = + withContext(Dispatchers.IO) { + runCatching { current.api.acceptConversation(userId) } + } + done + .onSuccess { refreshBuddies() } + .onFailure { failure -> update { it.copy(error = describe(failure)) } } + } + + /** Relay vocabulary -> the words on the presence chips. */ + internal fun displayPresence(stored: String): String = if (stored == "invisible") "offline" else stored + + // -- Notifications -------------------------------------------------------. + + fun refreshNotifications() = + viewModelScope.launch { + val current = session ?: return@launch + val fetched = withContext(Dispatchers.IO) { runCatching { current.api.notifications() } } + fetched + .onSuccess { list -> update { it.copy(notifications = list, error = null) } } + .onFailure { failure -> update { it.copy(error = describe(failure)) } } + } + + fun markNotificationRead(id: Long) = + viewModelScope.launch { + val current = session ?: return@launch + withContext(Dispatchers.IO) { runCatching { current.api.markNotificationRead(id) } } + refreshNotifications() + } + + fun markAllNotificationsRead() = + viewModelScope.launch { + val current = session ?: return@launch + withContext(Dispatchers.IO) { runCatching { current.api.markAllNotificationsRead() } } + refreshNotifications() + } + + // -- The account ---------------------------------------------------------. + + fun updateDisplayName(name: String) = + viewModelScope.launch { + val current = session ?: return@launch + val done = + withContext(Dispatchers.IO) { + runCatching { current.api.updateDisplayName(name) } + } + done + .onSuccess { update { it.copy(error = null, status = "Display name updated.") } } + .onFailure { failure -> update { it.copy(error = describe(failure)) } } + } + + /** + * Changing the password invalidates this device's credential too, that is + * what changing it means. + */ + fun updatePassword(password: String) = + viewModelScope.launch { + val current = session ?: return@launch + val done = withContext(Dispatchers.IO) { runCatching { current.api.updatePassword(password) } } + done + .onSuccess { signOut() } + .onFailure { failure -> update { it.copy(error = describe(failure)) } } + } + + /** Deletes the account on the relay AND erases this phone. Irreversible. */ + fun deleteAccount() = + viewModelScope.launch { + val current = session ?: return@launch + val done = withContext(Dispatchers.IO) { runCatching { current.api.deleteAccount() } } + done + .onSuccess { signOut(erasingDevice = true) } + .onFailure { failure -> update { it.copy(error = describe(failure)) } } + } + + // -- Password reset (signed out) -----------------------------------------. + + /** + * The relay answers identically whatever the truth is, unknown handle, no + * address on file, an account that authenticates elsewhere. + */ + fun requestPasswordReset(handle: String) = + viewModelScope.launch { + val config = site ?: return@launch + val done = + withContext(Dispatchers.IO) { + runCatching { + io.flamenet.client + .RelayAccounts(config.baseUrl) + .requestPasswordReset(handle) + } + } + _phase.value = + Phase.SignedOut( + error = + done.fold( + onSuccess = { "If that account exists and has an address on file, a code is on its way." }, + onFailure = { describe(it) }, + ), + ) + } + + fun confirmPasswordReset( + handle: String, + code: String, + newPassword: String, + ) = viewModelScope.launch { + val config = site ?: return@launch + val done = + withContext(Dispatchers.IO) { runCatching { - // A group thread sends to the group, not to whoever the - // thread happens to name. `peerId!!` here used to make a - // photo in a group crash rather than send. - val group = chat.group - if (group != null) current.sendGroupImage(group, bytes, mime, width, height) - else current.sendImage(chat.peerId!!, bytes, mime, width, height) + io.flamenet.client + .RelayAccounts(config.baseUrl) + .confirmPasswordReset(handle, code, newPassword) } } - done.onSuccess { sent -> + _phase.value = + Phase.SignedOut( + error = + done.fold( + onSuccess = { "Password changed. Sign in with the new one." }, + onFailure = { describe(it) }, + ), + ) + } + + // -- Attachments ---------------------------------------------------------. + + /** + * Set a profile picture and tell every contact. + */ + fun setAvatar( + bytes: ByteArray, + mime: String, + width: Int, + height: Int, + ) = viewModelScope.launch { + val current = session ?: return@launch + val ids = signedIn()?.buddies?.map { it.userId } ?: emptyList() + update { it.copy(busy = true, error = null) } + val done = + withContext(Dispatchers.IO) { + runCatching { current.setAvatar(bytes, mime, width, height, ids) } + } + done.fold( + onSuccess = { posted -> + update { + it.copy( + busy = false, + myAvatar = posted.ref, + error = + if (posted.missed > 0) { + "Picture saved, but ${posted.missed} of ${ids.size} contacts " + + "could not be told, check their keys." + } else { + null + }, + ) + } + }, + onFailure = { failure -> update { it.copy(busy = false, error = describe(failure)) } }, + ) + } + + /** Drop our picture. Contacts keep the last one they were told about. */ + fun clearAvatar() = + viewModelScope.launch { + val current = session ?: return@launch + withContext(Dispatchers.IO) { runCatching { current.vault.saveMyAvatar(null) } } + update { it.copy(myAvatar = null) } + } + + fun sendImage( + bytes: ByteArray, + mime: String, + width: Int, + height: Int, + ) = viewModelScope.launch { + val current = session ?: return@launch + val chat = signedIn()?.openChat ?: return@launch + update { it.copy(busy = true, error = null) } + val done = + withContext(Dispatchers.IO) { + runCatching { + // A group thread sends to the group, not to whoever the + // thread happens to name. + val group = chat.group + if (group != null) { + current.sendGroupImage(group, bytes, mime, width, height) + } else { + current.sendImage(chat.peerId!!, bytes, mime, width, height) + } + } + } + done + .onSuccess { sent -> update { it.copy( busy = false, @@ -2355,17 +2222,17 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { ) } }.onFailure { failure -> update { it.copy(busy = false, error = describe(failure)) } } - } + } - // -- Calls (§8.10) --------------------------------------------------------- + // -- Calls (§8.10) + // ---------------------------------------------------------. private var calls: CallManager? = null /** - * Built lazily and once. `PeerConnectionFactory.initialize` must run - * exactly once per process; a second call is not idempotent and takes the - * process with it. + * Built lazily and once. */ + /** Whether the call now ending ever reached ACTIVE. */ private var callWasConnected = false @@ -2374,104 +2241,96 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { /** Re-read the log after a call, so the Calls tab is current. */ private suspend fun refreshCallLog() { - val log = withContext(Dispatchers.IO) { - runCatching { session?.callLog() }.getOrNull() ?: emptyList() - } + val log = + withContext(Dispatchers.IO) { + runCatching { session?.callLog() }.getOrNull() ?: emptyList() + } update { it.copy(calls = log) } } private fun callManager(): CallManager { calls?.let { return it } - val manager = CallManager( - context = getApplication(), - signal = { peerId, signal -> - // Fire and forget on the IO pool: signalling must not block the - // WebRTC callback thread it is raised from. - viewModelScope.launch(Dispatchers.IO) { - runCatching { session?.sendCallSignal(peerId, signal) } - // This was `runCatching { }` and nothing else, so - // every failure here vanished. The one that matters - // is a changed key: the send path REFUSES to encrypt to - // an unreviewed identity. TOFU working exactly as - // intended, and the exception was swallowed, so the - // offer never left and the screen sat in DIALLING until - // it timed out. Reported as "calls wouldn't connect and - // it wasn't obvious why", which is precisely right. - // - // The fix is one tap and it is on a screen the - // person is not looking at: the accept-the-new-key - // banner lives in the CHAT, and somebody calling from - // the Calls tab never sees it. So say where to go. - .onFailure { failure -> - if (failure is IdentityChangedException) { - calls?.hangUp(notifyPeer = false) - update { - it.copy( - call = null, - error = "Their security code changed, so the call " - + "was not placed. Open the conversation with " - + "${nameFor(it, peerId)} to review it, then " - + "call again.", - ) + val manager = + CallManager( + context = getApplication(), + signal = { peerId, signal -> + // Fire and forget on the IO pool: signalling must not block the + // WebRTC callback thread it is raised from. + viewModelScope.launch(Dispatchers.IO) { + runCatching { session?.sendCallSignal(peerId, signal) } + // This was `runCatching { }` and nothing else, so every + // failure here vanished. + .onFailure { failure -> + if (failure is IdentityChangedException) { + calls?.hangUp(notifyPeer = false) + update { + it.copy( + call = null, + error = + "Their security code changed, so the call " + + "was not placed. Open the conversation with " + + "${nameFor(it, peerId)} to review it, then " + + "call again.", + ) + } + } + } + } + }, + onStateChanged = { state -> + // Filed once, at the end. + if (state == CallManager.State.ENDED || state == CallManager.State.IDLE) { + calls?.current?.let { active -> + if (!callFiled) { + callFiled = true + val connected = callWasConnected + viewModelScope.launch(Dispatchers.IO) { + runCatching { + session?.recordCall(active.peerId, active.incoming, connected) + } + refreshCallLog() } } } - } - }, - onStateChanged = { state -> - // Filed once, at the end. WebRTC walks DIALLING → RINGING → - // CONNECTING → ACTIVE on the way to a call, and recording each - // transition would turn one call into five rows. - // - // `everConnected` is read BEFORE the manager is torn down. - // Asking afterwards always says no, which would file every - // answered call as missed. - if (state == CallManager.State.ENDED || state == CallManager.State.IDLE) { - calls?.current?.let { active -> - if (!callFiled) { - callFiled = true - val connected = callWasConnected - viewModelScope.launch(Dispatchers.IO) { - runCatching { - session?.recordCall(active.peerId, active.incoming, connected) - } - refreshCallLog() - } - } - } - } else { - callFiled = false - when (state) { - CallManager.State.ACTIVE -> callWasConnected = true - // A new call starting, outgoing OR incoming. placeCall - // resets this too, but an incoming call never went - // through placeCall, and inheriting the previous - // call's `true` filed a missed call as answered. - CallManager.State.DIALLING, - CallManager.State.RINGING -> callWasConnected = false - else -> Unit - } - } - update { phase -> - val active = calls?.current - if (state == CallManager.State.IDLE || active == null) { - phase.copy(call = null) } else { - phase.copy( - call = (phase.call ?: Phase.CallState( - state = state, - peerId = active.peerId, - peerName = nameFor(phase, active.peerId), - incoming = active.incoming, - )).copy(state = state, video = active.video), - ) + callFiled = false + when (state) { + CallManager.State.ACTIVE -> callWasConnected = true + + // A new call starting, outgoing OR incoming. placeCall + // resets this too, but an incoming call never went + // through placeCall, and inheriting the previous call's + // `true` filed a missed call as answered. + CallManager.State.DIALLING, + CallManager.State.RINGING, + -> callWasConnected = false + + else -> Unit + } } - } - }, - ) + update { phase -> + val active = calls?.current + if (state == CallManager.State.IDLE || active == null) { + phase.copy(call = null) + } else { + phase.copy( + call = + ( + phase.call ?: Phase.CallState( + state = state, + peerId = active.peerId, + peerName = nameFor(phase, active.peerId), + incoming = active.incoming, + ) + ).copy(state = state, video = active.video), + ) + } + } + }, + ) manager.onStaleOffer = { fromUser, _ -> - // Too old to ring, the caller gave up long ago, but not too old - // to matter: file it so the Calls tab shows who tried. + // Too old to ring, the caller gave up long ago, but not too old to + // matter: file it so the Calls tab shows who tried. viewModelScope.launch(Dispatchers.IO) { runCatching { session?.recordCall(fromUser, incoming = true, connected = false) } refreshCallLog() @@ -2490,48 +2349,28 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { /** * Put whoever just contacted us onto the buddy list. - * - * Without this, first contact is invisible. The Messages screen - * renders the buddy list, and the buddy list is one-directional, the - * person who added you appears in YOUR list nowhere. Their message - * arrived, decrypted, filed and counted as unread, and there was no row - * anywhere to open: an unread badge over an empty list. Same for a call, - * it rang from "user 32". The envelope names the sender by id only - * (sealed sender means the relay cannot annotate it), so the id is - * resolved here and added like any other contact. - * - * Blocked users stay invisible, blocking is the one signal that says - * "do not surface this person to me", and auto-adding would undo it. */ private fun adoptStrangers(threadIds: Set) { val current = session ?: return val known = signedIn()?.buddies?.map { it.userId }?.toSet() ?: emptySet() - val strangers = threadIds - .filter { it.startsWith("user:") } - .mapNotNull { it.removePrefix("user:").toLongOrNull() } - .filter { it != current.userId && it !in known && it !in adopting } + val strangers = + threadIds + .filter { it.startsWith("user:") } + .mapNotNull { it.removePrefix("user:").toLongOrNull() } + .filter { it != current.userId && it !in known && it !in adopting } if (strangers.isEmpty()) return adopting += strangers viewModelScope.launch(Dispatchers.IO) { var added = false - // Fail closed. This used to default to "nobody is blocked" - // when the call failed, which is the wrong direction for a safety - // check: a parse bug in the block list silently became "adopt the - // person you blocked". If we cannot establish who is blocked, we - // adopt nobody and try again on the next poll, a stranger showing - // up a few seconds late is a cosmetic problem; re-adding somebody - // who blocked their way out of a conversation is not. + // Fail closed. val blocked = runCatching { current.api.blocked() }.getOrNull() ?: return@launch for (id in strangers) { runCatching { if (id in blocked) return@runCatching val who = current.api.userInfo(id) ?: return@runCatching if (who.handle.isNotBlank()) { - // `auto = true`: this add is the app's doing, not - // the user's. It keeps the stranger's first message - // visible without deciding on their behalf that the - // sender is a contact, the thread shows the choice - // instead. + // `auto = true`: this add is the app's doing, not the + // user's. current.api.addBuddy(who.handle, auto = true) added = true } @@ -2546,19 +2385,11 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { /** * Route incoming call signals into the manager. - * - * Installed at sign-in, not on first outgoing call. This hookup - * lived only inside [callManager], and a manager was only ever built to - * PLACE a call, so a phone that had never dialled had a null handler, - * `Session.receive` dropped every incoming offer on the floor, and an - * incoming call showed nothing at all. Re-run on every sign-in: the - * handler closes over nothing, but the Session object it hangs off is - * new each time. */ private fun installCallSignalHandler() { session?.onCallSignal = { fromUser, signal, sentAt -> - // A caller can be a stranger too, name them before the banner - // shows "user 32" ringing. + // A caller can be a stranger too, name them before the banner shows + // "user 32" ringing. adoptStrangers(setOf("user:$fromUser")) viewModelScope.launch(Dispatchers.IO) { val manager = callManager() @@ -2569,28 +2400,27 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { } /** - * Ordinary SharedPreferences, NOT the vault. This records that a - * warning was shown, it is not a secret, and putting it in the encrypted - * store would mean re-showing the warning on every sign-out, which is how - * a one-time prompt becomes one people click past. + * Ordinary SharedPreferences, NOT the vault. */ private fun callPrefs(): android.content.SharedPreferences = getApplication().getSharedPreferences("calls", android.content.Context.MODE_PRIVATE) - private fun nameFor(phase: Phase.SignedIn, userId: Long): String = - if (userId == phase.userId) "You" - else phase.buddies.firstOrNull { it.userId == userId } - ?.let { it.displayName.ifBlank { it.handle } } ?: "user $userId" + private fun nameFor( + phase: Phase.SignedIn, + userId: Long, + ): String = + if (userId == phase.userId) { + "You" + } else { + phase.buddies + .firstOrNull { it.userId == userId } + ?.let { it.displayName.ifBlank { it.handle } } ?: "user $userId" + } /** * Ask to place a call. - * - * The first call on this device explains what a direct connection - * discloses, and waits. A peer-to-peer call shows the other person your - * IP address; that is what makes it direct, and it is not something to - * find out afterwards. Asked once, not every time, a prompt people see - * repeatedly is a prompt they stop reading. */ + /** Remembered across the privacy prompt, so "Understood, call" keeps it. */ private var pendingCallVideo = false @@ -2607,10 +2437,16 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { update { it.copy() } } - fun requestCall(peerId: Long, video: Boolean = false) { + fun requestCall( + peerId: Long, + video: Boolean = false, + ) { pendingCallVideo = video - if (callPrefs().getBoolean(CALL_PRIVACY_ACKNOWLEDGED, false)) placeCall(peerId, video) - else update { it.copy(callPrivacyPrompt = peerId) } + if (callPrefs().getBoolean(CALL_PRIVACY_ACKNOWLEDGED, false)) { + placeCall(peerId, video) + } else { + update { it.copy(callPrivacyPrompt = peerId) } + } } fun acknowledgeCallPrivacy(andCall: Boolean) { @@ -2620,7 +2456,10 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { if (andCall && peerId != null) placeCall(peerId, pendingCallVideo) } - private fun placeCall(peerId: Long, video: Boolean = false) = viewModelScope.launch { + private fun placeCall( + peerId: Long, + video: Boolean = false, + ) = viewModelScope.launch { // Supervision: refused with the reason, before anything is gathered. if (ParentalControls.callsDisabled(getApplication())) { update { it.copy(error = "Calls are turned off on this device.") } @@ -2630,32 +2469,33 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { val current = session ?: return@launch val manager = callManager() val turn = withContext(Dispatchers.IO) { current.turnCredentials() } - // Whether the offer left, and to how many devices. "I called - // them and nothing happened on the other phone" had no evidence on - // either side: the send path reports partial delivery to the UI as a - // banner, but a call places its offer from inside the WebRTC callback - // and that banner is never shown. A call to somebody with no registered - // device looked identical to a call that rang. - android.util.Log.i("fnmsg-call", - "placing call to $peerId, video=$video, turn=${if (turn == null) "none" else "yes"}") + // Whether the offer left, and to how many devices. + android.util.Log.i( + "fnmsg-call", + "placing call to $peerId, video=$video, turn=${if (turn == null) "none" else "yes"}", + ) update { - it.copy(call = Phase.CallState( - state = CallManager.State.DIALLING, - peerId = peerId, - peerName = nameFor(it, peerId), - incoming = false, - video = video, - relayless = turn == null, - )) + it.copy( + call = + Phase.CallState( + state = CallManager.State.DIALLING, + peerId = peerId, + peerName = nameFor(it, peerId), + incoming = false, + video = video, + relayless = turn == null, + ), + ) } withContext(Dispatchers.IO) { manager.call(peerId, turn, video) } } - fun acceptCall() = viewModelScope.launch { - val manager = calls ?: return@launch - val turn = withContext(Dispatchers.IO) { session?.turnCredentials() } - withContext(Dispatchers.IO) { manager.accept(turn) } - } + fun acceptCall() = + viewModelScope.launch { + val manager = calls ?: return@launch + val turn = withContext(Dispatchers.IO) { session?.turnCredentials() } + withContext(Dispatchers.IO) { manager.accept(turn) } + } fun declineCall() { calls?.decline() @@ -2668,9 +2508,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { } /** - * The live manager, for the call screen's video renderers only. Views - * otherwise take plain values; a video surface is the one thing that - * needs the track objects and the shared EGL context. + * The live manager, for the call screen's video renderers only. */ fun activeCallManager(): CallManager? = calls @@ -2679,69 +2517,62 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { update { it.copy(call = it.call?.copy(muted = muted)) } } - // -- Member lookup --------------------------------------------------------- + // -- Member lookup + // ---------------------------------------------------------. /** * Find out who a handle belongs to before adding them. - * - * Look up, then add, deliberately two steps. Adding straight from a - * typed string means a typo silently becomes a contact who is a stranger, - * and the first thing sent to them is a message meant for somebody else. - * Seeing the display name first is what catches that. - * - * A miss is reported as "no such handle" and nothing more. The relay - * answers the same way for an account that does not exist and one that has - * not enrolled, on purpose, so that this cannot be used to enumerate - * accounts, and this client must not invent a distinction the relay - * refused to make. */ - fun lookupMember(handle: String) = viewModelScope.launch { - val current = session ?: return@launch - val trimmed = handle.trim() - if (trimmed.isEmpty()) return@launch - update { it.copy(busy = true, lookedUp = null, lookupMiss = null, error = null) } - val found = withContext(Dispatchers.IO) { runCatching { current.api.lookup(trimmed) } } - update { - it.copy( - busy = false, - lookedUp = found.getOrNull(), - lookupMiss = if (found.getOrNull() == null) "No account called \"$trimmed\" here." else null, - ) + fun lookupMember(handle: String) = + viewModelScope.launch { + val current = session ?: return@launch + val trimmed = handle.trim() + if (trimmed.isEmpty()) return@launch + update { it.copy(busy = true, lookedUp = null, lookupMiss = null, error = null) } + val found = withContext(Dispatchers.IO) { runCatching { current.api.lookup(trimmed) } } + update { + it.copy( + busy = false, + lookedUp = found.getOrNull(), + lookupMiss = if (found.getOrNull() == null) "No account called \"$trimmed\" here." else null, + ) + } } - } fun clearLookup() = update { it.copy(lookedUp = null, lookupMiss = null) } - // -- Registered devices (§8.3a) ------------------------------------------ + // -- Registered devices (§8.3a) ------------------------------------------. - fun refreshDevices() = viewModelScope.launch { - val current = session ?: return@launch - val fetched = withContext(Dispatchers.IO) { runCatching { current.ownDevices() } } - fetched.onSuccess { list -> update { it.copy(registeredDevices = list) } } - } - - fun removeDevice(deviceId: String) = viewModelScope.launch { - val current = session ?: return@launch - update { it.copy(busy = true, error = null) } - val done = withContext(Dispatchers.IO) { runCatching { current.revokeDevice(deviceId) } } - done.onSuccess { - val fresh = withContext(Dispatchers.IO) { runCatching { current.ownDevices() } } - update { - it.copy(busy = false, - registeredDevices = fresh.getOrDefault(it.registeredDevices)) - } - }.onFailure { failure -> - update { it.copy(busy = false, error = describe(failure)) } + fun refreshDevices() = + viewModelScope.launch { + val current = session ?: return@launch + val fetched = withContext(Dispatchers.IO) { runCatching { current.ownDevices() } } + fetched.onSuccess { list -> update { it.copy(registeredDevices = list) } } } - } - // -- History backup (D4) ------------------------------------------------- + fun removeDevice(deviceId: String) = + viewModelScope.launch { + val current = session ?: return@launch + update { it.copy(busy = true, error = null) } + val done = withContext(Dispatchers.IO) { runCatching { current.revokeDevice(deviceId) } } + done + .onSuccess { + val fresh = withContext(Dispatchers.IO) { runCatching { current.ownDevices() } } + update { + it.copy( + busy = false, + registeredDevices = fresh.getOrDefault(it.registeredDevices), + ) + } + }.onFailure { failure -> + update { it.copy(busy = false, error = describe(failure)) } + } + } + + // -- History backup (D4) -------------------------------------------------. /** * The vault's history, sealed under a passphrase the user chose. - * - * Off the main thread on purpose: the KDF runs 600k rounds, which is the - * point of it, and would freeze the UI for a visible beat. */ suspend fun exportHistory(passphrase: CharArray): ByteArray? = withContext(Dispatchers.Default) { @@ -2749,76 +2580,71 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { } /** Merge a backup file in. Returns how much of it was actually new. */ - suspend fun importHistory(bytes: ByteArray, passphrase: CharArray): Result = + suspend fun importHistory( + bytes: ByteArray, + passphrase: CharArray, + ): Result = withContext(Dispatchers.Default) { - val v = vault - ?: return@withContext Result.failure(IllegalStateException("Sign in first.")) + val v = + vault + ?: return@withContext Result.failure(IllegalStateException("Sign in first.")) runCatching { v.mergeImportedPayload( HistoryExport.open(bytes, passphrase), - nowSeconds = DeviceClock.expirySeconds()) + nowSeconds = DeviceClock.expirySeconds(), + ) } } - // -- Stories (§6.6) ------------------------------------------------------ + // -- Stories (§6.6) ------------------------------------------------------. - fun refreshStories() = viewModelScope.launch { - val current = session ?: return@launch - // A failed fetch must not empty the strip. Defaulting to an - // empty list here did not merely look wrong, it CLOBBERED whatever was - // on screen, so one hiccup wiped the stories the user was looking at, - // and the poll below repeated that on every tick. - val fetched = withContext(Dispatchers.IO) { runCatching { current.stories() } } - val live = fetched.getOrNull() ?: return@launch - update { it.copy(stories = live) } - } + fun refreshStories() = + viewModelScope.launch { + val current = session ?: return@launch + // A failed fetch must not empty the strip. + val fetched = withContext(Dispatchers.IO) { runCatching { current.stories() } } + val live = fetched.getOrNull() ?: return@launch + update { it.copy(stories = live) } + } /** * Post a story to every contact. - * - * To contacts, one by one, down their existing ratchets, there is - * no broadcast channel and there should not be. A relay-side story feed - * would need the relay to know who may read it, which is a list of your - * contacts handed to the server for a feature that works without it. */ + /** * Save a story you posted yourself, before it expires (ios#34). - * - * The rule is `Vault.isSaveable`, checked here rather than only where - * the menu item is drawn. Saving somebody else's story keeps a copy of - * something they were told would disappear. - * - * The message says the copy has LEFT the disappearing model and where - * it went. "Saved" alone would be true and useless, somebody not told - * that may reasonably assume the expiry still covers it. */ - fun saveStory(story: Vault.Story) = viewModelScope.launch { - val current = session ?: return@launch - val ref = story.ref - if (!Vault.isSaveable(story.mine, ref != null) || ref == null) return@launch - val saved = withContext(Dispatchers.IO) { - runCatching { - val bytes = current.attachmentBytes(ref, getApplication().cacheDir) - StorySave.save(getApplication(), bytes, ref.mime, ref.id) - }.getOrNull() - } - update { - val message = if (saved != null) { - "Saved to your gallery as $saved. That copy is yours to keep, " + - "it will not disappear when the story does." - } else { - "Could not save that story." + fun saveStory(story: Vault.Story) = + viewModelScope.launch { + val current = session ?: return@launch + val ref = story.ref + if (!Vault.isSaveable(story.mine, ref != null) || ref == null) return@launch + val saved = + withContext(Dispatchers.IO) { + runCatching { + val bytes = current.attachmentBytes(ref, getApplication().cacheDir) + StorySave.save(getApplication(), bytes, ref.mime, ref.id) + }.getOrNull() + } + update { + val message = + if (saved != null) { + "Saved to your gallery as $saved. That copy is yours to keep, " + + "it will not disappear when the story does." + } else { + "Could not save that story." + } + it.copy(error = message) } - it.copy(error = message) } - } /** Change who may see this account's stories. */ - fun setStoryAudience(audience: Session.StoryAudience) = viewModelScope.launch { - val current = session ?: return@launch - withContext(Dispatchers.IO) { runCatching { current.saveStoryAudience(audience) } } - update { it.copy(storyAudience = audience) } - } + fun setStoryAudience(audience: Session.StoryAudience) = + viewModelScope.launch { + val current = session ?: return@launch + withContext(Dispatchers.IO) { runCatching { current.saveStoryAudience(audience) } } + update { it.copy(storyAudience = audience) } + } /** A picked image, held until the person captions it and taps Post. */ data class StoryDraft( @@ -2833,8 +2659,13 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { ) /** Hold picked media for the composer sheet rather than posting it. */ - fun stageStory(bytes: ByteArray, mime: String, width: Int, height: Int, - duration: Double = 0.0) { + fun stageStory( + bytes: ByteArray, + mime: String, + width: Int, + height: Int, + duration: Double = 0.0, + ) { update { it.copy(storyDraft = StoryDraft(bytes, mime, width, height, duration)) } } @@ -2844,156 +2675,205 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { fun discardStagedStory() = update { it.copy(storyDraft = null) } /** The deliberate Post from the composer sheet. */ - fun postStagedStory(caption: String?, audience: Session.StoryAudience? = null, - streamUrl: String? = null) { + fun postStagedStory( + caption: String?, + audience: Session.StoryAudience? = null, + streamUrl: String? = null, + ) { val draft = signedIn()?.storyDraft ?: return update { it.copy(storyDraft = null) } - postStory(draft.bytes, draft.mime, draft.width, draft.height, - caption?.takeIf { it.isNotBlank() }, audience, draft.duration, - streamUrl?.takeIf { it.isNotBlank() }) + postStory( + draft.bytes, + draft.mime, + draft.width, + draft.height, + caption?.takeIf { it.isNotBlank() }, + audience, + draft.duration, + streamUrl?.takeIf { it.isNotBlank() }, + ) } /** * Post a story that is only words. - * - * Same audience rule as a picture: the audience decides who is SENT it, - * because there is no copy on the relay that anybody could be denied. */ - fun postTextStory(text: String) = viewModelScope.launch { + fun postTextStory(text: String) = + viewModelScope.launch { + val current = session ?: return@launch + val contacts = signedIn()?.buddies?.map { it.userId }.orEmpty() + if (contacts.isEmpty()) { + update { it.copy(error = "Add a contact before posting a story.") } + return@launch + } + val recipients = current.storyAudience().recipients(contacts) + if (recipients.isEmpty()) { + update { + it.copy( + error = + "Nobody is in your story audience, so this " + + "would go to no one. Change who can see your stories on Account.", + ) + } + return@launch + } + update { it.copy(busy = true, error = null) } + val done = + withContext(Dispatchers.IO) { + runCatching { + current.postTextStory( + text, + recipients, + MediaSettings(getApplication()).storyLifetimeSeconds, + ) + } + } + done + .onSuccess { posted -> + update { + it.copy( + busy = false, + stories = current.stories(), + error = + if (posted.missed == 0) { + null + } else { + "Posted to ${posted.reached} of " + + "${posted.reached + posted.missed} contacts." + }, + ) + } + }.onFailure { failure -> + update { it.copy(busy = false, error = failure.message ?: "Couldn't post that.") } + } + } + + /** + * React to a message, or take it back with an empty [emoji]. + */ + + /** + * Change the wording of a message already sent (§6.15). + */ + fun editMessage( + line: Session.Line, + chat: Phase.Chat, + newText: String, + ) = viewModelScope.launch { val current = session ?: return@launch - val contacts = signedIn()?.buddies?.map { it.userId }.orEmpty() - if (contacts.isEmpty()) { - update { it.copy(error = "Add a contact before posting a story.") } - return@launch - } - val recipients = current.storyAudience().recipients(contacts) - if (recipients.isEmpty()) { - update { - it.copy(error = "Nobody is in your story audience, so this " - + "would go to no one. Change who can see your stories on Account.") - } - return@launch - } - update { it.copy(busy = true, error = null) } - val done = withContext(Dispatchers.IO) { + val mid = line.id ?: return@launch + withContext(Dispatchers.IO) { runCatching { - current.postTextStory(text, recipients, - MediaSettings(getApplication()).storyLifetimeSeconds) + current.editMessage( + chat.threadId, + mid, + newText, + peerUserId = chat.peerId, + group = chat.group, + ) } } - done.onSuccess { posted -> - update { - it.copy(busy = false, stories = current.stories(), - error = if (posted.missed == 0) null - else "Posted to ${posted.reached} of " - + "${posted.reached + posted.missed} contacts.") + update { + it.copy( + openChat = + it.openChat?.copy( + lines = current.history(chat.threadId), + pinnedMessageId = current.pinnedMessage(chat.threadId), + ), + ) + } + } + + /** Pin a message, or take the pin down by pinning the empty id (§6.19). */ + fun pinMessage( + line: Session.Line, + chat: Phase.Chat, + pin: Boolean, + ) = viewModelScope.launch { + val current = session ?: return@launch + val mid = line.id ?: return@launch + withContext(Dispatchers.IO) { + runCatching { + current.pinMessage( + chat.threadId, + if (pin) mid else "", + peerUserId = chat.peerId, + group = chat.group, + ) } - }.onFailure { failure -> - update { it.copy(busy = false, error = failure.message ?: "Couldn't post that.") } + } + update { + it.copy( + openChat = + it.openChat?.copy( + lines = current.history(chat.threadId), + pinnedMessageId = current.pinnedMessage(chat.threadId), + ), + ) } } - /** - * React to a message, or take it back with an empty [emoji]. - * - * The transcript is rewritten by [Session.react] before the send, so - * the tap registers immediately and a failed send puts back what was - * there. All this has to do is re-read the thread afterwards. - */ - /** - * Change the wording of a message already sent (§6.15). - * - * Refreshes the open chat from the transcript afterwards, like - * `reactToMessage`, the edit is applied to storage first and the screen - * reads from storage, so without this the correction lands and is invisible - * until the next poll. - */ - fun editMessage(line: Session.Line, chat: Phase.Chat, newText: String) = - viewModelScope.launch { - val current = session ?: return@launch - val mid = line.id ?: return@launch - withContext(Dispatchers.IO) { - runCatching { - current.editMessage(chat.threadId, mid, newText, - peerUserId = chat.peerId, group = chat.group) - } - } - update { - it.copy(openChat = it.openChat?.copy( - lines = current.history(chat.threadId), - pinnedMessageId = current.pinnedMessage(chat.threadId))) - } - } - - /** Pin a message, or take the pin down by pinning the empty id (§6.19). */ - fun pinMessage(line: Session.Line, chat: Phase.Chat, pin: Boolean) = - viewModelScope.launch { - val current = session ?: return@launch - val mid = line.id ?: return@launch - withContext(Dispatchers.IO) { - runCatching { - current.pinMessage(chat.threadId, if (pin) mid else "", - peerUserId = chat.peerId, group = chat.group) - } - } - update { - it.copy(openChat = it.openChat?.copy( - lines = current.history(chat.threadId), - pinnedMessageId = current.pinnedMessage(chat.threadId))) - } - } - /** * Pass a message's words on to another conversation (§6.16). - * - * Takes the TEXT, not the line. A forward carries the words and nothing - * about where they came from, and a signature that accepted the whole line - * would make it easy to start carrying the author "just for context", - * which is the one thing §6.16 exists to prevent. */ - fun forwardMessage(text: String, toPeerUserId: Long?, toGroup: RelayClient.Group?) = - viewModelScope.launch { - val current = session ?: return@launch - withContext(Dispatchers.IO) { - runCatching { current.forwardMessage(text, toPeerUserId, toGroup) } - } - // The forward lands in ANOTHER thread, so the chat on screen is - // not the one that changed. Only the open chat is re-read, which is - // correct: reloading the destination would be reloading a screen - // nobody is looking at. - update { - it.copy(openChat = it.openChat?.let { chat -> - chat.copy(lines = current.history(chat.threadId)) - }) - } + fun forwardMessage( + text: String, + toPeerUserId: Long?, + toGroup: RelayClient.Group?, + ) = viewModelScope.launch { + val current = session ?: return@launch + withContext(Dispatchers.IO) { + runCatching { current.forwardMessage(text, toPeerUserId, toGroup) } } + // The forward lands in ANOTHER thread, so the chat on screen is not + // the one that changed. + update { + it.copy( + openChat = + it.openChat?.let { chat -> + chat.copy(lines = current.history(chat.threadId)) + }, + ) + } + } - fun reactToMessage(emoji: String, line: Session.Line, chat: Phase.Chat) = - viewModelScope.launch { - val current = session ?: return@launch - val mid = line.id ?: return@launch - // Whose message it is, which is what the other side needs to find - // it: our own id for something we sent, theirs for something they did. - val author = if (line.mine) current.userId - else line.author.takeIf { it != 0L } ?: chat.peerId ?: 0L - withContext(Dispatchers.IO) { - runCatching { - current.react(emoji, chat.threadId, mid, author, - peerUserId = chat.peerId, group = chat.group) - } + fun reactToMessage( + emoji: String, + line: Session.Line, + chat: Phase.Chat, + ) = viewModelScope.launch { + val current = session ?: return@launch + val mid = line.id ?: return@launch + // Whose message it is, which is what the other side needs to find + // it: our own id for something we sent, theirs for something they + // did. + val author = + if (line.mine) { + current.userId + } else { + line.author.takeIf { it != 0L } ?: chat.peerId ?: 0L } - update { - it.copy(openChat = it.openChat?.copy( - lines = current.history(chat.threadId))) + withContext(Dispatchers.IO) { + runCatching { + current.react( + emoji, + chat.threadId, + mid, + author, + peerUserId = chat.peerId, + group = chat.group, + ) } } + update { + it.copy( + openChat = + it.openChat?.copy(lines = current.history(chat.threadId)), + ) + } + } /** * Wire the sealing policy and counters into a fresh session (ios#16). - * - * In one place, because two call sites building a `Session` and only - * one of them applying the policy is how a setting silently stops working - * on one of the two sign-in paths. */ private fun wireSealingPolicy(session: Session) { val settings = MediaSettings(getApplication()) @@ -3005,11 +2885,11 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { /** * The reasons a report may give (§6.25). - * - * Enumerated, matching the relay. A reason the relay rejects is a - * report somebody believed they had filed. */ - enum class ReportReason(val wire: String, val label: String) { + enum class ReportReason( + val wire: String, + val label: String, + ) { ABUSE("abuse", "Abusive content"), HARASSMENT("harassment", "Harassment"), SPAM("spam", "Spam"), @@ -3020,16 +2900,6 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { /** * File a report about a story (§6.25). - * - * `content` leaves the device readable. That is the point and the - * cost: the relay cannot open a sealed story, so a report about one is - * only actionable if the reporter hands over what they saw. The caller - * must have obtained consent naming that consequence, this function does - * not ask, and must never be reached from a control that does not. - * - * Sends only what it is passed. It does not reach into the thread for - * surrounding context: §6.25 forbids including anything the reporter did - * not choose to include. */ fun reportStory( story: Vault.Story, @@ -3038,26 +2908,24 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { note: String, ) = viewModelScope.launch { val current = session ?: return@launch - val done = withContext(Dispatchers.IO) { - runCatching { - current.reportStory(story, reason.wire, includeContent, note) + val done = + withContext(Dispatchers.IO) { + runCatching { + current.reportStory(story, reason.wire, includeContent, note) + } + } + val message = + if (done.isSuccess) { + "Report sent. Whoever runs this server will see it; you will not be " + + "told what they do." + } else { + "That report could not be sent." } - } - val message = if (done.isSuccess) { - "Report sent. Whoever runs this server will see it; you will not be " + - "told what they do." - } else { - "That report could not be sent." - } update { it.copy(error = message) } } /** * File a report about a message (§6.25, android#20). - * - * Same consent requirement as `reportStory`: the sheet that reaches - * this must have said, in words, that a readable copy leaves the device - * and cannot be taken back. */ fun reportMessage( text: String, @@ -3067,99 +2935,135 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { note: String, ) = viewModelScope.launch { val current = session ?: return@launch - val done = withContext(Dispatchers.IO) { - runCatching { - current.reportMessage(text, senderId, reason.wire, includeContent, note) + val done = + withContext(Dispatchers.IO) { + runCatching { + current.reportMessage(text, senderId, reason.wire, includeContent, note) + } + } + val message = + if (done.isSuccess) { + "Report sent. Whoever runs this server will see it; you will not be " + + "told what they do." + } else { + // A relay may decline reports entirely (reports_not_accepted, 501). + "That report could not be sent. This server may not take reports." } - } - val message = if (done.isSuccess) { - "Report sent. Whoever runs this server will see it; you will not be " + - "told what they do." - } else { - // A relay may decline reports entirely (reports_not_accepted, - // 501). That is a property of the server, not a mistake the - // reporter made, and the message should not read like one. - "That report could not be sent. This server may not take reports." - } update { it.copy(error = message) } } /** Counts per emoji, for the author's own view of their story. */ fun storyTally(storyKey: String): Map = - session?.reactionsFor(storyKey).orEmpty().values - .groupingBy { it }.eachCount() + session + ?.reactionsFor(storyKey) + .orEmpty() + .values + .groupingBy { it } + .eachCount() fun myStoryReaction(storyKey: String): String? = session?.myReactionTo(storyKey) /** React to somebody's story. Goes to the author alone. */ - fun reactToStory(emoji: String, story: Vault.Story) = viewModelScope.launch { + fun reactToStory( + emoji: String, + story: Vault.Story, + ) = viewModelScope.launch { val current = session ?: return@launch withContext(Dispatchers.IO) { runCatching { current.reactToStory(emoji, story) } } update { it.copy(stories = current.stories()) } } /** Reply privately to somebody's story, in the ordinary conversation. */ - fun replyToStory(body: String, story: Vault.Story) = viewModelScope.launch { + fun replyToStory( + body: String, + story: Vault.Story, + ) = viewModelScope.launch { val current = session ?: return@launch - val done = withContext(Dispatchers.IO) { - runCatching { current.replyToStory(body, story) } - } + val done = + withContext(Dispatchers.IO) { + runCatching { current.replyToStory(body, story) } + } done.onFailure { failure -> update { it.copy(error = failure.message ?: "Couldn't send that reply.") } } } /** Stop showing somebody's stories on this device. */ - fun setStoriesMuted(authorId: Long, muted: Boolean) = viewModelScope.launch { + fun setStoriesMuted( + authorId: Long, + muted: Boolean, + ) = viewModelScope.launch { val current = session ?: return@launch withContext(Dispatchers.IO) { current.setStoriesMuted(authorId, muted) } update { it.copy(stories = current.stories()) } } - fun postStory(bytes: ByteArray, mime: String, width: Int, height: Int, caption: String?, - /** THIS story's audience from the composer; null = the account setting. */ - audienceOverride: Session.StoryAudience? = null, - duration: Double = 0.0, - streamUrl: String? = null) = - viewModelScope.launch { - val current = session ?: return@launch - val contacts = signedIn()?.buddies?.map { it.userId }.orEmpty() - if (contacts.isEmpty()) { - update { it.copy(error = "Add a contact before posting a story.") } - return@launch + fun postStory( + bytes: ByteArray, + mime: String, + width: Int, + height: Int, + caption: String?, + /** THIS story's audience from the composer; null = the account setting. */ + audienceOverride: Session.StoryAudience? = null, + duration: Double = 0.0, + streamUrl: String? = null, + ) = viewModelScope.launch { + val current = session ?: return@launch + val contacts = signedIn()?.buddies?.map { it.userId }.orEmpty() + if (contacts.isEmpty()) { + update { it.copy(error = "Add a contact before posting a story.") } + return@launch + } + // The audience decides who is SENT the bytes. + val recipients = (audienceOverride ?: current.storyAudience()).recipients(contacts) + if (recipients.isEmpty()) { + update { + it.copy( + error = + "Nobody is in your story audience, so this " + + "would go to no one. Change who can see your stories on Account.", + ) } - // The audience decides who is SENT the bytes. There is no copy - // on the relay that somebody could be denied, see Session.StoryAudience. - val recipients = (audienceOverride ?: current.storyAudience()).recipients(contacts) - if (recipients.isEmpty()) { - update { - it.copy(error = "Nobody is in your story audience, so this " - + "would go to no one. Change who can see your stories on Account.") - } - return@launch - } - update { it.copy(busy = true, error = null) } - val done = withContext(Dispatchers.IO) { + return@launch + } + update { it.copy(busy = true, error = null) } + val done = + withContext(Dispatchers.IO) { runCatching { - current.postStory(bytes, mime, width, height, caption, recipients, - duration, streamUrl, - lifetimeSeconds = MediaSettings(getApplication()) - .storyLifetimeSeconds) + current.postStory( + bytes, + mime, + width, + height, + caption, + recipients, + duration, + streamUrl, + lifetimeSeconds = + MediaSettings(getApplication()) + .storyLifetimeSeconds, + ) } } - done.onSuccess { posted -> + done + .onSuccess { posted -> update { it.copy( busy = false, stories = current.stories(), // Partial delivery stated rather than hidden: a contact // with no registered device cannot receive anything. - error = if (posted.missed == 0) null - else "Posted to ${posted.reached} of ${posted.reached + posted.missed} contacts.", + error = + if (posted.missed == 0) { + null + } else { + "Posted to ${posted.reached} of ${posted.reached + posted.missed} contacts." + }, ) } }.onFailure { failure -> update { it.copy(busy = false, error = describe(failure)) } } - } + } fun openStory(story: Vault.Story) { session?.markStorySeen(story.id) @@ -3168,47 +3072,39 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { fun closeStory() = update { it.copy(openStory = null) } - // -- Voice notes (§6.6) -------------------------------------------------- + // -- Voice notes (§6.6) --------------------------------------------------. private var recording: VoiceNote.Recording? = null private var recordingTicker: kotlinx.coroutines.Job? = null /** * Begin recording. - * - * The caller must already hold `RECORD_AUDIO`. Asking here would raise - * the dialog after the user has pressed record, so the first note of every - * install would be lost to a prompt. */ fun startRecording() { if (recording != null) return viewModelScope.launch { - // Off the main thread. `MediaRecorder.prepare()` and - // `start()` acquire the microphone, and acquiring hardware blocks, - // on a device whose audio route is busy that is the UI thread - // stalled for as long as it takes. `stopAndSendRecording` below - // already went through Dispatchers.IO for exactly this reason; - // starting did not, and starting is the half that has to wait for - // the route to come up. The iOS recorder had the same bug and - // froze its main thread for SIXTY SECONDS on a simulator. - val started = withContext(Dispatchers.IO) { - runCatching { VoiceNote.start(getApplication().cacheDir) } - } - val begun = started.getOrElse { failure -> - update { it.copy(error = "Could not start recording: ${failure.message}") } - return@launch - } + // Off the main thread. + val started = + withContext(Dispatchers.IO) { + runCatching { VoiceNote.start(getApplication().cacheDir) } + } + val begun = + started.getOrElse { failure -> + update { it.copy(error = "Could not start recording: ${failure.message}") } + return@launch + } recording = begun // A visible timer, because a recorder with no feedback is one // people talk into after it has already stopped. - recordingTicker = viewModelScope.launch { - while (recording != null) { - val seconds = recording?.elapsedSeconds - update { it.copy(openChat = it.openChat?.copy(recordingSeconds = seconds)) } - kotlinx.coroutines.delay(200) + recordingTicker = + viewModelScope.launch { + while (recording != null) { + val seconds = recording?.elapsedSeconds + update { it.copy(openChat = it.openChat?.copy(recordingSeconds = seconds)) } + kotlinx.coroutines.delay(200) + } } - } } } @@ -3217,9 +3113,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { recordingTicker?.cancel() recordingTicker = null // Releasing the recorder blocks too, so it goes the same way as - // starting and stopping. The UI state is cleared immediately, the - // user pressed Cancel and should not watch a bar linger while the - // hardware is handed back. + // starting and stopping. val discarded = recording recording = null update { it.copy(openChat = it.openChat?.copy(recordingSeconds = null)) } @@ -3230,71 +3124,68 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { /** * Stop, and send what was captured. - * - * A recording too short to carry speech is dropped rather than delivered: - * a note that plays silence looks like a delivery bug, and the user who - * fumbled the button already knows they did. */ - fun stopAndSendRecording() = viewModelScope.launch { - recordingTicker?.cancel() - recordingTicker = null - val finished = withContext(Dispatchers.IO) { recording?.stop() } - recording = null - update { it.copy(openChat = it.openChat?.copy(recordingSeconds = null)) } + fun stopAndSendRecording() = + viewModelScope.launch { + recordingTicker?.cancel() + recordingTicker = null + val finished = withContext(Dispatchers.IO) { recording?.stop() } + recording = null + update { it.copy(openChat = it.openChat?.copy(recordingSeconds = null)) } - val current = session ?: return@launch - val chat = signedIn()?.openChat ?: return@launch - val peerId = chat.peerId - if (finished == null) { - update { it.copy(error = "Too short to send, hold to record.") } - return@launch - } - if (peerId == null) { - update { it.copy(error = "Voice notes are one-to-one for now.") } - return@launch - } - - update { it.copy(busy = true, error = null) } - val done = withContext(Dispatchers.IO) { - runCatching { - current.sendVoice(peerId, finished.bytes, finished.mime, finished.seconds) + val current = session ?: return@launch + val chat = signedIn()?.openChat ?: return@launch + val peerId = chat.peerId + if (finished == null) { + update { it.copy(error = "Too short to send, hold to record.") } + return@launch } - } - done.onSuccess { sent -> - update { - it.copy( - busy = false, - openChat = it.openChat?.copy(lines = current.history(chat.threadId)), - error = describeDelivery(sent), - ) + if (peerId == null) { + update { it.copy(error = "Voice notes are one-to-one for now.") } + return@launch } - }.onFailure { failure -> update { it.copy(busy = false, error = describe(failure)) } } - } + update { it.copy(busy = true, error = null) } + val done = + withContext(Dispatchers.IO) { + runCatching { + current.sendVoice(peerId, finished.bytes, finished.mime, finished.seconds) + } + } + done + .onSuccess { sent -> + update { + it.copy( + busy = false, + openChat = it.openChat?.copy(lines = current.history(chat.threadId)), + error = describeDelivery(sent), + ) + } + }.onFailure { failure -> update { it.copy(busy = false, error = describe(failure)) } } + } /** * What to say about a send. - * - * "Delivered to 0 of 1 devices" is a ratio pretending to be an - * explanation. The case that actually happens, the other person is signed - * out, so they have no devices at all, deserves the sentence, because the - * message is not queued anywhere and will not arrive later. */ - private fun describeDelivery(sent: io.flamenet.client.Conversations.Sent): String? = when { - sent.failed.isEmpty() -> null - sent.delivered.isEmpty() -> - "Not delivered, they are not signed in on any device. " + - "This message was not queued; send it again once they are back." - else -> "Delivered to ${sent.delivered.size} of " + - "${sent.delivered.size + sent.failed.size} devices." - } + private fun describeDelivery(sent: io.flamenet.client.Conversations.Sent): String? = + when { + sent.failed.isEmpty() -> { + null + } + + sent.delivered.isEmpty() -> { + "Not delivered, they are not signed in on any device. " + + "This message was not queued; send it again once they are back." + } + + else -> { + "Delivered to ${sent.delivered.size} of " + + "${sent.delivered.size + sent.failed.size} devices." + } + } /** * Bytes for a received attachment, fetched and cached on first use. - * - * Named for attachments rather than images: photos and voice notes take - * the same path, and a method called `imageBytes` that audio also calls is - * the kind of name that quietly stops being true. */ suspend fun attachmentBytes(ref: io.flamenet.e2e.Attachment.Ref): ByteArray? { val current = session ?: return null @@ -3305,42 +3196,48 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { } /** Run something against the API and refresh the contact list from the result. */ - private fun act(block: (Session) -> List) = viewModelScope.launch { - val current = session ?: return@launch - update { it.copy(busy = true, error = null) } - val done = withContext(Dispatchers.IO) { runCatching { block(current) } } - done.onSuccess { list -> update { it.copy(busy = false, buddies = list) } } - .onFailure { failure -> update { it.copy(busy = false, error = describe(failure)) } } - } + private fun act(block: (Session) -> List) = + viewModelScope.launch { + val current = session ?: return@launch + update { it.copy(busy = true, error = null) } + val done = withContext(Dispatchers.IO) { runCatching { block(current) } } + done + .onSuccess { list -> update { it.copy(busy = false, buddies = list) } } + .onFailure { failure -> update { it.copy(busy = false, error = describe(failure)) } } + } /** * Send a sticker into whatever this conversation is. - * - * Forks on group vs peer exactly as sendMessage does. A sticker sent to - * a group has to be filed under the group thread, or it scatters across as - * many threads as the group has members. */ - fun sendSticker(chat: Phase.Chat, stickerId: String) = viewModelScope.launch { + fun sendSticker( + chat: Phase.Chat, + stickerId: String, + ) = viewModelScope.launch { val current = session ?: return@launch - val done = withContext(Dispatchers.IO) { - runCatching { - val group = chat.group - if (group != null) { - current.sendGroupSticker(group, StickerPack.CORE, stickerId) - } else { - current.sendSticker(chat.peerId ?: return@runCatching null, - StickerPack.CORE, stickerId) + val done = + withContext(Dispatchers.IO) { + runCatching { + val group = chat.group + if (group != null) { + current.sendGroupSticker(group, StickerPack.CORE, stickerId) + } else { + current.sendSticker( + chat.peerId ?: return@runCatching null, + StickerPack.CORE, + stickerId, + ) + } } } - } - done.onSuccess { sent -> - update { - it.copy( - openChat = it.openChat?.copy(lines = current.history(chat.threadId)), - error = sent?.let(::describeDelivery), - ) - } - }.onFailure { failure -> update { it.copy(error = describe(failure)) } } + done + .onSuccess { sent -> + update { + it.copy( + openChat = it.openChat?.copy(lines = current.history(chat.threadId)), + error = sent?.let(::describeDelivery), + ) + } + }.onFailure { failure -> update { it.copy(error = describe(failure)) } } } /** @@ -3348,12 +3245,13 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { * transcript uses, so a group, a local contact and somebody on another * relay cannot collide. */ - private fun Phase.Chat.vaultThreadId(): String = when { - group != null -> Vault.threadForGroup(group.id) - address != null -> Vault.threadForAddress(address) - peerId != null -> Vault.threadForUser(peerId) - else -> threadId - } + private fun Phase.Chat.vaultThreadId(): String = + when { + group != null -> Vault.threadForGroup(group.id) + address != null -> Vault.threadForAddress(address) + peerId != null -> Vault.threadForUser(peerId) + else -> threadId + } /** What was half-written in the open conversation, restored on open. */ fun openDraft(): String { @@ -3363,10 +3261,6 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { /** * Keep, or forget, what is in the composer. - * - * Called on every change including back to empty, clearing the - * composer has to clear what was stored, or a draft somebody deliberately - * deleted reappears the next time they open the conversation. */ fun rememberDraft(text: String) { val chat = signedIn()?.openChat ?: return @@ -3376,313 +3270,263 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { /** * Messages whose words match, across every conversation on this device. - * - * Scanned, not indexed. An index over the decrypted history is - * plaintext by another name and would have to be sealed and taken by - * `forgetEverything` like everything else in the vault. See - * [io.flamenet.e2e.MessageSearch] for the condition under which that stops - * being the right call. - * - * Off the main thread, because unlike the iPhone app this decrypts each - * thread on demand rather than reading one already in memory. - * - * The query is never persisted. What somebody searched for is often more - * revealing than what they found. */ suspend fun searchMessages(query: String): List { val vault = vault ?: return emptyList() return withContext(Dispatchers.IO) { - runCatching { io.flamenet.e2e.MessageSearch.hits(query, vault) } - .getOrDefault(emptyList()) + runCatching { + io.flamenet.e2e.MessageSearch + .hits(query, vault) + }.getOrDefault(emptyList()) } } /** * @param replyTo the message this one answers (android#16), or null. - * - * Only the 1:1 path carries it today. Groups and federated addresses - * take a different send, and wiring a reply through those without a way to - * render the quote in a group transcript would be a half-feature that looks - * whole, the menu is hidden there rather than accepting a reply and - * dropping it. */ - fun sendMessage(text: String, mentions: List = emptyList(), - replyTo: Content.Reference? = null) = viewModelScope.launch { + fun sendMessage( + text: String, + mentions: List = emptyList(), + replyTo: Content.Reference? = null, + ) = viewModelScope.launch { val current = session ?: return@launch val chat = signedIn()?.openChat ?: return@launch if (text.isBlank()) return@launch - val result = withContext(Dispatchers.IO) { - runCatching { - val group = chat.group - val address = chat.address - if (group != null) current.sendGroup(group, text, mentions) - // Before the `peerId!!` below, because a remote thread has - // no peer id and that assertion would crash rather than send. - // - // No link preview. §6.12 fetches the card on the SENDER's - // device to keep the recipient off the URL, and doing that here - // would tell the site that somebody on this relay is writing to - // somebody on another one, a correlation the whole federated - // path exists to avoid. A plain URL is a perfectly good thing - // for a message to contain. - else if (address != null) { - val card = ContactCard.decode(address) - ?: error("this conversation's address is unreadable") - current.sendToAddress(card, text) + val result = + withContext(Dispatchers.IO) { + runCatching { + val group = chat.group + val address = chat.address + if (group != null) { + current.sendGroup(group, text, mentions) + } // Before the `peerId!!` below, because a remote thread has no + // peer id and that assertion would crash rather than send. + else if (address != null) { + val card = + ContactCard.decode(address) + ?: error("this conversation's address is unreadable") + current.sendToAddress(card, text) + } + // §6.12, fetched HERE, on the sender's device, and carried + // inside the ciphertext. + else { + current.send(chat.peerId!!, text, linkPreviewFor(text), replyTo = replyTo) + } } - // §6.12, fetched HERE, on the sender's device, and carried - // inside the ciphertext. The recipient never touches the URL: a - // card each of them fetched would tell the site who read the - // link and when, once per member of a group. - // - // Best-effort. Every failure path ends as a message with a - // plain URL in it, which is a perfectly good thing to be. - else current.send(chat.peerId!!, text, linkPreviewFor(text), replyTo = replyTo) } - } - result.onSuccess { sent -> - // The badge has to catch up on SUCCESS too, and this is the case - // that actually matters. Trust on first use pins the peer's keys - // during the send itself, so before the first message the header - // correctly says "No keys seen yet", and without this it goes on - // saying it, with no safety number to read, until the user leaves - // the thread and comes back. The failure branch below already did - // this for the opposite reason; the pin is the more common one. - // - // Group threads have no single peer identity, so their trust is - // left alone rather than recomputed into something meaningless. - val peer = chat.peerId - update { - it.copy( - openChat = it.openChat?.copy(lines = current.history(chat.threadId)) - ?.let { open -> - if (peer == null) open - else open.copy( - trust = current.conversations.trust.state(peer), - safetyNumber = current.conversations.trust.safetyNumber(peer), - ) - }, - // Partial delivery is stated, not hidden. "Sent" when one - // of someone's three devices got it is a lie the user acts - // on. - error = describeDelivery(sent), - ) + result + .onSuccess { sent -> + // The badge has to catch up on SUCCESS too, and this is the case + // that actually matters. + val peer = chat.peerId + update { + it.copy( + openChat = + it.openChat + ?.copy(lines = current.history(chat.threadId)) + ?.let { open -> + if (peer == null) { + open + } else { + open.copy( + trust = current.conversations.trust.state(peer), + safetyNumber = current.conversations.trust.safetyNumber(peer), + ) + } + }, + // Partial delivery is stated, not hidden. + error = describeDelivery(sent), + ) + } + }.onFailure { failure -> + update { + it.copy( + error = describe(failure), + // A refusal changes what we believe about them, so the + // badge has to catch up in the same breath, otherwise the + // header still says "pinned" while the send just failed + // because it is not. + openChat = + chat.peerId?.let { peer -> + it.openChat?.copy(trust = current.conversations.trust.state(peer)) + } ?: it.openChat, + forkDetected = current.conversations.trust.forkDetected, + ) + } } - }.onFailure { failure -> - update { - it.copy( - error = describe(failure), - // A refusal changes what we believe about them, so the - // badge has to catch up in the same breath, otherwise the - // header still says "pinned" while the send just failed - // because it is not. - openChat = chat.peerId?.let { peer -> - it.openChat?.copy(trust = current.conversations.trust.state(peer)) - } ?: it.openChat, - forkDetected = current.conversations.trust.forkDetected, - ) - } - } } /** * Poll for mail while signed in. - * - * Polling, not push. There is no push channel, and pretending otherwise - * would mean a client that looks connected and silently is not. The - * interval is a compromise between battery and feeling live; the honest - * limit is that a closed app receives nothing. */ private fun startPolling() { polling?.cancel() - // So an incoming call rings, see installCallSignalHandler. This must - // not wait for the user to place a call first. + // So an incoming call rings, see installCallSignalHandler. installCallSignalHandler() - polling = viewModelScope.launch { - // Make sure the relay holds a digest for this account before the - // first poll. Without one, every contact that tries to reach us - // sealed is rejected, and a sealed submission is rejected by the - // relay, so the sender learns nothing useful about why. - withContext(Dispatchers.IO) { - runCatching { session?.conversations?.ensureDeliveryKey() } - } - while (true) { - // An offer the background service consumed while the app was - // off screen. Same process, so the handoff is in memory. - PendingIncomingCall.take()?.let { parked -> - // Stop the ring. The call notification is `ongoing` so - // that it cannot be swiped away by accident while the phone - // is ringing, which also means it does NOT dismiss itself, - // and every path that ends a call has to cancel it. This is - // the "answered" path. - runCatching { - androidx.core.app.NotificationManagerCompat - .from(getApplication()).cancel(3) - } - launch(Dispatchers.IO) { - val manager = callManager() - val turn = session?.turnCredentials() - manager.onSignal(parked.fromUser, parked.signal, turn, parked.at) - } + polling = + viewModelScope.launch { + // Make sure the relay holds a digest for this account before the + // first poll. + withContext(Dispatchers.IO) { + runCatching { session?.conversations?.ensureDeliveryKey() } } - val current = session - if (current != null) { - // A story arriving must appear without the user pulling - // to refresh; it is filed by `receive()` and read back here. - val received = withContext(Dispatchers.IO) { - val got = runCatching { current.receive() } - // Cached plaintext for anything nothing refers to any - // more, an expired message's photo, a story past its - // lifetime. Throttled inside, and it declines to run at - // all if it cannot tell what is still in use. + while (true) { + // An offer the background service consumed while the app was + // off screen. + PendingIncomingCall.take()?.let { parked -> + // Stop the ring. runCatching { - current.purgeOrphanedAttachments( - getApplication().cacheDir) + androidx.core.app.NotificationManagerCompat + .from(getApplication()) + .cancel(3) } - got - } - // 410 from the poll: this device's registration is gone - // revoked from another phone, or expired by the sweep. - // Wipe and sign out with an honest sentence, rather than - // hammering a mailbox that no longer exists while sitting - // on ciphertext the account's owner asked to be gone. - val failure = received.exceptionOrNull() - if (failure is RelayException.Http && failure.status == 410) { - signOutRevoked() - return@launch - } - val threads = received.getOrDefault(emptySet()) - // Retry the outbox on every tick. A message to - // somebody with no registered device waits here rather than - // being lost or dumped on the sender to remember, see - // Session.flushOutbox. This is also the honest limit of it: - // the queue is on THIS phone, so it goes out the next time - // this app runs and polls, not the instant the other person - // signs in. - val flushed = withContext(Dispatchers.IO) { - runCatching { current.flushOutbox() }.getOrDefault(emptySet()) - } - if (flushed.isNotEmpty()) { - update { state -> - state.copy(openChat = state.openChat?.let { - if (it.threadId in flushed) - it.copy(lines = current.history(it.threadId)) else it - }) + launch(Dispatchers.IO) { + val manager = callManager() + val turn = session?.turnCredentials() + manager.onSignal(parked.fromUser, parked.signal, turn, parked.at) } } - // Read back every tick rather than only when a thread was - // touched: a story files itself outside the thread map, so - // `threads` is empty for a poll that brought one in. - // Keep what is on screen when the fetch fails; see - // refreshStories. A poll that cannot reach the relay has - // learned nothing, and "nothing" is not "no stories". - val polled = withContext(Dispatchers.IO) { - runCatching { current.stories() } - } - // Skip THIS tick's update on failure rather than leaving - // the loop, the next poll may well succeed. - polled.getOrNull()?.let { live -> - update { state -> - if (state.stories.map { it.id } == live.map { it.id }) state - else state.copy(stories = live) + val current = session + if (current != null) { + // A story arriving must appear without the user pulling to + // refresh; it is filed by `receive()` and read back here. + val received = + withContext(Dispatchers.IO) { + val got = runCatching { current.receive() } + // Cached plaintext for anything nothing refers to any + // more, an expired message's photo, a story past its + // lifetime. + runCatching { + current.purgeOrphanedAttachments(getApplication().cacheDir) + } + got + } + // 410 from the poll: this device's registration is gone + // revoked from another phone, or expired by the sweep. + val failure = received.exceptionOrNull() + if (failure is RelayException.Http && failure.status == 410) { + signOutRevoked() + return@launch } - } - // A picture arriving is a control message: it touches no - // thread, so it needs its own signal. Without this the - // Home circles showed initials until the next sign-in. - // A group name arriving is control traffic: it touches no - // thread, so it needs its own signal. Without this a group - // someone else named stayed on the relay's placeholder - // until the next sign-in, the server has no copy to fill - // it in from any more. - if (current.groupNamesUpdated) { - current.groupNamesUpdated = false - withContext(Dispatchers.IO) { runCatching { current.namedGroups() } } - .getOrNull()?.let { live -> - update { state -> - state.copy( - groups = live, - openChat = state.openChat?.let { chat -> - chat.copy(group = live.firstOrNull { - g -> g.id == chat.group?.id - } ?: chat.group) + val threads = received.getOrDefault(emptySet()) + // Retry the outbox on every tick. + val flushed = + withContext(Dispatchers.IO) { + runCatching { current.flushOutbox() }.getOrDefault(emptySet()) + } + if (flushed.isNotEmpty()) { + update { state -> + state.copy( + openChat = + state.openChat?.let { + if (it.threadId in flushed) { + it.copy(lines = current.history(it.threadId)) + } else { + it + } }, - ) + ) + } + } + // Read back every tick rather than only when a thread was + // touched: a story files itself outside the thread map, so + // `threads` is empty for a poll that brought one in. + val polled = + withContext(Dispatchers.IO) { + runCatching { current.stories() } + } + // Skip THIS tick's update on failure rather than leaving + // the loop, the next poll may well succeed. + polled.getOrNull()?.let { live -> + update { state -> + if (state.stories.map { it.id } == live.map { it.id }) { + state + } else { + state.copy(stories = live) } } - } - if (current.conversations.avatarsUpdated) { - current.conversations.avatarsUpdated = false - val fresh = withContext(Dispatchers.IO) { - runCatching { - current.avatars(signedIn()?.buddies?.map { it.userId }.orEmpty()) - }.getOrNull() } - // Merge, never replace. A refresh made while the - // buddy list was momentarily empty, a roster fetch in - // flight, or the list loading from the vault, produced - // an empty map that wiped every picture from the UI at - // once. The avatars were in the vault the whole time, - // which is why "the profile pictures are gone" looked - // like data loss rather than a render bug. Absent means - // "not loaded yet", never "deleted". - fresh?.takeIf { it.isNotEmpty() }?.let { live -> - update { it.copy(avatars = it.avatars + live) } + // A picture arriving is a control message: it touches no + // thread, so it needs its own signal. + if (current.groupNamesUpdated) { + current.groupNamesUpdated = false + withContext(Dispatchers.IO) { runCatching { current.namedGroups() } } + .getOrNull() + ?.let { live -> + update { state -> + state.copy( + groups = live, + openChat = + state.openChat?.let { chat -> + chat.copy( + group = + live.firstOrNull { g -> + g.id == chat.group?.id + } ?: chat.group, + ) + }, + ) + } + } + } + if (current.conversations.avatarsUpdated) { + current.conversations.avatarsUpdated = false + val fresh = + withContext(Dispatchers.IO) { + runCatching { + current.avatars(signedIn()?.buddies?.map { it.userId }.orEmpty()) + }.getOrNull() + } + // Merge, never replace. + fresh?.takeIf { it.isNotEmpty() }?.let { live -> + update { it.copy(avatars = it.avatars + live) } + } + } + if (threads.isNotEmpty()) { + // Whoever just wrote may not be on the list at all, see + // adoptStrangers. + adoptStrangers(threads) + update { state -> + val open = state.openChat + state.copy( + openChat = + open?.let { + if (it.threadId in threads) { + it.copy(lines = current.history(it.threadId)) + } else { + it + } + }, + // A thread that is already open is being read, + // so it does not also become unread. + unread = state.unread + threads - setOfNotNull(open?.threadId), + // Refreshed here, not only at sign-in. + remoteAddresses = current.remoteAddresses(), + ) + } } } - if (threads.isNotEmpty()) { - // Whoever just wrote may not be on the list at all, - // see adoptStrangers. Before the UI update, so the - // refreshed list is at most one poll behind the badge. - adoptStrangers(threads) - update { state -> - val open = state.openChat - state.copy( - openChat = open?.let { - if (it.threadId in threads) { - it.copy(lines = current.history(it.threadId)) - } else it - }, - // A thread that is already open is being read, - // so it does not also become unread. - unread = state.unread + threads - setOfNotNull(open?.threadId), - // Refreshed here, not only at sign-in. A - // first message from another relay creates a - // thread nothing else on any screen points at, - // its sender is not in the roster and never will - // be, so a list computed once at launch would - // leave the reply unreachable until the next - // one. - remoteAddresses = current.remoteAddresses(), - ) + // A call being set up lives or dies on signalling latency, the + // 5 s cadence made every answer feel geological, and stacked + // with the same cadence on the other end. + val settingUp = + when (calls?.state) { + CallManager.State.DIALLING, + CallManager.State.RINGING, + CallManager.State.CONNECTING, + -> true + + else -> false } - } + kotlinx.coroutines.delay(if (settingUp) CALL_SETUP_POLL_MS else POLL_INTERVAL_MS) } - // A call being set up lives or dies on signalling latency, - // the 5 s cadence made every answer feel geological, and - // stacked with the same cadence on the other end. Matches the - // 1 s fast-poll iOS runs while dialling/ringing/connecting. - val settingUp = when (calls?.state) { - CallManager.State.DIALLING, - CallManager.State.RINGING, - CallManager.State.CONNECTING -> true - else -> false - } - kotlinx.coroutines.delay(if (settingUp) CALL_SETUP_POLL_MS else POLL_INTERVAL_MS) } - } } companion object { /** * How long sign-in may take before it reports failure (android#19). - * - * A backstop for "never answers", not a latency budget. Deriving the - * account key from a passphrase is deliberately slow, and an emulator - * or an older handset is slower still, a tight value here would turn - * a working sign-in into a confusing error on exactly the devices least - * able to spare the confusion. */ private const val SIGN_IN_TIMEOUT_MS = 60_000L @@ -3691,13 +3535,6 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { /** * The presence chips, in order. - * - * A contract with the relay, checked by no compiler. Every entry - * must be a value `ChosenStatus` accepts, `offline` is accepted there - * as an alias for `invisible`. Adding a word the relay does not know - * brings the original bug back in a newer form: the relay answers 400 - * now rather than lying, so the chip fails visibly instead of silently, - * but it is still a control that does not work. */ val PRESENCE_CHOICES = listOf("online", "away", "busy", "offline") @@ -3706,111 +3543,82 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { const val CALL_SETUP_POLL_MS = 1_000L } - /// The relay said this device was removed. Same teardown as a sign-out, - /// which on Android is already a full vault destroy, plus the sentence - /// that says what happened and what signing in again means. - private fun signOutRevoked() = viewModelScope.launch { - polling?.cancel() - polling = null - session = null - withContext(Dispatchers.IO) { runCatching { KeystoreVault.destroy(getApplication()) } } - identity = null - vault = KeystoreVault.open(getApplication()) - _phase.value = Phase.SignedOut( - error = "This device was removed from the account, so its local " - + "messages were cleared. Signing in again registers it as a " - + "new device.") - } + // / The relay said this device was removed. + private fun signOutRevoked() = + viewModelScope.launch { + polling?.cancel() + polling = null + session = null + withContext(Dispatchers.IO) { runCatching { KeystoreVault.destroy(getApplication()) } } + identity = null + vault = KeystoreVault.open(getApplication()) + _phase.value = + Phase.SignedOut( + error = + "This device was removed from the account, so its local " + + "messages were cleared. Signing in again registers it as a " + + "new device.", + ) + } /** * End the session, and optionally erase this phone with it. - * - * Signing out used to cost every one of your contacts a prompt. It - * destroyed the vault unconditionally, so signing back in was a * NEW device: every contact had the old key pinned, saw a safety-number - * change, and could not message you until they accepted it. For someone - * stepping away for an afternoon that is a strange price to pay, and it is - * why signing out and back in kept looking like the app breaking. - * - * Two doors now. Plain sign-out ends the session and keeps the device, so - * coming back is silent. Erasing is for handing the phone on, and for - * deleting the account, and the settings screen says which is which. - * - * Keeping the keys does not leave the mailbox reachable: a relay token - * is minted against the account credential, which this clears either way. */ + /** * Throw away a vault that cannot be opened, so sign-in is possible again. - * - * The "Erase and start fresh" button used to call `signOut()`, which - * erases nothing. `signOut` defaults to `erasingDevice = false`, the - * door that deliberately KEEPS the device so coming back is silent, so it - * set the phase to SignedOut and left the unreadable vault exactly where it - * was. The next `restore()` opened it, failed again, and went straight back - * to VaultUnreadable. The one button on a dead-end screen was a loop. - * - * Not `signOut(erasingDevice = true)` either, though that would have - * worked. That path first tries to revoke the device on the relay using a - * session that by definition does not exist here, and its comments are - * about handing a phone on. This is a different situation: the keys are - * already lost, and the only question is whether the app can get past them. */ - fun discardUnreadableVault() = viewModelScope.launch { - polling?.cancel() - polling = null - session = null - withContext(Dispatchers.IO) { runCatching { KeystoreVault.destroy(getApplication()) } } - identity = null - vault = KeystoreVault.open(getApplication()) - _phase.value = Phase.SignedOut( - error = "The keys stored on this phone could not be opened, so they have " - + "been cleared. Signing in registers this phone as a new device, " - + "your contacts will see that your security keys changed.") - } - - fun signOut(erasingDevice: Boolean = false) = viewModelScope.launch { - polling?.cancel() - polling = null - val current = session - val myDevice = identity?.deviceId - session = null - if (erasingDevice) { - withContext(Dispatchers.IO) { - // Tell the relay BEFORE destroying the keys that authorise - // saying it. Otherwise the account goes on publishing a device - // nobody will ever read again, and fan-out is per device, so - // contacts keep addressing copies to a dead mailbox while the - // relay reports every one of them delivered. Best effort: a bad - // network must not leave the phone half-erased. - if (myDevice != null) runCatching { current?.revokeDevice(myDevice) } - KeystoreVault.destroy(getApplication()) - } + fun discardUnreadableVault() = + viewModelScope.launch { + polling?.cancel() + polling = null + session = null + withContext(Dispatchers.IO) { runCatching { KeystoreVault.destroy(getApplication()) } } identity = null vault = KeystoreVault.open(getApplication()) - } else { - identity = null + _phase.value = + Phase.SignedOut( + error = + "The keys stored on this phone could not be opened, so they have " + + "been cleared. Signing in registers this phone as a new device, " + + "your contacts will see that your security keys changed.", + ) + } + + fun signOut(erasingDevice: Boolean = false) = + viewModelScope.launch { + polling?.cancel() + polling = null + val current = session + val myDevice = identity?.deviceId + session = null + if (erasingDevice) { + withContext(Dispatchers.IO) { + // Tell the relay BEFORE destroying the keys that authorise + // saying it. + if (myDevice != null) runCatching { current?.revokeDevice(myDevice) } + KeystoreVault.destroy(getApplication()) + } + identity = null + vault = KeystoreVault.open(getApplication()) + } else { + identity = null + } + _phase.value = Phase.SignedOut() } - _phase.value = Phase.SignedOut() - } /** - * Wrong handle and wrong passphrase must read the same. The account - * service answers them identically on purpose, and a message here that - * distinguishes them would undo that at the last step. + * Wrong handle and wrong passphrase must read the same. */ private fun describe(failure: Throwable): String = describeFailure(failure) /** * The failure-to-sentence mapping, as a pure function of its argument. - * - * Lifted out of the view model so it can be tested without an - * `Application`. It never touched instance state, it reads only the - * throwable, so this is a move, not a rewrite, and the alternative was - * a Robolectric run to assert a string. */ /** - * A recorder outlives the view model unless something stops it, and a - * live MediaRecorder holds the microphone against the whole device. + * A recorder outlives the view model unless something stops it, and a live + * MediaRecorder holds the microphone against the whole device. */ override fun onCleared() { calls?.shutdown() @@ -3824,135 +3632,155 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { /** * The four levels the local vault offers, and no compartments. - * - * Matches iOS's `Vocabularies.local` exactly, ordinals included. The - * NUMBER is what travels with a file, the word is only how it is drawn, so a - * client that renumbered these would file somebody's Restricted document as - * Internal on the other phone, with no error anywhere and nothing on either - * screen looking wrong. - * - * No compartments here, deliberately. The lattice supports 64 of them and - * the local vault offers none: there is nobody to agree with about what area - * 7 means, and an area that only one phone understands is a label that stops - * a file opening rather than one that protects it. */ object FileLevels { - val LOCAL_LEVELS: List> = listOf( - 0.toShort() to "Unclassified", - 1.toShort() to "Internal", - 2.toShort() to "Confidential", - 3.toShort() to "Restricted", - ) + val LOCAL_LEVELS: List> = + listOf( + 0.toShort() to "Unclassified", + 1.toShort() to "Internal", + 2.toShort() to "Confidential", + 3.toShort() to "Restricted", + ) fun levelName(classification: Short): String = LOCAL_LEVELS.firstOrNull { it.first == classification }?.second ?: "Level $classification" } -internal fun describeFailure(failure: Throwable): String = when (failure) { - is AccountsClient.Failure.BadCredentials -> "That handle or passphrase isn't right." - is AccountsClient.Failure.SessionExpired -> - "Your session expired. Sign out and back in to keep going." +internal fun describeFailure(failure: Throwable): String = + when (failure) { + is AccountsClient.Failure.BadCredentials -> { + "That handle or passphrase isn't right." + } - is AccountsClient.Failure.HandleTaken -> "That handle is already taken." - // Each of these says what to DO. A refusal somebody cannot act on is - // the same as no explanation: they retry the same thing and are refused - // again. "Too similar" without "changing a digit won't help" sends - // people round that loop deliberately. - is AccountsClient.Failure.EmailTaken -> - "An account with that email already exists. Try signing in instead." - is AccountsClient.Failure.HandleConfusable -> - "That handle looks too much like one that already exists. " + - "Changing a digit won't help, try a different word." - is AccountsClient.Failure.DisposableEmail -> - "That email provider isn't accepted. Use an address you'll still be " + - "able to read later, it's how you get back in." - is AccountsClient.Failure.BadHandle -> - "Handles can only contain lowercase letters and numbers." - is AccountsClient.Failure.BadEmail -> "That doesn't look like a valid email address." - is AccountsClient.Failure.WeakPassword -> - "Your passphrase needs to be at least 8 characters." - is AccountsClient.Failure.RateLimited -> "Too many attempts. Wait a few minutes." - // Not the underlying message: at this layer it is a Java networking - // string ("Failed to connect to /10.0.2.2:443"), which names an address - // the person never typed. - is AccountsClient.Failure.Transport -> - "Couldn't reach that server. Check the address and your connection." - // NOT "that passphrase is wrong". The relay rejects a TOKEN, which - // the user never typed and cannot check. Telling them their passphrase - // is wrong when their session merely aged out sends them off to change - // a password that was fine, observed on a send ten hours after a - // successful sign-in. - is RelayException.Unauthorized -> - "Your session expired. Sign out and back in to keep going." - is RelayException.NotFound -> "This server doesn't look like a Flamenet relay." - // The relay now says WHEN (Retry-After); a refusal with a clock is - // the difference between a person who waits and one who hammers. - is RelayException.RateLimited -> failure.retryAfterSeconds?.let { s -> - val mins = ((s + 59) / 60).coerceAtLeast(1) - "Too many attempts. Try again in about $mins minute" + - if (mins == 1) "." else "s." - } ?: "Too many attempts. Wait a few minutes." - // Named, because the generic fallback sent people to the network. - // "Something went wrong reaching that server" is what this used to - // produce for a changed key, and the server is fine, the refusal is - // this phone declining to encrypt to an identity nobody has reviewed. - // iOS names it ("Their security code changed"); this said nothing - // anywhere, in the call path or the message path. - is IdentityChangedException -> - "Their security code changed. Open the conversation to review it " + - "before sending anything else." - // The other two refusals reached the generic network line. - // Only `IdentityChangedException` was named, and the send path throws - // these just as often, a key the log does not account for is the - // failure a person actually hits after a contact reinstalls. "Something - // went wrong reaching that server" sent them to check their signal for a - // decision this phone made, and the server was answering fine. - is KeyNotLoggedException -> if (failure.hadEntries) { - "This contact's device is offering a key that doesn't match what " + - "the server has on record for it. Open the conversation to " + - "review their security code before sending anything else." - } else { - "This contact's device is offering a key the server never recorded. " + - "Nothing was sent. Check their security code with them directly." - } - is KeyLogForkException -> - "This server's record of published keys doesn't add up, so nothing " + - "was sent. That can mean tampering \u2014 check this contact's " + - "security code with them another way before continuing." - is Vault.Corrupt -> "This phone's stored keys could not be read." + is AccountsClient.Failure.SessionExpired -> { + "Your session expired. Sign out and back in to keep going." + } - // Anything not named above used to reach the screen verbatim. - // `RelayException.Http` carries "relay returned 409: {body}" as its - // message, so somebody choosing a handle that was already in use was - // shown a status code and a JSON fragment. Nothing in that tells them - // to pick a different name, which is the entire content of the answer. - // - // The body is deliberately dropped rather than appended. It is - // written for whoever is reading the logs, and half of these carry the - // handle back in it. - is RelayException.Http -> when (failure.status) { - 409 -> "That handle is already taken. Try a different one." - 400, 422 -> "That server couldn't accept those details. " + - "Check the handle and try again." - // Say what the relay said. A 403 is a rule refusing this - // specific thing, and the relay writes a sentence that names it - // and what to do, "this account already has 3 registered devices, - // remove one from the Devices screen". "That isn't allowed on this - // server" replaces an answer somebody can act on with one they - // cannot, and leaves them guessing at a rule nobody stated. - // - // Only when it really is a sentence. `body` falls back to the - // raw payload when there is no `message`, so anything that looks - // like JSON or a bare code keeps the generic line rather than - // putting a fragment on screen. - 403 -> failure.body - .takeIf { it.isNotBlank() && !it.trimStart().startsWith("{") && " " in it } - ?: "That isn't allowed on this server." - in 500..599 -> "That server is having trouble right now. " + - "Try again in a moment." - else -> "That server couldn't complete this. Try again in a moment." + is AccountsClient.Failure.HandleTaken -> { + "That handle is already taken." + } + + // Each of these says what to DO. + is AccountsClient.Failure.EmailTaken -> { + "An account with that email already exists. Try signing in instead." + } + + is AccountsClient.Failure.HandleConfusable -> { + "That handle looks too much like one that already exists. " + + "Changing a digit won't help, try a different word." + } + + is AccountsClient.Failure.DisposableEmail -> { + "That email provider isn't accepted. Use an address you'll still be " + + "able to read later, it's how you get back in." + } + + is AccountsClient.Failure.BadHandle -> { + "Handles can only contain lowercase letters and numbers." + } + + is AccountsClient.Failure.BadEmail -> { + "That doesn't look like a valid email address." + } + + is AccountsClient.Failure.WeakPassword -> { + "Your passphrase needs to be at least 8 characters." + } + + is AccountsClient.Failure.RateLimited -> { + "Too many attempts. Wait a few minutes." + } + + // Not the underlying message: at this layer it is a Java networking string + // ("Failed to connect to /10.0.2.2:443"), which names an address the person + // never typed. + is AccountsClient.Failure.Transport -> { + "Couldn't reach that server. Check the address and your connection." + } + + // NOT "that passphrase is wrong". + is RelayException.Unauthorized -> { + "Your session expired. Sign out and back in to keep going." + } + + is RelayException.NotFound -> { + "This server doesn't look like a Flamenet relay." + } + + // The relay now says WHEN (Retry-After); a refusal with a clock is the + // difference between a person who waits and one who hammers. + is RelayException.RateLimited -> { + failure.retryAfterSeconds?.let { s -> + val mins = ((s + 59) / 60).coerceAtLeast(1) + "Too many attempts. Try again in about $mins minute" + + if (mins == 1) "." else "s." + } ?: "Too many attempts. Wait a few minutes." + } + + // Named, because the generic fallback sent people to the network. + is IdentityChangedException -> { + "Their security code changed. Open the conversation to review it " + + "before sending anything else." + } + + // The other two refusals reached the generic network line. + is KeyNotLoggedException -> { + if (failure.hadEntries) { + "This contact's device is offering a key that doesn't match what " + + "the server has on record for it. Open the conversation to " + + "review their security code before sending anything else." + } else { + "This contact's device is offering a key the server never recorded. " + + "Nothing was sent. Check their security code with them directly." + } + } + + is KeyLogForkException -> { + "This server's record of published keys doesn't add up, so nothing " + + "was sent. That can mean tampering \u2014 check this contact's " + + "security code with them another way before continuing." + } + + is Vault.Corrupt -> { + "This phone's stored keys could not be read." + } + + // Anything not named above used to reach the screen verbatim. + is RelayException.Http -> { + when (failure.status) { + 409 -> { + "That handle is already taken. Try a different one." + } + + 400, 422 -> { + "That server couldn't accept those details. " + + "Check the handle and try again." + } + + // Say what the relay said. + 403 -> { + failure.body + .takeIf { it.isNotBlank() && !it.trimStart().startsWith("{") && " " in it } + ?: "That isn't allowed on this server." + } + + in 500..599 -> { + "That server is having trouble right now. " + + "Try again in a moment." + } + + else -> { + "That server couldn't complete this. Try again in a moment." + } + } + } + + is RelayException.Malformed -> { + "That server sent back something unexpected." + } + + else -> { + "Something went wrong reaching that server." + } } - is RelayException.Malformed -> "That server sent back something unexpected." - else -> "Something went wrong reaching that server." -} diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MetadataScrub.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MetadataScrub.kt index 6690bef..12d81a9 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MetadataScrub.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MetadataScrub.kt @@ -6,55 +6,49 @@ import java.io.ByteArrayOutputStream /** * Strips metadata from an image on its way out, whichever door it left by. - * - * A photo sent as a *file* used to keep its GPS; sent as a *photo* it did - * not (android#17, ios#69). The picker path runs through [ImagePrep], which - * decodes to a bitmap and re-encodes, the output is pixels, with no container - * to carry EXIF. `Session.sendFile` handed the stored bytes straight to - * `sendAttachment`. Somebody who had learned "Flamenet strips location from my - * photos" had learned something true of one button and false of the one beside - * it, and the safer one looked like the shortcut. - * - * Sniffed, not trusted. The vault stores no MIME type and a file name is - * a guess in both directions: `holiday.txt` can be a JPEG and `photo.jpg` can be - * text. Deciding by extension would strip the wrong things and miss the right - * ones. - * - * Only images are touched. "Send this exact file" is what a file send - * means, and silently rewriting somebody's PDF or spreadsheet would be its own - * surprise. Anything not recognised goes byte-for-byte. - * - * Matches `MetadataScrub` on iOS, format list included. */ object MetadataScrub { - enum class ImageKind { JPEG, PNG, HEIC, TIFF, WEBP, GIF } /** What happened, so the caller can say so rather than guess. */ - data class Result(val bytes: ByteArray, val strippedKind: ImageKind?) { + data class Result( + val bytes: ByteArray, + val strippedKind: ImageKind?, + ) { // ByteArray in a data class needs these, or equality compares identity. override fun equals(other: Any?): Boolean = - this === other || (other is Result && - bytes.contentEquals(other.bytes) && strippedKind == other.strippedKind) + this === other || ( + other is Result && + bytes.contentEquals(other.bytes) && strippedKind == other.strippedKind + ) + override fun hashCode(): Int = 31 * bytes.contentHashCode() + (strippedKind?.hashCode() ?: 0) } /** Pure, and the reason this is testable without a device. */ fun kind(of: ByteArray): ImageKind? { fun at(i: Int): Int? = if (i < of.size) of[i].toInt() and 0xFF else null - fun matches(bytes: List, offset: Int = 0): Boolean = - bytes.withIndex().all { (i, b) -> at(offset + i) == b } + + fun matches( + bytes: List, + offset: Int = 0, + ): Boolean = bytes.withIndex().all { (i, b) -> at(offset + i) == b } if (matches(listOf(0xFF, 0xD8, 0xFF))) return ImageKind.JPEG if (matches(listOf(0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A))) return ImageKind.PNG if (matches(listOf(0x47, 0x49, 0x46, 0x38))) return ImageKind.GIF - // TIFF, either byte order. Also the container many RAW files use, - // which is exactly why it should be recognised rather than sent raw. + // TIFF, either byte order. if (matches(listOf(0x49, 0x49, 0x2A, 0x00)) || - matches(listOf(0x4D, 0x4D, 0x00, 0x2A))) return ImageKind.TIFF + matches(listOf(0x4D, 0x4D, 0x00, 0x2A)) + ) { + return ImageKind.TIFF + } // RIFF….WEBP, the size field sits between the two markers. if (matches(listOf(0x52, 0x49, 0x46, 0x46)) && - matches(listOf(0x57, 0x45, 0x42, 0x50), 8)) return ImageKind.WEBP + matches(listOf(0x57, 0x45, 0x42, 0x50), 8) + ) { + return ImageKind.WEBP + } // ISO-BMFF: "ftyp" at offset 4, then a HEIF brand. if (matches(listOf(0x66, 0x74, 0x79, 0x70), 4)) { val brand = (8..11).mapNotNull { at(it) }.map { it.toChar() }.joinToString("") @@ -68,28 +62,20 @@ object MetadataScrub { /** * Re-encode an image so nothing but pixels survives; pass anything else * through unchanged. - * - * Re-encoded, not filtered. Removing the EXIF block by hand leaves - * XMP, IPTC, MakerNote and thumbnail streams, several of which carry - * location independently. Decoding to a bitmap and writing out again is the - * only version of this with no list to keep up to date. - * - * Undecodable bytes are returned as they came. A file that will not - * decode is not necessarily a file with location in it, and losing - * somebody's data to a scrubber is a worse failure than the one being - * fixed. */ fun scrub(data: ByteArray): Result { val kind = kind(data) ?: return Result(data, null) - val bitmap = runCatching { BitmapFactory.decodeByteArray(data, 0, data.size) }.getOrNull() - ?: return Result(data, null) + val bitmap = + runCatching { BitmapFactory.decodeByteArray(data, 0, data.size) }.getOrNull() + ?: return Result(data, null) val out = ByteArrayOutputStream() // PNG where a lossy pass would be a second surprise; JPEG otherwise, at // a quality high enough not to be visible. - val format = when (kind) { - ImageKind.PNG, ImageKind.GIF, ImageKind.TIFF, ImageKind.WEBP -> Bitmap.CompressFormat.PNG - ImageKind.JPEG, ImageKind.HEIC -> Bitmap.CompressFormat.JPEG - } + val format = + when (kind) { + ImageKind.PNG, ImageKind.GIF, ImageKind.TIFF, ImageKind.WEBP -> Bitmap.CompressFormat.PNG + ImageKind.JPEG, ImageKind.HEIC -> Bitmap.CompressFormat.JPEG + } val ok = runCatching { bitmap.compress(format, 95, out) }.getOrDefault(false) bitmap.recycle() return if (ok && out.size() > 0) Result(out.toByteArray(), kind) else Result(data, null) diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/NetworkSection.kt b/app/src/main/kotlin/io/flamenet/messenger/app/NetworkSection.kt index cdd7fad..8da6f52 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/NetworkSection.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/NetworkSection.kt @@ -2,12 +2,11 @@ package io.flamenet.messenger.app import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.padding -import androidx.compose.ui.Alignment -import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text @@ -17,6 +16,7 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag @@ -30,18 +30,6 @@ import io.flamenet.obfs4.Obfs4Stats /** * Reaching Flamenet on a network that blocks it. - * - * This screen exists because a bad line used to be silent. A line that - * would not parse became "no bridge", and "no bridge" is a direct connection, - * so somebody who pasted a bridge *because* direct does not work would have - * been put straight back on the path they were escaping, with the setting still - * looking configured. Nothing is stored here that could not be used, and what - * is shown afterwards is whether a handshake has actually completed, not what - * is written in preferences. - * - * Every word has to stay honest about what this protects against. A - * bridge hides traffic from whoever is between the phone and the bridge. The - * bridge then proxies to the relay, which sees exactly what it saw before. */ @Composable fun ColumnScope.NetworkSection( @@ -54,12 +42,11 @@ fun ColumnScope.NetworkSection( var stored by remember { mutableStateOf(settings.bridgeLine) } // Bumped on save/remove so the live status below re-reads. var tick by remember { mutableIntStateOf(0) } - // Who has been handed the line this sitting. A confirmation, not a record: - // it is not persisted, because whether somebody still has a working bridge - // is not something this phone can know. + // Who has been handed the line this sitting. var sharedWith by remember { mutableStateOf(setOf()) } - @Suppress("UNUSED_EXPRESSION") tick + @Suppress("UNUSED_EXPRESSION") + tick val parsed = if (draft.isBlank()) null else BridgeLine.parse(draft) val isUnchanged = draft.trim() == (stored ?: "") @@ -67,8 +54,7 @@ fun ColumnScope.NetworkSection( Text("Network", style = MaterialTheme.typography.titleMedium) // Reads the handshake counter and the bridge in force, never the stored - // line. The two disagree exactly when it matters, a saved bridge that is - // not there leaves the app connecting directly, and only this says so. + // line. Text( statusLine(), fontSize = 13.sp, @@ -76,13 +62,7 @@ fun ColumnScope.NetworkSection( modifier = Modifier.testTag("obfs4-status"), ) - // Offers from contacts, and the reason they are a list and not a - // setting. A bridge line routes every relay connection through a machine - // somebody else chose. Nothing applies one on arrival, a contact, or - // whoever has taken their account, could otherwise move this phone's - // traffic onto a host they control. They could not read it, but they would - // learn who is online and when, and could cut them off. Accepting is a - // button, pressed by the person it was sent to. + // Offers from contacts, and the reason they are a list and not a setting. val offers = remember(tick) { model?.bridgeOffers().orEmpty() } if (offers.isNotEmpty()) { Spacer(Modifier.height(Space.md)) @@ -94,10 +74,7 @@ fun ColumnScope.NetworkSection( fontSize = 14.sp, modifier = Modifier.testTag("obfs4-offer-${offer.fromUser}"), ) - // The line is shown, not hidden behind "Accept". Somebody may - // want to check the fingerprint against another source before - // routing their traffic through it, and they cannot check what - // they cannot see. + // The line is shown, not hidden behind "Accept". Text( offer.line, fontSize = 11.sp, @@ -105,18 +82,23 @@ fun ColumnScope.NetworkSection( color = Palette.SecondaryText, ) when (val parsed = BridgeLine.parse(offer.line)) { - is BridgeLineResult.Bad -> Text( - // An offer that cannot be used says why, rather than - // failing when it is accepted. - parsed.problem.message, - fontSize = 12.sp, - color = Palette.Danger, - ) - is BridgeLineResult.Ok -> Text( - "Reads as ${parsed.bridge.host} port ${parsed.bridge.port}", - fontSize = 12.sp, - color = Palette.SecondaryText, - ) + is BridgeLineResult.Bad -> { + Text( + // An offer that cannot be used says why, rather than + // failing when it is accepted. + parsed.problem.message, + fontSize = 12.sp, + color = Palette.Danger, + ) + } + + is BridgeLineResult.Ok -> { + Text( + "Reads as ${parsed.bridge.host} port ${parsed.bridge.port}", + fontSize = 12.sp, + color = Palette.SecondaryText, + ) + } } Spacer(Modifier.height(Space.xs)) Row(modifier = Modifier.fillMaxWidth()) { @@ -137,7 +119,10 @@ fun ColumnScope.NetworkSection( Spacer(Modifier.width(Space.sm)) KitSecondaryButton( text = "Dismiss", - onClick = { model?.dismissBridgeOffer(offer.fromUser); tick++ }, + onClick = { + model?.dismissBridgeOffer(offer.fromUser) + tick++ + }, modifier = Modifier.weight(1f), tag = "obfs4-offer-dismiss-${offer.fromUser}", ) @@ -157,15 +142,18 @@ fun ColumnScope.NetworkSection( modifier = Modifier.fillMaxWidth().testTag("obfs4-bridge-line"), ) - // Under the field and always in the same place, so it reads as feedback - // on what was typed rather than as a warning from somewhere else. + // Under the field and always in the same place, so it reads as feedback on + // what was typed rather than as a warning from somewhere else. when (parsed) { - is BridgeLineResult.Bad -> Text( - parsed.problem.message, - fontSize = 12.sp, - color = Palette.Danger, - modifier = Modifier.testTag("obfs4-problem"), - ) + is BridgeLineResult.Bad -> { + Text( + parsed.problem.message, + fontSize = 12.sp, + color = Palette.Danger, + modifier = Modifier.testTag("obfs4-problem"), + ) + } + is BridgeLineResult.Ok -> { Text( "Reads as ${parsed.bridge.host} port ${parsed.bridge.port}", @@ -182,7 +170,10 @@ fun ColumnScope.NetworkSection( ) } } - null -> Unit + + null -> { + Unit + } } Spacer(Modifier.height(Space.sm)) @@ -191,8 +182,7 @@ fun ColumnScope.NetworkSection( text = "Save this bridge", onClick = { // Goes through `save`, which parses again and stores only what it - // could use. The button being enabled is a convenience; the refusal - // lives one layer down, where nothing can route around it. + // could use. if (settings.save(draft) is BridgeLineResult.Ok) { stored = settings.bridgeLine tick++ @@ -218,21 +208,12 @@ fun ColumnScope.NetworkSection( ) } - // The other half of receiving one. A bridge is only useful while - // whoever wants to block it does not have the line, and every public way of - // handing one out is somewhere a censor can go and collect them. Passing it - // to somebody you already talk to is the one route that does not scale for - // them: enumerating bridges becomes infiltrating friendships one at a time. - // - // Only when a bridge is actually configured. "Share" on a screen with - // nothing set would send an empty line and put an un-actionable row on - // somebody else's settings screen. + // The other half of receiving one. if (stored != null && buddies.isNotEmpty()) { Spacer(Modifier.height(Space.md)) Text("Share it with", fontWeight = FontWeight.Medium, fontSize = 15.sp) Text( - // Says who learns what. The bridge operator sees a new client; the - // contact learns you use this bridge. Neither is nothing. + // Says who learns what. "They get the line and decide for themselves. Whoever runs the bridge will " + "see one more person connecting, and your contact will know you use it.", fontSize = 12.sp, @@ -254,9 +235,7 @@ fun ColumnScope.NetworkSection( model?.shareBridge(buddy.userId) sharedWith = sharedWith + buddy.userId }, - // Not disabled after sending. An operator rotates a - // bridge and the same person needs the new line; a button - // that never re-arms would make that impossible from here. + // Not disabled after sending. tag = "obfs4-share-${buddy.userId}", ) } @@ -281,35 +260,32 @@ private fun parsedOk(line: String) = BridgeLine.parse(line) is BridgeLineResult. /** * What is actually happening, as opposed to what is stored. - * - * Lifted out of the Composable so it can be asserted. A string built - * inline in a `Text(...)` is a string nothing can test, which is how a screen - * ends up quietly claiming protection it does not have. The same lesson the - * `registeredLine` note records, applied before it bites rather than after. */ internal fun statusLine( bridge: io.flamenet.obfs4.Obfs4Bridge? = Obfs4Routing.bridge, handshakes: Int = Obfs4Stats.completedHandshakes(), -): String = when { - bridge == null -> - "Connecting directly to your Flamenet server. That's the right setting almost " + - "everywhere." - handshakes > 0 -> - "Going through ${bridge.host}:${bridge.port}. Traffic to your server is wrapped so " + - "that whoever carries it can't tell what it is, your server still sees what it " + - "always did." - // Not "protected". A saved bridge that has never completed a handshake - // has carried nothing, and saying otherwise is the exact claim this screen - // was built to stop making. - else -> - "Bridge saved: ${bridge.host}:${bridge.port}. Nothing has gone through it yet, the " + - "app will try the next time it goes online." -} +): String = + when { + bridge == null -> { + "Connecting directly to your Flamenet server. That's the right setting almost " + + "everywhere." + } + + handshakes > 0 -> { + "Going through ${bridge.host}:${bridge.port}. Traffic to your server is wrapped so " + + "that whoever carries it can't tell what it is, your server still sees what it " + + "always did." + } + + // Not "protected". + else -> { + "Bridge saved: ${bridge.host}:${bridge.port}. Nothing has gone through it yet, the " + + "app will try the next time it goes online." + } + } /** - * Says what it does not protect, in the same breath as what it does. The - * failure mode for this feature is somebody believing it hides them from their - * server, behaving accordingly, and being wrong. + * Says what it does not protect, in the same breath as what it does. */ private const val EXPLANATION = "Some networks block Flamenet outright, or recognise it and slow it down. A bridge is a " + diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/NotificationSection.kt b/app/src/main/kotlin/io/flamenet/messenger/app/NotificationSection.kt index c483a26..6253bf0 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/NotificationSection.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/NotificationSection.kt @@ -22,11 +22,6 @@ import androidx.compose.ui.unit.sp /** * What this phone's notifications are allowed to say. - * - * Android had none of this. iOS has offered these since it shipped, and - * the parity scrub on 2026-09-06 found eight preferences on one phone and not - * the other. `MessengerService` had already written down what was missing, at - * the only place it could: "Count only, until there is a setting for it." */ @Composable fun ColumnScope.NotificationSection() { @@ -40,44 +35,56 @@ fun ColumnScope.NotificationSection() { Text("Notifications", style = MaterialTheme.typography.titleMedium) @Composable - fun row(label: String, tag: String, value: Boolean, onChange: (Boolean) -> Unit) { + fun row( + label: String, + tag: String, + value: Boolean, + onChange: (Boolean) -> Unit, + ) { Row( modifier = Modifier.fillMaxWidth().padding(vertical = Space.sm), verticalAlignment = Alignment.CenterVertically, ) { Text(label, fontSize = 16.sp, modifier = Modifier.weight(1f)) - Switch(checked = value, onCheckedChange = onChange, - modifier = Modifier.testTag(tag)) + Switch( + checked = value, + onCheckedChange = onChange, + modifier = Modifier.testTag(tag), + ) } } row("Notify me", "notify-enabled", enabled) { on -> - settings.enabled = on; enabled = on + settings.enabled = on + enabled = on } if (enabled) { row("New messages", "notify-messages", newMessages) { on -> - settings.newMessages = on; newMessages = on + settings.newMessages = on + newMessages = on } row("Show who it is from", "notify-previews", previews) { on -> - settings.showPreviews = on; previews = on + settings.showPreviews = on + previews = on } row("Sound while the app is open", "notify-sounds", sounds) { on -> - settings.inAppSounds = on; sounds = on + settings.inAppSounds = on + sounds = on } } Spacer(Modifier.height(Space.xs)) - // Says what turning it on costs, and does not pretend the default is - // merely cautious. Who is writing to you is, on some phones, the more - // sensitive of the two facts. + // Says what turning it on costs, and does not pretend the default is merely + // cautious. Text( - if (previews) - "Your lock screen will name whoever wrote to you. Anyone who can see your " - + "phone can see that, which is sometimes more revealing than the message." - else - "Notifications say how many messages arrived and nothing else, no name, no " - + "words. Calls still ring.", + if (previews) { + "Your lock screen will name whoever wrote to you. Anyone who can see your " + + "phone can see that, which is sometimes more revealing than the message." + } else { + "Notifications say how many messages arrived and nothing else, no name, no " + + "words. Calls still ring." + }, fontSize = 12.sp, color = Palette.SecondaryText, ) diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/NotificationSettings.kt b/app/src/main/kotlin/io/flamenet/messenger/app/NotificationSettings.kt index c8d1492..8585ad9 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/NotificationSettings.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/NotificationSettings.kt @@ -4,24 +4,10 @@ import android.content.Context /** * What a notification is allowed to say, and whether to post one at all. - * - * A lock screen is a shoulder-surfing surface. This app draws its - * notifications on the phone, from plaintext it has already decrypted, so it - * *could* put the sender and the message on the lock screen, and for most of - * this app's life it deliberately did not, because the product's whole claim is - * that a message is for the two people in it. `MessengerService` says so at the - * only place it could have: "Count only, until there is a setting for it." - * - * This is that setting, and it defaults to the behaviour that was there before: - * a count, no name, no words. Somebody who wants their phone to say who is - * writing can ask for it; nobody gets it by upgrading. - * - * Same keys and same defaults as iOS, so the two phones behave alike for one - * person. iOS reads these through `SettingsKeys`; the names differ because the - * platforms store differently, and the DEFAULTS are what must not drift. */ -class NotificationSettings(context: Context) { - +class NotificationSettings( + context: Context, +) { private val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) /** The master switch. Off means no notification of any kind is posted. */ @@ -36,13 +22,6 @@ class NotificationSettings(context: Context) { /** * Put the sender's name, and a hint of the message, on the lock screen. - * - * Off by default, and that is the honest default rather than the - * cautious one. Turning it on is a real choice with a real cost: anyone - * who can see the phone can see who is writing to you, which on some - * phones is a more sensitive fact than what they said. The people for whom - * that is fine can have it; nobody should acquire it by installing an - * update. */ var showPreviews: Boolean get() = prefs.getBoolean(SHOW_PREVIEWS, false) @@ -55,44 +34,44 @@ class NotificationSettings(context: Context) { /** * The text for a message notification, given how many threads are waiting. - * - * Lifted out of the service so it can be asserted. A string built - * inline inside a `NotificationCompat.Builder` chain is a string nothing - * can test, and the thing being decided here is whether a name reaches a - * lock screen. `registeredLine` records what that costs, a template that - * shipped rendering its own source code, invisible to the compiler. - * - * `from` is only consulted when previews are on. A caller that passes a - * name is not asking for it to be shown; this decides that. */ - fun messageText(threads: Int, from: String?): String = - messageText(showPreviews, threads, from) + fun messageText( + threads: Int, + from: String?, + ): String = messageText(showPreviews, threads, from) /** The same question for a reply reminder. */ - fun replyReminderText(count: Int, from: String?): String = - replyReminderText(showPreviews, count, from) + fun replyReminderText( + count: Int, + from: String?, + ): String = replyReminderText(showPreviews, count, from) companion object { /** - * Pure, and takes the preference as an argument, so a test can - * call THIS rather than a copy of it. The first version of the test - * beside this file reimplemented the rules against a fake and passed - * perfectly while asserting nothing about the code that ships, the - * same shape as two identically-wrong engines agreeing. A `Context` is - * not available in a JVM unit test, so the decision had to leave the - * class that needs one. + * Pure, and takes the preference as an argument, so a test can call + * THIS rather than a copy of it. */ - fun messageText(showPreviews: Boolean, threads: Int, from: String?): String = when { - showPreviews && threads == 1 && !from.isNullOrBlank() -> "New message from $from" - threads == 1 -> "New message" - else -> "New messages in $threads chats" - } + fun messageText( + showPreviews: Boolean, + threads: Int, + from: String?, + ): String = + when { + showPreviews && threads == 1 && !from.isNullOrBlank() -> "New message from $from" + threads == 1 -> "New message" + else -> "New messages in $threads chats" + } - fun replyReminderText(showPreviews: Boolean, count: Int, from: String?): String = when { - showPreviews && count == 1 && !from.isNullOrBlank() -> "Still no reply from $from" - count == 1 -> "Still no reply" - else -> "Still no reply in $count chats" - } + fun replyReminderText( + showPreviews: Boolean, + count: Int, + from: String?, + ): String = + when { + showPreviews && count == 1 && !from.isNullOrBlank() -> "Still no reply from $from" + count == 1 -> "Still no reply" + else -> "Still no reply in $count chats" + } private const val PREFS = "notification-settings" private const val ENABLED = "enabled" diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/Obfs4Settings.kt b/app/src/main/kotlin/io/flamenet/messenger/app/Obfs4Settings.kt index 31282b1..a276877 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/Obfs4Settings.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/Obfs4Settings.kt @@ -7,46 +7,27 @@ import io.flamenet.obfs4.BridgeLineResult /** * The bridge line this phone uses, if any. - * - * Nothing is stored that [BridgeLine.parse] rejected. A line that will - * not parse becomes "no bridge", and "no bridge" is a direct connection, so - * storing one would put the person who pasted it back on the exact path they - * were trying to leave, while the screen went on showing their line. The screen - * refuses it instead and says which part is wrong. - * - * Ordinary SharedPreferences, not the vault. A bridge line is not a secret: - * operators publish them, and it has to be readable on a path that runs before - * the encrypted store is open, the app needs its transport before it can sign - * in. - * - * This hides traffic from the NETWORK, not from us. The bridge proxies - * to the relay, which still sees everything it saw before. No copy anywhere may - * imply otherwise. */ -class Obfs4Settings(context: Context) { - +class Obfs4Settings( + context: Context, +) { private val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) /** * The stored line, exactly as it was pasted. - * - * Kept as the operator's own line rather than as parsed fields, so it can - * be shown back unchanged. A round trip through separate fields loses the - * fingerprint and makes a typo unrecoverable. */ var bridgeLine: String? get() = prefs.getString(BRIDGE_LINE, null) private set(value) { - prefs.edit().apply { - if (value.isNullOrBlank()) remove(BRIDGE_LINE) else putString(BRIDGE_LINE, value) - }.apply() + prefs + .edit() + .apply { + if (value.isNullOrBlank()) remove(BRIDGE_LINE) else putString(BRIDGE_LINE, value) + }.apply() } /** * Stores a line and starts using it, or reports why it cannot be used. - * - * The only way in. A setter that took any string would be the silent - * fallback all over again. */ fun save(line: String): BridgeLineResult { val trimmed = line.trim() @@ -65,22 +46,15 @@ class Obfs4Settings(context: Context) { } /** - * Puts the stored line into force. Called once at startup. - * - * Before any client is built. The default HTTP client consults - * [Obfs4Routing] per socket rather than at construction, so a late call - * still takes effect, but a request that went out first would have gone - * out directly, and on a network that blocks the relay that is a failed - * launch rather than a slow one. - * - * A stored line that no longer parses is discarded rather than kept. - * Leaving it would show a bridge on the settings screen that nothing could - * use. + * Puts the stored line into force. */ fun applyAtStartup() { val line = bridgeLine ?: return when (val result = BridgeLine.parse(line)) { - is BridgeLineResult.Ok -> Obfs4Routing.use(result.bridge) + is BridgeLineResult.Ok -> { + Obfs4Routing.use(result.bridge) + } + is BridgeLineResult.Bad -> { bridgeLine = null Obfs4Routing.use(null) @@ -90,15 +64,6 @@ class Obfs4Settings(context: Context) { /** * Accept a bridge a contact offered. - * - * The only path from an offer to a route, and it takes a person. - * The transport layer stores offers and applies none of them; this is - * called from a button. Everything else about an offer, that it arrived, - * who sent it, that it parses, changes nothing about where this phone's - * traffic goes. - * - * Returns the same result [save] does, so a line that arrived broken is - * reported with a reason rather than dropped. */ fun accept(offeredLine: String): BridgeLineResult = save(offeredLine) diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/ParentalControls.kt b/app/src/main/kotlin/io/flamenet/messenger/app/ParentalControls.kt index fcd6193..6660fb4 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ParentalControls.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ParentalControls.kt @@ -6,24 +6,8 @@ import java.security.SecureRandom /** * Device-side supervision a parent configures on a child's phone. - * - * The honest shape for an E2E messenger: the server cannot read messages, so - * there is nothing server-side to moderate, what a parent CAN govern is what - * this device is allowed to do. A PIN the parent sets guards three switches: - * adding new contacts, the stories surface, and calls. - * - * Threat model, stated plainly: this restrains a child using a phone the - * parent configured. It is enforced in this app's own UI and stored on the - * device; it is not a defence against someone who can install apps freely or - * clear this one's data, that boundary belongs to Family Link, which parents - * should use alongside it. - * - * The PIN is stored as a salted SHA-256 hash, never in the clear, and none of - * this, the PIN, the toggles, or the fact supervision is on, ever leaves - * the device. The server learns nothing. */ object ParentalControls { - private const val PREFS = "parental-controls" private const val PIN_HASH = "pinHash" private const val PIN_SALT = "pinSalt" @@ -31,38 +15,45 @@ object ParentalControls { const val STORIES_HIDDEN = "storiesHidden" const val CALLS_DISABLED = "callsDisabled" - private fun prefs(context: Context) = - context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + private fun prefs(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) fun isEnabled(context: Context) = prefs(context).getString(PIN_HASH, null) != null - fun contactsGated(context: Context) = - isEnabled(context) && prefs(context).getBoolean(CONTACTS_GATED, false) + fun contactsGated(context: Context) = isEnabled(context) && prefs(context).getBoolean(CONTACTS_GATED, false) - fun storiesHidden(context: Context) = - isEnabled(context) && prefs(context).getBoolean(STORIES_HIDDEN, false) + fun storiesHidden(context: Context) = isEnabled(context) && prefs(context).getBoolean(STORIES_HIDDEN, false) - fun callsDisabled(context: Context) = - isEnabled(context) && prefs(context).getBoolean(CALLS_DISABLED, false) + fun callsDisabled(context: Context) = isEnabled(context) && prefs(context).getBoolean(CALLS_DISABLED, false) - fun setGate(context: Context, key: String, on: Boolean) { + fun setGate( + context: Context, + key: String, + on: Boolean, + ) { prefs(context).edit().putBoolean(key, on).apply() } - fun enable(context: Context, pin: String) { + fun enable( + context: Context, + pin: String, + ) { val salt = ByteArray(16).also(SecureRandom()::nextBytes) - prefs(context).edit() + prefs(context) + .edit() .putString(PIN_SALT, salt.joinToString("") { "%02x".format(it) }) .putString(PIN_HASH, hash(pin, salt)) - // The gates default ON, a parent turning this on wants - // supervision, and each is individually releasable afterwards. + // The gates default ON, a parent turning this on wants supervision, + // and each is individually releasable afterwards. .putBoolean(CONTACTS_GATED, true) .putBoolean(STORIES_HIDDEN, true) .putBoolean(CALLS_DISABLED, true) .apply() } - fun verify(context: Context, pin: String): Boolean { + fun verify( + context: Context, + pin: String, + ): Boolean { val stored = prefs(context).getString(PIN_HASH, null) ?: return false val saltHex = prefs(context).getString(PIN_SALT, null) ?: return false val salt = saltHex.chunked(2).map { it.toInt(16).toByte() }.toByteArray() @@ -71,8 +62,12 @@ object ParentalControls { fun disable(context: Context) = prefs(context).edit().clear().apply() - private fun hash(pin: String, salt: ByteArray): String = - MessageDigest.getInstance("SHA-256") + private fun hash( + pin: String, + salt: ByteArray, + ): String = + MessageDigest + .getInstance("SHA-256") .apply { update(salt) } .digest(pin.toByteArray(Charsets.UTF_8)) .joinToString("") { "%02x".format(it) } diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/ParentalControlsSection.kt b/app/src/main/kotlin/io/flamenet/messenger/app/ParentalControlsSection.kt index c2c82fc..0acf295 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ParentalControlsSection.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ParentalControlsSection.kt @@ -25,8 +25,7 @@ import androidx.compose.ui.unit.sp /** * Parental controls on the Account screen: a parent's PIN guarding what this - * device may do. Opening the switches requires the PIN, they would be - * decoration if the person they restrain could flip them. + * device may do. */ @Composable fun ParentalControlsSection() { @@ -39,24 +38,26 @@ fun ParentalControlsSection() { var newPin by remember { mutableStateOf("") } var repeatPin by remember { mutableStateOf("") } - @Suppress("UNUSED_EXPRESSION") tick + @Suppress("UNUSED_EXPRESSION") + tick Text("Parental controls", style = MaterialTheme.typography.titleMedium) when { !ParentalControls.isEnabled(context) -> { Text( - "A parent sets a PIN on this phone. While supervision is on, " - + "adding contacts asks for the PIN, and stories and calls can " - + "be switched off, all enforced on this device. Nothing about " - + "supervision is sent to any server. Use it alongside Family " - + "Link, which governs the phone itself.", + "A parent sets a PIN on this phone. While supervision is on, " + + "adding contacts asks for the PIN, and stories and calls can " + + "be switched off, all enforced on this device. Nothing about " + + "supervision is sent to any server. Use it alongside Family " + + "Link, which governs the phone itself.", fontSize = 12.sp, color = Palette.SecondaryText, ) Spacer(Modifier.height(Space.sm)) OutlinedTextField( - value = newPin, onValueChange = { newPin = it }, + value = newPin, + onValueChange = { newPin = it }, placeholder = { Text("Parent PIN (at least 4 digits)", fontSize = 14.sp) }, singleLine = true, visualTransformation = PasswordVisualTransformation(), @@ -64,7 +65,8 @@ fun ParentalControlsSection() { ) Spacer(Modifier.height(Space.xs)) OutlinedTextField( - value = repeatPin, onValueChange = { repeatPin = it }, + value = repeatPin, + onValueChange = { repeatPin = it }, placeholder = { Text("Repeat PIN", fontSize = 14.sp) }, singleLine = true, visualTransformation = PasswordVisualTransformation(), @@ -74,7 +76,10 @@ fun ParentalControlsSection() { OutlinedButton( onClick = { ParentalControls.enable(context, newPin) - newPin = ""; repeatPin = ""; unlocked = true; tick++ + newPin = "" + repeatPin = "" + unlocked = true + tick++ }, enabled = newPin.length >= 4 && newPin == repeatPin, modifier = Modifier.testTag("parental-enable"), @@ -82,11 +87,18 @@ fun ParentalControlsSection() { } !unlocked -> { - Text("Supervision is on. The settings behind this are the parent's to change.", - fontSize = 12.sp, color = Palette.SecondaryText) + Text( + "Supervision is on. The settings behind this are the parent's to change.", + fontSize = 12.sp, + color = Palette.SecondaryText, + ) Spacer(Modifier.height(Space.sm)) OutlinedTextField( - value = pinEntry, onValueChange = { pinEntry = it; wrong = false }, + value = pinEntry, + onValueChange = { + pinEntry = it + wrong = false + }, placeholder = { Text("Parent PIN", fontSize = 14.sp) }, singleLine = true, visualTransformation = PasswordVisualTransformation(), @@ -94,8 +106,11 @@ fun ParentalControlsSection() { modifier = Modifier.fillMaxWidth().testTag("parental-unlock-pin"), ) if (wrong) { - Text("That's not the PIN.", fontSize = 12.sp, - color = MaterialTheme.colorScheme.error) + Text( + "That's not the PIN.", + fontSize = 12.sp, + color = MaterialTheme.colorScheme.error, + ) } Spacer(Modifier.height(Space.xs)) OutlinedButton( @@ -108,31 +123,44 @@ fun ParentalControlsSection() { ) { Text("Unlock") } } - else -> Column { - GateRow("Ask the PIN to add a contact", ParentalControls.CONTACTS_GATED) { tick++ } - GateRow("Hide stories", ParentalControls.STORIES_HIDDEN) { tick++ } - GateRow("Turn off calls", ParentalControls.CALLS_DISABLED) { tick++ } - Text( - "With calls off, this phone neither places calls nor rings, a " - + "caller is answered busy.", - fontSize = 12.sp, color = Palette.SecondaryText, - ) - Spacer(Modifier.height(Space.sm)) - OutlinedButton( - onClick = { ParentalControls.disable(context); unlocked = false; tick++ }, - modifier = Modifier.testTag("parental-disable"), - ) { Text("Turn off supervision") } + else -> { + Column { + GateRow("Ask the PIN to add a contact", ParentalControls.CONTACTS_GATED) { tick++ } + GateRow("Hide stories", ParentalControls.STORIES_HIDDEN) { tick++ } + GateRow("Turn off calls", ParentalControls.CALLS_DISABLED) { tick++ } + Text( + "With calls off, this phone neither places calls nor rings, a " + + "caller is answered busy.", + fontSize = 12.sp, + color = Palette.SecondaryText, + ) + Spacer(Modifier.height(Space.sm)) + OutlinedButton( + onClick = { + ParentalControls.disable(context) + unlocked = false + tick++ + }, + modifier = Modifier.testTag("parental-disable"), + ) { Text("Turn off supervision") } + } } } } @Composable -private fun GateRow(label: String, key: String, onChanged: () -> Unit) { +private fun GateRow( + label: String, + key: String, + onChanged: () -> Unit, +) { val context = LocalContext.current var on by remember(key) { mutableStateOf( - context.getSharedPreferences("parental-controls", android.content.Context.MODE_PRIVATE) - .getBoolean(key, false)) + context + .getSharedPreferences("parental-controls", android.content.Context.MODE_PRIVATE) + .getBoolean(key, false), + ) } Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { Text(label, fontSize = 14.sp, modifier = Modifier.weight(1f)) diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/Pickers.kt b/app/src/main/kotlin/io/flamenet/messenger/app/Pickers.kt index 6a9cc8a..571de34 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/Pickers.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/Pickers.kt @@ -36,10 +36,6 @@ import androidx.compose.ui.unit.sp /** * Stickers, from the shipped pack. - * - * Tapping one SENDS it. A sticker is a whole message, there is nothing to - * add it to and nothing further to say, which is the opposite of the emoji - * panel below, and the reason these are two controls rather than one. */ @Composable fun ColumnScope.StickerPicker(onPick: (StickerPack.Sticker) -> Unit) { @@ -47,8 +43,7 @@ fun ColumnScope.StickerPicker(onPick: (StickerPack.Sticker) -> Unit) { val stickers = StickerPack.all(context) if (stickers.isEmpty()) { - // The manifest failed to load. Say so rather than showing an empty - // panel that reads as "there are no stickers". + // The manifest failed to load. Text( "Stickers could not be loaded on this device.", fontSize = 14.sp, @@ -60,22 +55,26 @@ fun ColumnScope.StickerPicker(onPick: (StickerPack.Sticker) -> Unit) { LazyVerticalGrid( columns = GridCells.Adaptive(minSize = 64.dp), - modifier = Modifier - .fillMaxWidth() - .heightIn(max = 220.dp) - .clip(RoundedCornerShape(Radius.large)) - .background(Palette.SurfaceAlt) - .testTag("sticker-picker"), - contentPadding = androidx.compose.foundation.layout.PaddingValues(Space.sm), + modifier = + Modifier + .fillMaxWidth() + .heightIn(max = 220.dp) + .clip(RoundedCornerShape(Radius.large)) + .background(Palette.SurfaceAlt) + .testTag("sticker-picker"), + contentPadding = + androidx.compose.foundation.layout + .PaddingValues(Space.sm), ) { items(stickers, key = { it.id }) { sticker -> Box( - modifier = Modifier - .padding(Space.xs) - .clip(RoundedCornerShape(Radius.small)) - .clickable { onPick(sticker) } - .semantics { contentDescription = sticker.name } - .testTag("sticker-pick-${sticker.id}"), + modifier = + Modifier + .padding(Space.xs) + .clip(RoundedCornerShape(Radius.small)) + .clickable { onPick(sticker) } + .semantics { contentDescription = sticker.name } + .testTag("sticker-pick-${sticker.id}"), contentAlignment = Alignment.Center, ) { Text(sticker.glyph, fontSize = 34.sp, modifier = Modifier.padding(Space.sm)) @@ -86,33 +85,14 @@ fun ColumnScope.StickerPicker(onPick: (StickerPack.Sticker) -> Unit) { /** * Emoji, straight into the draft. - * - * Not a protocol feature. An emoji is a character: it goes into the text - * of an ordinary message and is indistinguishable from any other character once - * sent. Nothing on the receiving end needs to understand it, which is exactly - * what makes it different from a sticker. - * - * The panel stays open on tap. Somebody picking three in a row expects three - * characters in the box, and closing on each tap would make that impossible. - * - * Recents and grouping, matching iOS. This was a flat grid while iOS - * kept a recents row and grouped the set the way people think about them, - * found when a duplicate iOS picker nearly shadowed the better one. Two - * platforms offering the same emoji through different furniture is the drift - * nobody files a bug about and everybody notices. The mechanics mirror the - * iOS file: recents are the last twelve picked, most recent first, in ordinary - * SharedPreferences, which emoji somebody reaches for is not a secret worth a - * vault entry, and the vault cannot be read before sign-in anyway. - * - * Still a curated set, not all of Unicode. A full picker is search, skin - * tones and category tabs, a real feature the system keyboard already ships. */ @Composable fun ColumnScope.EmojiPicker(onPick: (String) -> Unit) { val context = LocalContext.current - val prefs = remember { - context.getSharedPreferences("flamenet-emoji", android.content.Context.MODE_PRIVATE) - } + val prefs = + remember { + context.getSharedPreferences("flamenet-emoji", android.content.Context.MODE_PRIVATE) + } var recents by remember { mutableStateOf(prefs.getString("recent", "")!!.split(" ").filter { it.isNotBlank() }) } @@ -125,14 +105,15 @@ fun ColumnScope.EmojiPicker(onPick: (String) -> Unit) { } Column( - modifier = Modifier - .fillMaxWidth() - .heightIn(max = 220.dp) - .clip(RoundedCornerShape(Radius.large)) - .background(Palette.SurfaceAlt) - .verticalScroll(rememberScrollState()) - .padding(Space.sm) - .testTag("emoji-picker"), + modifier = + Modifier + .fillMaxWidth() + .heightIn(max = 220.dp) + .clip(RoundedCornerShape(Radius.large)) + .background(Palette.SurfaceAlt) + .verticalScroll(rememberScrollState()) + .padding(Space.sm) + .testTag("emoji-picker"), verticalArrangement = Arrangement.spacedBy(Space.xs), ) { if (recents.isNotEmpty()) EmojiSection("Recent", recents, ::pick) @@ -141,7 +122,11 @@ fun ColumnScope.EmojiPicker(onPick: (String) -> Unit) { } @Composable -private fun EmojiSection(title: String, emoji: List, onPick: (String) -> Unit) { +private fun EmojiSection( + title: String, + emoji: List, + onPick: (String) -> Unit, +) { Text( title, fontSize = 11.sp, @@ -149,20 +134,22 @@ private fun EmojiSection(title: String, emoji: List, onPick: (String) -> color = Palette.SecondaryText, ) Row( - modifier = Modifier - .fillMaxWidth() - .horizontalScroll(rememberScrollState()), + modifier = + Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(Space.xs), ) { emoji.forEach { e -> Box( - modifier = Modifier - .clip(RoundedCornerShape(Radius.small)) - .clickable { onPick(e) } - // Scoped by section, as on iOS: the same emoji can sit in - // Recent AND its own group, and a tag on the glyph alone is - // ambiguous to a test. - .testTag("emoji-$title-$e"), + modifier = + Modifier + .clip(RoundedCornerShape(Radius.small)) + .clickable { onPick(e) } + // Scoped by section, as on iOS: the same emoji can sit in + // Recent AND its own group, and a tag on the glyph alone is + // ambiguous to a test. + .testTag("emoji-$title-$e"), ) { Text(e, fontSize = 24.sp, modifier = Modifier.padding(Space.xs)) } @@ -172,13 +159,47 @@ private fun EmojiSection(title: String, emoji: List, onPick: (String) -> /** * The same set as iOS, grouped the same way, kept in step on purpose. - * If one platform's list changes, change the other in the same sitting. */ -private val EMOJI_GROUPS = listOf( - "Smileys" to listOf("😀", "😂", "🙂", "😉", "😍", "🤔", "😅", "😭", "😡", "🥳", - "😴", "🤯", "🫠", "😬", "🙃", "😎"), - "Gestures" to listOf("👍", "👎", "👋", "🙏", "👏", "🤝", "💪", "🫶", "✌️", "🤞"), - "Hearts" to listOf("❤️", "🧡", "💛", "💚", "💙", "💜", "🖤", "💔", "✨", "🔥"), - "Things" to listOf("✅", "❌", "⚠️", "📌", "📎", "🔒", "🔑", "☕", "🍕", "🎉", - "🎂", "🚀", "🌧️", "☀️", "🌙", "⭐"), -) +private val EMOJI_GROUPS = + listOf( + "Smileys" to + listOf( + "😀", + "😂", + "🙂", + "😉", + "😍", + "🤔", + "😅", + "😭", + "😡", + "🥳", + "😴", + "🤯", + "🫠", + "😬", + "🙃", + "😎", + ), + "Gestures" to listOf("👍", "👎", "👋", "🙏", "👏", "🤝", "💪", "🫶", "✌️", "🤞"), + "Hearts" to listOf("❤️", "🧡", "💛", "💚", "💙", "💜", "🖤", "💔", "✨", "🔥"), + "Things" to + listOf( + "✅", + "❌", + "⚠️", + "📌", + "📎", + "🔒", + "🔑", + "☕", + "🍕", + "🎉", + "🎂", + "🚀", + "🌧️", + "☀️", + "🌙", + "⭐", + ), + ) diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/SafReferences.kt b/app/src/main/kotlin/io/flamenet/messenger/app/SafReferences.kt index 32b32c5..39446f7 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/SafReferences.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/SafReferences.kt @@ -9,99 +9,80 @@ import io.flamenet.client.LocalVault /** * Files that stay where their owner put them, reached through the Storage * Access Framework. - * - * This is the Android half of [LocalVault.ReferenceResolver], the piece the - * vault deliberately does not know about, so that the vault itself can be - * tested on a laptop with no emulator. - * - * The permission must be persisted or the row is broken tomorrow. - * `ActivityResultContracts.GetContent` hands back a URI whose read grant dies - * with the process. A reference made from one works perfectly for the rest of - * the session and then fails on the next launch, the worst shape of bug, since - * whoever added the file watched it work. `OpenDocument` plus - * [takePersistablePermission] is the combination that survives a reboot, and it - * is why the picker for this screen is not the picker the chat composer uses. - * - * A persisted grant is still revocable. The owner can move the file, - * delete it, or clear the app's access from Settings, and the row is then - * pointing at nothing. That is allowed, it is their file, so every path here - * reports it rather than throwing something a screen cannot phrase. */ -class SafReferences(private val context: Context) : LocalVault.ReferenceResolver { - +class SafReferences( + private val context: Context, +) : LocalVault.ReferenceResolver { /** * Keep read access across relaunches. - * - * Returns false when the provider refuses to make the grant persistable, - * which some do. The caller must NOT file a reference in that case: a row - * that cannot be reopened is worse than a picker that says "this file can't - * be linked to, copy it in instead", because the first one looks like it - * worked. */ - fun takePersistablePermission(uri: Uri): Boolean = try { - context.contentResolver.takePersistableUriPermission( - uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) - true - } catch (e: SecurityException) { - false - } + fun takePersistablePermission(uri: Uri): Boolean = + try { + context.contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) + true + } catch (e: SecurityException) { + false + } fun releasePersistablePermission(bookmark: String) { runCatching { - context.contentResolver.releasePersistableUriPermission( - Uri.parse(bookmark), Intent.FLAG_GRANT_READ_URI_PERMISSION) + context.contentResolver.releasePersistableUriPermission(Uri.parse(bookmark), Intent.FLAG_GRANT_READ_URI_PERMISSION) } } /** - * Opening the stream, not asking the provider whether the file - * exists. `DocumentsContract` will happily describe a document that can no - * longer be read, a cloud provider with the account signed out is the - * everyday case, and a row that says it is fine and then fails on tap is - * the thing this whole distinction exists to avoid. + * Opening the stream, not asking the provider whether the file exists. */ - override fun exists(bookmark: String): Boolean = try { - context.contentResolver.openInputStream(Uri.parse(bookmark))?.use { true } ?: false - } catch (e: Exception) { - // Deliberately broad. A provider that has gone away throws whatever it - // likes. SecurityException, FileNotFoundException, IllegalArgument, - // and on at least one vendor's provider a plain NullPointerException. - // Every one of them means the same thing to a person: it isn't there. - false - } + override fun exists(bookmark: String): Boolean = + try { + context.contentResolver.openInputStream(Uri.parse(bookmark))?.use { true } ?: false + } catch (e: Exception) { + // Deliberately broad. + false + } override fun read(bookmark: String): ByteArray = context.contentResolver.openInputStream(Uri.parse(bookmark))?.use { it.readBytes() } - ?: throw LocalVault.VaultException( - "That file isn't where it was, it may have been moved or deleted.") + ?: throw LocalVault.VaultException("That file isn't where it was, it may have been moved or deleted.") /** * Zero when the provider will not say, which is a real answer and not a - * failure. `OpenableColumns.SIZE` is optional, and several providers return - * null for it, the row then shows no size rather than a wrong one. + * failure. */ - override fun size(bookmark: String): Int = try { - context.contentResolver.query( - Uri.parse(bookmark), arrayOf(OpenableColumns.SIZE), null, null, null - )?.use { cursor -> - if (!cursor.moveToFirst()) return@use 0 - val column = cursor.getColumnIndex(OpenableColumns.SIZE) - if (column < 0 || cursor.isNull(column)) 0 else cursor.getLong(column).toInt() - } ?: 0 - } catch (e: Exception) { - 0 - } + override fun size(bookmark: String): Int = + try { + context.contentResolver + .query( + Uri.parse(bookmark), + arrayOf(OpenableColumns.SIZE), + null, + null, + null, + )?.use { cursor -> + if (!cursor.moveToFirst()) return@use 0 + val column = cursor.getColumnIndex(OpenableColumns.SIZE) + if (column < 0 || cursor.isNull(column)) 0 else cursor.getLong(column).toInt() + } ?: 0 + } catch (e: Exception) { + 0 + } /** The name the provider shows, for pre-filling the file's name in the vault. */ - fun displayName(uri: Uri): String? = try { - context.contentResolver.query( - uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null - )?.use { cursor -> - if (!cursor.moveToFirst()) return@use null - val column = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) - if (column < 0 || cursor.isNull(column)) null else cursor.getString(column) + fun displayName(uri: Uri): String? = + try { + context.contentResolver + .query( + uri, + arrayOf(OpenableColumns.DISPLAY_NAME), + null, + null, + null, + )?.use { cursor -> + if (!cursor.moveToFirst()) return@use null + val column = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (column < 0 || cursor.isNull(column)) null else cursor.getString(column) + } + } catch (e: Exception) { + null } - } catch (e: Exception) { - null - } } diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/ScanAddress.kt b/app/src/main/kotlin/io/flamenet/messenger/app/ScanAddress.kt index 846cbe5..da99ecf 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ScanAddress.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ScanAddress.kt @@ -21,26 +21,18 @@ import androidx.compose.ui.window.DialogProperties /** * Point the camera at somebody's address, the other half of [AddressCard]. - * - * Nothing here trusts what it read. A QR code is a string an arbitrary - * stranger controls, so every scan goes through [parseScannedAddress], the - * same refusals as iOS's `ContactCard.decode`: a relay is a hostname, never a - * URL, and a code that is not an address is ignored rather than shown as one. - * - * The camera pipeline is WebRTC's own, [org.webrtc.Camera2Capturer] into a - * [org.webrtc.SurfaceViewRenderer], exactly the capture stack video calls - * already ship. The only new code is handing each frame's Y plane to the QR - * decoder; pulling in a second camera framework to look for a square would be - * a dependency for decoration. */ /** What a valid code carries. `handle` only when the QR's author included it. */ -internal data class ScannedAddress(val fnid: String, val relay: String, val handle: String?) +internal data class ScannedAddress( + val fnid: String, + val relay: String, + val handle: String?, +) /** - * `flamenet:@[?u=&k=…]`, or the bare `fnid@relay` - * somebody retypes. Refuses anything else rather than guessing, an address - * that is nearly right addresses somebody else. + * `flamenet:@[?u=&k=…]`, or the bare `fnid@relay` somebody + * retypes. */ internal fun parseScannedAddress(text: String): ScannedAddress? { var s = text.trim() @@ -58,19 +50,23 @@ internal fun parseScannedAddress(text: String): ScannedAddress? { val fnid = s.substring(0, at).trim() val relay = s.substring(at + 1).trim().lowercase() if (fnid.isEmpty() || relay.isEmpty() || !relay.contains('.')) return null - // A relay is a hostname. Accepting "https://x/y" would let a printed code - // aim this client at a path on a host of the attacker's choosing. + // A relay is a hostname. if (relay.contains('/') || relay.contains(':')) return null return ScannedAddress(fnid, relay, handle?.takeIf { it.isNotBlank() }) } /** Full-screen camera dialog. Calls [onFound] once with the raw decoded text. */ @Composable -fun ScanAddressDialog(onFound: (String) -> Unit, onDismiss: () -> Unit) { +fun ScanAddressDialog( + onFound: (String) -> Unit, + onDismiss: () -> Unit, +) { val context = LocalContext.current - val granted = androidx.core.content.ContextCompat.checkSelfPermission( - context, android.Manifest.permission.CAMERA - ) == android.content.pm.PackageManager.PERMISSION_GRANTED + val granted = + androidx.core.content.ContextCompat.checkSelfPermission( + context, + android.Manifest.permission.CAMERA, + ) == android.content.pm.PackageManager.PERMISSION_GRANTED Dialog( onDismissRequest = onDismiss, @@ -78,12 +74,11 @@ fun ScanAddressDialog(onFound: (String) -> Unit, onDismiss: () -> Unit) { ) { Box(modifier = Modifier.fillMaxSize().background(Color.Black)) { if (!granted) { - // Says what to do, not just that it failed. Once refused, - // only Android settings can change the answer. + // Says what to do, not just that it failed. Column(modifier = Modifier.align(Alignment.Center).padding(24.dp)) { Text( - "Flamenet cannot use the camera. Allow it in Android " - + "settings, or paste their address into Add contact instead.", + "Flamenet cannot use the camera. Allow it in Android " + + "settings, or paste their address into Add contact instead.", color = Color.White, fontSize = 14.sp, modifier = Modifier.testTag("scan-denied"), @@ -103,26 +98,26 @@ fun ScanAddressDialog(onFound: (String) -> Unit, onDismiss: () -> Unit) { "Point at the code on their Address screen.", color = Color.White, fontSize = 13.sp, - modifier = Modifier - .align(Alignment.BottomCenter) - .padding(bottom = 48.dp), + modifier = + Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 48.dp), ) } TextButton( onClick = onDismiss, - modifier = Modifier - .align(Alignment.TopEnd) - .padding(top = 24.dp, end = 8.dp) - .testTag("scan-cancel"), + modifier = + Modifier + .align(Alignment.TopEnd) + .padding(top = 24.dp, end = 8.dp) + .testTag("scan-cancel"), ) { Text("Cancel", color = Color.White) } } } } /** - * The capture-and-decode loop. Frames render to the given view AND, four times - * a second, have their luminance plane offered to the QR decoder. First hit - * wins; everything after it is dropped. + * The capture-and-decode loop. */ private class QrScan( context: Context, @@ -132,28 +127,40 @@ private class QrScan( private val egl = org.webrtc.EglBase.create() private val helper = org.webrtc.SurfaceTextureHelper.create("qr-scan", egl.eglBaseContext) private var capturer: org.webrtc.CameraVideoCapturer? = null - private val done = java.util.concurrent.atomic.AtomicBoolean(false) + private val done = + java.util.concurrent.atomic + .AtomicBoolean(false) + @Volatile private var lastDecode = 0L - private val reader = com.google.zxing.qrcode.QRCodeReader() + private val reader = + com.google.zxing.qrcode + .QRCodeReader() private val main = android.os.Handler(android.os.Looper.getMainLooper()) init { renderer.init(egl.eglBaseContext, null) // The BACK camera: a code is on somebody else's screen, not your face. val enumerator = org.webrtc.Camera2Enumerator(context) - val name = enumerator.deviceNames.firstOrNull { enumerator.isBackFacing(it) } - ?: enumerator.deviceNames.firstOrNull() + val name = + enumerator.deviceNames.firstOrNull { enumerator.isBackFacing(it) } + ?: enumerator.deviceNames.firstOrNull() if (name != null) { runCatching { val cap = enumerator.createCapturer(name, null) - cap.initialize(helper, context, object : org.webrtc.CapturerObserver { - override fun onCapturerStarted(success: Boolean) {} - override fun onCapturerStopped() {} - override fun onFrameCaptured(frame: org.webrtc.VideoFrame) { - renderer.onFrame(frame) - maybeDecode(frame) - } - }) + cap.initialize( + helper, + context, + object : org.webrtc.CapturerObserver { + override fun onCapturerStarted(success: Boolean) {} + + override fun onCapturerStopped() {} + + override fun onFrameCaptured(frame: org.webrtc.VideoFrame) { + renderer.onFrame(frame) + maybeDecode(frame) + } + }, + ) cap.startCapture(1280, 720, 30) capturer = cap } @@ -170,8 +177,7 @@ private class QrScan( val w = i420.width val h = i420.height // Row by row because the stride can exceed the width; the decoder - // wants the packed plane. A QR carries its own orientation in the - // finder patterns, so the frame's rotation does not matter. + // wants the packed plane. val y = ByteArray(w * h) val data = i420.dataY for (row in 0 until h) { @@ -179,8 +185,11 @@ private class QrScan( data.get(y, row * w, w) } val source = com.google.zxing.PlanarYUVLuminanceSource(y, w, h, 0, 0, w, h, false) - val bitmap = com.google.zxing.BinaryBitmap( - com.google.zxing.common.HybridBinarizer(source)) + val bitmap = + com.google.zxing.BinaryBitmap( + com.google.zxing.common + .HybridBinarizer(source), + ) val text = runCatching { reader.decode(bitmap).text }.getOrNull() reader.reset() if (text != null && done.compareAndSet(false, true)) { diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt index 9afccd8..5bdc004 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt @@ -6,19 +6,14 @@ import io.flamenet.client.MessengerApi import io.flamenet.client.RelayClient import io.flamenet.e2e.Attachment import io.flamenet.e2e.ContactCard -import io.flamenet.e2e.SealedRoster import io.flamenet.e2e.Content import io.flamenet.e2e.DeviceIdentity +import io.flamenet.e2e.SealedRoster import io.flamenet.e2e.Vault import org.json.JSONObject /** * Everything a signed-in person needs, assembled once. - * - * Held together rather than reached for individually because they are only - * ever valid as a set: a [Conversations] built on one device identity and a - * [RelayClient] authenticated as a different account is a combination that - * compiles and cannot work. */ class Session( val handle: String, @@ -32,10 +27,6 @@ class Session( /** * One line in a thread. - * - * An attachment is stored as its *reference*, not its bytes: the blob is - * cached separately and a thread that inlined megabytes of image would - * rewrite all of them on every append. */ data class Line( val text: String, @@ -43,31 +34,15 @@ class Session( val at: Long, /** * What identifies this message to the other side. - * - * Null for anything stored before ids existed, and that is the - * whole compatibility story. A reaction, an edit and a pin all say - * "this one" by id; a line with none simply cannot be the subject of - * any of them. Making one up on read would be worse, two devices - * would invent different ids for the same message and a reaction would - * land on nothing. */ val id: String? = null, /** * Emoji to the people who sent it, folded onto the message rather than * filed as one of their own. - * - * One emoji per person, so reacting again replaces rather than - * accumulates, which is why this is keyed by emoji and holds a set of - * authors, and why applying one removes that author from every other - * entry first. */ val reactions: Map> = emptyMap(), /** - * Who wrote it, a user id, 0 when unrecorded. Groups need it: every - * received bubble in a group was captioned and pictured as the GROUP - * (its title's initial in the avatar slot), because the transcript - * only knew the thread, not the author. `record` has always persisted - * this; the read side just never surfaced it. + * Who wrote it, a user id, 0 when unrecorded. */ val author: Long = 0, val attachment: Attachment.Ref? = null, @@ -75,67 +50,28 @@ class Session( val sticker: Sticker? = null, /** * What happened to this message on its way out. - * - * The thing that makes a ghost message impossible. The old code - * had one bit, record it or do not, and both answers are wrong. Not - * recording means what you typed vanishes with no trace, which is what - * happened to a 1:1 message sent while the other person was signed out. - * Recording unconditionally means a bubble that looks exactly like a - * delivered one and was received by nobody, which is what happened to a - * group text. A message can only be a ghost while a delivered bubble - * and an undelivered bubble are the same picture. - * - * Three states, not two, and the third is the important one. - * "Not delivered" on a message to a friend who is merely signed out - * puts the work on the person who did nothing wrong, they would have - * to remember, and check, and send it again. [Delivery.PENDING] means - * it is in the outbox and will go on its own. Only a message that has - * been waiting long enough to be worth mentioning becomes FAILED. - * - * Null for received messages and for anything written before this - * field existed, absence of a claim, not a claim of failure. */ val delivery: Delivery? = null, /** A sender-generated preview of a link in this message (§6.12). */ val link: Content.LinkPreview? = null, /** * The sender's own id for this message (§6.9). - * - * The wire always carried one and this type never stored it. - * `Content.groupText` has taken a `messageId` since §6.9 shipped and - * the parser reads it back, the transcript was where it stopped. So - * nothing here could name an earlier message: no replies to resolve, no - * reactions to fold, no edits to apply. */ val messageId: String? = null, /** * What this message quotes, when it is a reply (§6.9). - * - * Also never stored, so a reply from the iPhone app arrived as a bare - * message with the quote silently removed, and the reader had no way - * to know anything had been dropped. */ val about: Content.Reference? = null, /** * Second-hand words (§6.16). - * - * THAT it was forwarded, never from whom. */ val forwarded: Boolean = false, /** * When this message was last edited (§6.15), or null if never. - * - * Kept so the bubble can say "edited". A correction that silently - * replaces the text is worse than the typo: the reader has no way to - * know the words changed after they read them, and no way to tell a - * correction from their own misremembering. */ val editedAt: Long? = null, /** * A timer this one message carries, in seconds (§6.17); 0 for none. - * - * Not the conversation's, `Vault.effectiveLifetime` combines the - * two, and the shorter always wins. */ val lifetimeSeconds: Int = 0, ) @@ -144,9 +80,10 @@ class Session( enum class Delivery { PENDING, DELIVERED, FAILED } /** Which sticker a line is, when it is one. */ - data class Sticker(val pack: String, val id: String) - - + data class Sticker( + val pack: String, + val id: String, + ) fun history(threadId: String): List { val stored = vault.history(threadId) @@ -157,24 +94,35 @@ class Session( mine = m.optBoolean("mine"), at = m.optLong("at"), id = m.optString("mid").takeIf { it.isNotBlank() }, - reactions = m.optJSONObject("rx")?.let { stored -> - stored.keys().asSequence().associateWith { emoji -> - val who = stored.optJSONArray(emoji) ?: org.json.JSONArray() - (0 until who.length()).map { i -> who.getLong(i) }.toSet() - }.filterValues { it.isNotEmpty() } - }.orEmpty(), + reactions = + m + .optJSONObject("rx") + ?.let { stored -> + stored + .keys() + .asSequence() + .associateWith { emoji -> + val who = stored.optJSONArray(emoji) ?: org.json.JSONArray() + (0 until who.length()).map { i -> who.getLong(i) }.toSet() + }.filterValues { it.isNotEmpty() } + }.orEmpty(), author = m.optLong("author"), - attachment = m.optJSONObject("att")?.let { att -> - Attachment.Ref.fromJson(m.optString("kind", Attachment.IMAGE), att, 0.0) - }, + attachment = + m.optJSONObject("att")?.let { att -> + Attachment.Ref.fromJson(m.optString("kind", Attachment.IMAGE), att, 0.0) + }, // `if (has)` rather than `optBoolean`, which would turn a // message stored before this field into a claim of failure and // decorate every older bubble with a warning. - sticker = m.optJSONObject("stk")?.let { - Sticker(it.optString("pack"), it.optString("id")) - }, - delivery = m.optString("delivery").takeIf { it.isNotBlank() } - ?.let { runCatching { Delivery.valueOf(it) }.getOrNull() }, + sticker = + m.optJSONObject("stk")?.let { + Sticker(it.optString("pack"), it.optString("id")) + }, + delivery = + m + .optString("delivery") + .takeIf { it.isNotBlank() } + ?.let { runCatching { Delivery.valueOf(it) }.getOrNull() }, link = m.optJSONObject("lnk")?.let { Content.LinkPreview.fromJson(it) }, messageId = m.optString("mid").takeIf { it.isNotEmpty() }, about = m.optJSONObject("re")?.let { Content.Reference.fromJson(it) }, @@ -207,73 +155,66 @@ class Session( lifetimeSeconds: Int = 0, /** * Whether the sender is passing on somebody else's words (§6.16). - * - * Says THAT it was forwarded and never from whom, the point of a - * forward is that the original author does not travel with it. This - * exists so second-hand words are not shown as the sender's own, which - * is a different thing from attribution. */ forwarded: Boolean = false, ) { - vault.append(threadId, JSONObject() - .put("author", author) - .also { if (messageId != null) it.put("mid", messageId) } - .put("text", text).put("mine", mine).put("at", at) - // Written only when there is one. A zero on every message would - // grow the transcript for a feature almost nobody uses, and would - // make "no timer" indistinguishable from "a timer of nothing". - .also { if (lifetimeSeconds > 0) it.put("secs", lifetimeSeconds) } - .also { if (delivery != null) it.put("delivery", delivery.name) } - .also { - if (sticker != null) { - it.put("stk", JSONObject().put("pack", sticker.pack).put("id", sticker.id)) - } - } - .also { if (attachment != null) it.put("kind", attachment.kind).put("att", attachment.toJson()) } - .also { if (link != null) it.put("lnk", link.toJson()) } - // Written only when true, for the same reason `secs` is. A false - // on every message grows the transcript for a flag that is almost - // always absent. - .also { if (forwarded) it.put("fwd", true) } - .also { if (about != null) it.put("re", about.toJson()) }) + vault.append( + threadId, + JSONObject() + .put("author", author) + .also { if (messageId != null) it.put("mid", messageId) } + .put("text", text) + .put("mine", mine) + .put("at", at) + // Written only when there is one. + .also { if (lifetimeSeconds > 0) it.put("secs", lifetimeSeconds) } + .also { if (delivery != null) it.put("delivery", delivery.name) } + .also { + if (sticker != null) { + it.put("stk", JSONObject().put("pack", sticker.pack).put("id", sticker.id)) + } + }.also { if (attachment != null) it.put("kind", attachment.kind).put("att", attachment.toJson()) } + .also { if (link != null) it.put("lnk", link.toJson()) } + // Written only when true, for the same reason `secs` is. + .also { if (forwarded) it.put("fwd", true) } + .also { if (about != null) it.put("re", about.toJson()) }, + ) // A reply arriving cancels the reminder, and it is done HERE because - // this is the one place every inbound message passes through. Hanging it - // off a screen or a poll handler means the one path that forgot to call - // it produces a reminder that fires after they answered, which teaches - // people to ignore reminders, and is worse than not having them. + // this is the one place every inbound message passes through. if (!mine) clearReplyReminderOnInbound(threadId) } /** Clear a thread. Keeps the session, so the conversation still works. */ fun clearHistory(threadId: String) = vault.forgetHistory(threadId) - // -- Scheduled send ------------------------------------------------------- + // -- Scheduled send + // -------------------------------------------------------. /** * Queue a message to go out at [at] (epoch millis). - * - * Held on this phone, never at the relay. The relay could hold it - * and deliver on time with the phone off, and would then know that a - * message exists, who it is for, and exactly when it was meant to land, - * which is a richer piece of metadata than anything else this design gives - * it. The UI states the cost rather than hiding it. - * - * Rides the existing outbox, so a scheduled message inherits the retry - * behaviour instead of getting a second, less-tested delivery path. */ - fun scheduleSend(peerUserId: Long, text: String, at: Long) { + fun scheduleSend( + peerUserId: Long, + text: String, + at: Long, + ) { val pending = vault.outbox() - pending.put(JSONObject() - .put("to", peerUserId).put("text", text) - .put("at", System.currentTimeMillis()) - .put("not_before", at)) + pending.put( + JSONObject() + .put("to", peerUserId) + .put("text", text) + .put("at", System.currentTimeMillis()) + .put("not_before", at), + ) vault.saveOutbox(pending) } /** Messages queued for later in this conversation, soonest first. */ - fun scheduledFor(peerUserId: Long, now: Long = System.currentTimeMillis()): - List> { + fun scheduledFor( + peerUserId: Long, + now: Long = System.currentTimeMillis(), + ): List> { val pending = vault.outbox() return (0 until pending.length()) .mapNotNull { pending.optJSONObject(it) } @@ -283,38 +224,42 @@ class Session( } /** Drop the soonest scheduled message for a conversation before it goes. */ - fun cancelSoonestScheduled(peerUserId: Long, now: Long = System.currentTimeMillis()) { + fun cancelSoonestScheduled( + peerUserId: Long, + now: Long = System.currentTimeMillis(), + ) { val pending = vault.outbox() - val target = (0 until pending.length()) - .mapNotNull { i -> pending.optJSONObject(i)?.let { i to it } } - .filter { (_, o) -> o.optLong("to") == peerUserId && o.optLong("not_before", 0L) > now } - .minByOrNull { (_, o) -> o.optLong("not_before") } ?: return + val target = + (0 until pending.length()) + .mapNotNull { i -> pending.optJSONObject(i)?.let { i to it } } + .filter { (_, o) -> o.optLong("to") == peerUserId && o.optLong("not_before", 0L) > now } + .minByOrNull { (_, o) -> o.optLong("not_before") } ?: return val kept = org.json.JSONArray() for (i in 0 until pending.length()) if (i != target.first) kept.put(pending.get(i)) vault.saveOutbox(kept) } - // -- Attention management ------------------------------------------------- + // -- Attention management + // -------------------------------------------------. /** Snooze a conversation until [until] (epoch millis); 0 clears it. */ - fun snooze(peerId: Long, until: Long) = - vault.saveSnoozedUntil(Vault.threadForUser(peerId), until) + fun snooze( + peerId: Long, + until: Long, + ) = vault.saveSnoozedUntil(Vault.threadForUser(peerId), until) fun snoozedUntil(peerId: Long): Long = vault.snoozedUntil(Vault.threadForUser(peerId)) /** Thread ids still snoozed at [now]. */ - fun snoozedThreads(now: Long = System.currentTimeMillis()): Set = - vault.snoozedThreads(now) + fun snoozedThreads(now: Long = System.currentTimeMillis()): Set = vault.snoozedThreads(now) /** * Ask to be reminded about this conversation if nobody replies by [at]. - * - * Cancelled by a reply arriving, not only by firing, see - * [clearReplyReminderOnInbound]. A reminder that still goes off after they - * answered is worse than none: it teaches people to ignore it. */ - fun setReplyReminder(peerId: Long, at: Long) = - vault.saveReplyReminder(Vault.threadForUser(peerId), at) + fun setReplyReminder( + peerId: Long, + at: Long, + ) = vault.saveReplyReminder(Vault.threadForUser(peerId), at) fun replyReminder(peerId: Long): Long = vault.replyReminder(Vault.threadForUser(peerId)) @@ -324,55 +269,61 @@ class Session( } /** Reminders that have come due at [now]. */ - fun dueReplyReminders(now: Long = System.currentTimeMillis()): Map = - vault.dueReplyReminders(now) + fun dueReplyReminders(now: Long = System.currentTimeMillis()): Map = vault.dueReplyReminders(now) /** Delete one message from this device (delete for me). */ - fun deleteMessage(threadId: String, at: Long, mine: Boolean, text: String) = - vault.deleteLine(threadId, at, mine, text) + fun deleteMessage( + threadId: String, + at: Long, + mine: Boolean, + text: String, + ) = vault.deleteLine(threadId, at, mine, text) /** Send an image. Returns what happened, same as a text send. */ - fun sendImage(peerUserId: Long, bytes: ByteArray, mime: String, width: Int, height: Int): - io.flamenet.client.Conversations.Sent { - val sent = conversations.sendAttachment( - peerUserId, bytes, mime, Attachment.IMAGE, width = width, height = height) + fun sendImage( + peerUserId: Long, + bytes: ByteArray, + mime: String, + width: Int, + height: Int, + ): io.flamenet.client.Conversations.Sent { + val sent = + conversations.sendAttachment(peerUserId, bytes, mime, Attachment.IMAGE, width = width, height = height) // From the reference we just built, so the sender's own thread shows - // the image without fetching it back from the relay. Recorded whatever - // happened, carrying the outcome, see [Line.delivered]. - record(Vault.threadForUser(peerUserId), "", mine = true, - attachment = sent.attachment, delivery = if (sent.reachedSomeone) Delivery.DELIVERED else Delivery.PENDING) + // the image without fetching it back from the relay. + record( + Vault.threadForUser(peerUserId), + "", + mine = true, + attachment = sent.attachment, + delivery = if (sent.reachedSomeone) Delivery.DELIVERED else Delivery.PENDING, + ) return sent } /** * Send a voice note. - * - * The duration is measured while recording rather than read back from the - * container: the sender knows how long they held the button, and parsing - * metadata to rediscover it is a second source of truth that can disagree. */ - fun sendVoice(peerUserId: Long, bytes: ByteArray, mime: String, seconds: Double): - io.flamenet.client.Conversations.Sent { - val sent = conversations.sendAttachment( - peerUserId, bytes, mime, Attachment.AUDIO, duration = seconds) - record(Vault.threadForUser(peerUserId), "", mine = true, - attachment = sent.attachment, delivery = if (sent.reachedSomeone) Delivery.DELIVERED else Delivery.PENDING) + fun sendVoice( + peerUserId: Long, + bytes: ByteArray, + mime: String, + seconds: Double, + ): io.flamenet.client.Conversations.Sent { + val sent = + conversations.sendAttachment(peerUserId, bytes, mime, Attachment.AUDIO, duration = seconds) + record( + Vault.threadForUser(peerUserId), + "", + mine = true, + attachment = sent.attachment, + delivery = if (sent.reachedSomeone) Delivery.DELIVERED else Delivery.PENDING, + ) return sent } /** * Send a document from the Files vault. - * - * The label travels; it does not gate. The content key is in the same - * envelope as the message, so the recipient can open this whatever their - * clearance, the classification goes so their phone files it the same way, - * and every sentence on the way to this call has to say that rather than - * implying a control. See `Attachment.FILE`. - * - * `application/octet-stream` and not a guess from the extension. A - * document announced as `image/png` renders as a broken image rather than a - * file somebody can save, and the vault stores no MIME type to be right - * with. */ fun sendFile( peerUserId: Long, @@ -382,25 +333,30 @@ class Session( compartments: List, ): io.flamenet.client.Conversations.Sent { // android#17, a photo sent as a FILE used to keep its GPS while the - // same photo through the picker did not. Sniffed by magic bytes, - // because the vault stores no MIME type; non-images go byte-exact. + // same photo through the picker did not. val clean = MetadataScrub.scrub(bytes).bytes - val sent = conversations.sendAttachment( - peerUserId, clean, "application/octet-stream", Attachment.FILE, - filename = name, classification = classification, compartments = compartments) - record(Vault.threadForUser(peerUserId), "", mine = true, - attachment = sent.attachment, - delivery = if (sent.reachedSomeone) Delivery.DELIVERED else Delivery.PENDING) + val sent = + conversations.sendAttachment( + peerUserId, + clean, + "application/octet-stream", + Attachment.FILE, + filename = name, + classification = classification, + compartments = compartments, + ) + record( + Vault.threadForUser(peerUserId), + "", + mine = true, + attachment = sent.attachment, + delivery = if (sent.reachedSomeone) Delivery.DELIVERED else Delivery.PENDING, + ) return sent } /** * Send a document to a group. - * - * Sealed and uploaded once, then fanned out, one blob, one key, one - * upload, referenced by every member's envelope. And the count is worth - * saying out loud before tapping: a Restricted file into a group of eleven - * hands the key to eleven people. */ fun sendGroupFile( group: RelayClient.Group, @@ -409,51 +365,36 @@ class Session( classification: Int, compartments: List, ): Conversations.Sent { - // The group variant needs it too. The one-to-one path was fixed - // first and this sat two functions below it, a leak fixed on one of - // two identical doors is a leak. + // The group variant needs it too. val clean = MetadataScrub.scrub(bytes).bytes - val sent = conversations.sendGroupAttachment( - group, userId, clean, "application/octet-stream", width = 0, height = 0, - kind = Attachment.FILE, filename = name, - classification = classification, compartments = compartments) - record(Vault.threadForGroup(group.id), "", mine = true, - attachment = sent.attachment, author = userId, - delivery = if (sent.reachedSomeone) Delivery.DELIVERED else Delivery.PENDING) + val sent = + conversations.sendGroupAttachment( + group, + userId, + clean, + "application/octet-stream", + width = 0, + height = 0, + kind = Attachment.FILE, + filename = name, + classification = classification, + compartments = compartments, + ) + record( + Vault.threadForGroup(group.id), + "", + mine = true, + attachment = sent.attachment, + author = userId, + delivery = if (sent.reachedSomeone) Delivery.DELIVERED else Delivery.PENDING, + ) return sent } /** Bytes for an attachment, cached on disk after the first fetch. */ + /** * Delete cached attachment bytes that nothing refers to any more. - * - * `attachmentBytes` writes DECRYPTED bytes to the cache and nothing - * ever removed them. So a disappearing message expired, its bubble went, - * and the plaintext photo or voice note stayed on disk indefinitely, which - * is the exact failure the feature exists to prevent. Same for a story once - * its expiry passed. Spec §6.11 says it plainly: attachments MUST be purged - * with the message that referenced them. - * - * A sweep, not a purge at the point of deletion, and that is a - * deliberate choice. Four different paths drop a row, expiry, story - * expiry, delete-for-me and clear-thread, and threading "here is what I - * removed" out of each one would still miss anything a future path drops, - * and would never reach the orphans already on disk. Asking instead "what - * is still referenced?" is complete by construction and self-healing. - * - * The cost is that a file survives until the next sweep rather than going - * at the instant of expiry. Sweeps run on every poll, so that window is a - * poll interval, worth naming rather than implying it is instant. - * - * It fails closed. If the in-use set cannot be built, an - * unreadable record, anything at all, nothing is deleted. A partial answer - * to "what is still in use" is the one input that turns this from a cleanup - * into data loss. - * - * [graceMillis] protects a file that was only just written, so a sweep - * racing a fetch cannot delete bytes on their way to being shown. - * - * Returns how many files were removed, or 0 when it declined to run. */ private var lastAttachmentSweepAt = 0L @@ -463,24 +404,23 @@ class Session( graceMillis: Long = 60_000L, minIntervalMillis: Long = 10 * 60_000L, ): Int { - // Throttled here rather than at the call site, so it is safe to - // call from the poll loop unconditionally. Building the in-use set - // reads every record in the vault; doing that every few seconds on a - // phone with a lot of history would be a real cost for a cleanup that - // does not need to be prompt. + // Throttled here rather than at the call site, so it is safe to call + // from the poll loop unconditionally. if (now - lastAttachmentSweepAt < minIntervalMillis) return 0 lastAttachmentSweepAt = now - val inUse = try { - // The SAME transformation `attachmentBytes` applies when it names - // the file, comparing raw ids against mangled filenames would find - // no match and delete everything. - vault.attachmentIdsInUse().map { it.replace('/', '_') }.toSet() - } catch (e: Exception) { - return 0 - } - val files = cacheDir.listFiles { f: java.io.File -> f.name.startsWith("att-") } - ?: return 0 + val inUse = + try { + // The SAME transformation `attachmentBytes` applies when it names + // the file, comparing raw ids against mangled filenames would find + // no match and delete everything. + vault.attachmentIdsInUse().map { it.replace('/', '_') }.toSet() + } catch (e: Exception) { + return 0 + } + val files = + cacheDir.listFiles { f: java.io.File -> f.name.startsWith("att-") } + ?: return 0 var removed = 0 for (f in files) { if (now - f.lastModified() < graceMillis) continue @@ -490,7 +430,10 @@ class Session( return removed } - fun attachmentBytes(ref: Attachment.Ref, cacheDir: java.io.File): ByteArray { + fun attachmentBytes( + ref: Attachment.Ref, + cacheDir: java.io.File, + ): ByteArray { val cached = java.io.File(cacheDir, "att-" + ref.id.replace('/', '_')) if (cached.exists()) return cached.readBytes() val bytes = conversations.fetchAttachment(ref) @@ -500,89 +443,70 @@ class Session( /** * Send, and record it either way, with what happened. - * - * This used to record only on success, and that is how a message - * disappeared. Sent to somebody signed out, nothing reached a device, so - * nothing was written and nothing was shown: the sender watched their own - * text vanish out of the box with no bubble and no error, and sent it - * again to see whether it would stick. The alternative the group path took - * record regardless, is the other half of the same bug, a bubble that - * was received by nobody and looks exactly like one that was. - * - * Recording with the outcome is what makes neither possible. See - * [Line.delivered]. */ fun send( peerUserId: Long, text: String, /** * A preview the CALLER already fetched (§6.12). - * - * Passed in rather than fetched here: the fetch needs the user's - * setting, which needs an Android context, and this layer deliberately - * has none. It also keeps a network round trip out of the send path - * for every message that has no link in it. */ link: Content.LinkPreview? = null, /** A timer for this one message, in seconds (§6.17); 0 for none. */ lifetimeSeconds: Int = 0, /** * The message this one answers (android#16). - * - * The preview inside it is the sender's own copy of the quoted - * words, and it has to travel. A recipient who joined the thread - * later, or cleared their history, has never seen the message being - * answered, a reply rendering as "> [missing]" is worse than one that - * shows what was said. */ replyTo: Content.Reference? = null, ): Conversations.Sent { // Every message gets an id now, so this is always an envelope. - // It used to be the raw body byte for byte, which is cheaper on the - // wire and cost us reactions, edits and pins: all three say "this - // one", and there was nothing to point at. iOS has always wrapped, and - // both engines read a bare body as text, so an older peer is - // unaffected, see `isUnknownControl`. - val mid = java.util.UUID.randomUUID().toString() - val plaintext = Content.text(text, messageId = mid, about = replyTo, link = link, - lifetimeSeconds = lifetimeSeconds) + val mid = + java.util.UUID + .randomUUID() + .toString() + val plaintext = + Content.text( + text, + messageId = mid, + about = replyTo, + link = link, + lifetimeSeconds = lifetimeSeconds, + ) val sent = conversations.send(peerUserId, plaintext) val at = System.currentTimeMillis() - // The sender's own copy expires too. A timer that only cleared the - // recipient's phone would be the opposite of the promise: the person - // who set it would be the one still holding the message. - record(Vault.threadForUser(peerUserId), text, mine = true, at = at, - link = link, messageId = mid, about = replyTo, - lifetimeSeconds = lifetimeSeconds, - delivery = if (sent.reachedSomeone) Delivery.DELIVERED else Delivery.PENDING) - // ios#16, every send's outcome, not just this one's. `Sent.sealed` - // answers "was that sealed"; the record answers "how often does this - // happen to me", which nobody could ask before. + // The sender's own copy expires too. + record( + Vault.threadForUser(peerUserId), + text, + mine = true, + at = at, + link = link, + messageId = mid, + about = replyTo, + lifetimeSeconds = lifetimeSeconds, + delivery = if (sent.reachedSomeone) Delivery.DELIVERED else Delivery.PENDING, + ) + // ios#16, every send's outcome, not just this one's. onSendSealed(sent.sealed, at) - // Queued, not abandoned. A friend who is simply signed out should - // not cost the sender a reminder to try again later. - // - // The ENVELOPE is queued, not the bare text: a retry has to arrive - // carrying the same id, or the copy that eventually lands is a - // different message from the one already in this transcript and - // nothing said about it reaches anywhere. + // Queued, not abandoned. if (!sent.reachedSomeone) enqueue(peerUserId, plaintext, at) return sent } /** * Send to somebody on another relay, and record it in their thread. - * - * Not queued on failure, unlike [send]. The local outbox retries by - * user id against this relay; a remote address has neither. A federated - * send that fails is reported so the person can try again rather than - * silently held in a queue that will never drain. */ - fun sendToAddress(card: ContactCard, text: String): Conversations.Sent { + fun sendToAddress( + card: ContactCard, + text: String, + ): Conversations.Sent { val sent = conversations.sendToAddress(card, Content.text(text)) - record(Vault.threadForAddress(card.address), text, mine = true, - at = System.currentTimeMillis(), - delivery = if (sent.reachedSomeone) Delivery.DELIVERED else Delivery.PENDING) + record( + Vault.threadForAddress(card.address), + text, + mine = true, + at = System.currentTimeMillis(), + delivery = if (sent.reachedSomeone) Delivery.DELIVERED else Delivery.PENDING, + ) return sent } @@ -593,76 +517,90 @@ class Session( data class CallEntry( val peerId: Long, val incoming: Boolean, - /** Whether it was ever actually connected, a missed call is the - * interesting row on this screen, so it has to be distinguishable. */ + /** + * Whether it was ever actually connected, a missed call is the. + */ val connected: Boolean, val at: Long, ) fun callLog(): List { val stored = vault.callLog() - return (0 until stored.length()).mapNotNull { i -> - stored.optJSONObject(i)?.let { - CallEntry( - peerId = it.optLong("peer"), - incoming = it.optBoolean("in"), - connected = it.optBoolean("ok"), - at = it.optLong("at"), - ) - } - }.sortedByDescending { it.at } + return (0 until stored.length()) + .mapNotNull { i -> + stored.optJSONObject(i)?.let { + CallEntry( + peerId = it.optLong("peer"), + incoming = it.optBoolean("in"), + connected = it.optBoolean("ok"), + at = it.optLong("at"), + ) + } + }.sortedByDescending { it.at } } /** * File a finished call. - * - * Called once, when the call reaches its end, not on every state - * change. WebRTC passes through DIALLING, RINGING, CONNECTING and ACTIVE - * on the way, and recording each of them would make one call into five - * rows. */ - fun recordCall(peerId: Long, incoming: Boolean, connected: Boolean, - at: Long = System.currentTimeMillis()) { + fun recordCall( + peerId: Long, + incoming: Boolean, + connected: Boolean, + at: Long = System.currentTimeMillis(), + ) { val log = vault.callLog() - log.put(JSONObject() - .put("peer", peerId).put("in", incoming) - .put("ok", connected).put("at", at)) + log.put( + JSONObject() + .put("peer", peerId) + .put("in", incoming) + .put("ok", connected) + .put("at", at), + ) vault.saveCallLog(log) } /** * Send a sticker. - * - * Recorded with its outcome like every other send, a sticker nobody - * received must not look like one that was. It does NOT go into the outbox: - * see the comment on the record below. */ - fun sendSticker(peerUserId: Long, pack: String, stickerId: String): - Conversations.Sent { + fun sendSticker( + peerUserId: Long, + pack: String, + stickerId: String, + ): Conversations.Sent { val sent = conversations.sendSticker(peerUserId, pack, stickerId) val at = System.currentTimeMillis() - record(Vault.threadForUser(peerUserId), "", mine = true, at = at, - sticker = Sticker(pack, stickerId), - delivery = if (sent.reachedSomeone) Delivery.DELIVERED else Delivery.PENDING) + record( + Vault.threadForUser(peerUserId), + "", + mine = true, + at = at, + sticker = Sticker(pack, stickerId), + delivery = if (sent.reachedSomeone) Delivery.DELIVERED else Delivery.PENDING, + ) // Queued, like text, because the bubble already promised it. - // A sticker to somebody signed out was marked "Undelivered, will - // send" and then never entered the outbox, so the label was a lie for - // exactly one content kind. The queue stores the ratchet plaintext, - // and a sticker's wire IS its plaintext, so the same replay path - // delivers both. if (!sent.reachedSomeone) { - enqueue(peerUserId, io.flamenet.e2e.Content.sticker(pack, stickerId), at) + enqueue( + peerUserId, + io.flamenet.e2e.Content + .sticker(pack, stickerId), + at, + ) } return sent } /** A sticker to a group. */ - fun sendGroupSticker(group: RelayClient.Group, pack: String, stickerId: String): - Conversations.Sent { + fun sendGroupSticker( + group: RelayClient.Group, + pack: String, + stickerId: String, + ): Conversations.Sent { var delivered = 0 var failed = 0 val at = System.currentTimeMillis() - val wire = io.flamenet.e2e.Content.sticker(pack, stickerId, groupId = group.id) + val wire = + io.flamenet.e2e.Content + .sticker(pack, stickerId, groupId = group.id) for (member in group.memberIds) { if (member == userId) continue val one = conversations.sendSticker(member, pack, stickerId, groupId = group.id) @@ -674,9 +612,15 @@ class Session( enqueue(member, wire, at, thread = Vault.threadForGroup(group.id)) } } - record(Vault.threadForGroup(group.id), "", mine = true, author = userId, at = at, - sticker = Sticker(pack, stickerId), - delivery = if (delivered > 0) Delivery.DELIVERED else Delivery.PENDING) + record( + Vault.threadForGroup(group.id), + "", + mine = true, + author = userId, + at = at, + sticker = Sticker(pack, stickerId), + delivery = if (delivered > 0) Delivery.DELIVERED else Delivery.PENDING, + ) return Conversations.Sent( delivered = List(delivered) { "" }, failed = if (failed > 0) mapOf("group" to "some members unreachable") else emptyMap(), @@ -685,11 +629,6 @@ class Session( /** * Who may see this account's stories. - * - * `EVERYONE` means every contact, which is already the narrowest a story - * can be without a list, because a story only ever goes to people who are - * contacts. There is no public audience and cannot be one: a story is - * sealed per recipient. */ enum class AudienceMode { EVERYONE, ONLY, EXCEPT } @@ -699,76 +638,57 @@ class Session( ) { /** * Resolve against the current contact list. - * - * Intersected with `contacts` in every branch, including ONLY. An - * id left in a saved list after that person was removed as a contact - * must not resurrect them as a recipient. */ - fun recipients(contacts: List): List = when (mode) { - AudienceMode.EVERYONE -> contacts - AudienceMode.ONLY -> contacts.filter { it in userIds } - AudienceMode.EXCEPT -> contacts.filter { it !in userIds } - } + fun recipients(contacts: List): List = + when (mode) { + AudienceMode.EVERYONE -> contacts + AudienceMode.ONLY -> contacts.filter { it in userIds } + AudienceMode.EXCEPT -> contacts.filter { it !in userIds } + } } fun storyAudience(): StoryAudience { val json = vault.storyAudience() ?: return StoryAudience() - val mode = runCatching { - AudienceMode.valueOf(json.optString("mode")) - }.getOrDefault(AudienceMode.EVERYONE) + val mode = + runCatching { + AudienceMode.valueOf(json.optString("mode")) + }.getOrDefault(AudienceMode.EVERYONE) val ids = json.optJSONArray("ids") - val set = buildSet { - for (i in 0 until (ids?.length() ?: 0)) add(ids!!.optLong(i)) - } + val set = + buildSet { + for (i in 0 until (ids?.length() ?: 0)) add(ids!!.optLong(i)) + } return StoryAudience(mode, set) } fun saveStoryAudience(audience: StoryAudience) { val ids = org.json.JSONArray() audience.userIds.forEach { ids.put(it) } - vault.saveStoryAudience(JSONObject() - .put("mode", audience.mode.name) - .put("ids", ids)) + vault.saveStoryAudience( + JSONObject() + .put("mode", audience.mode.name) + .put("ids", ids), + ) } // MARK: - Outbox /** * Hold a message that reached nobody, and send it when they come back. - * - * Why this is on the phone and not the relay. Every envelope is - * sealed to a particular device's keys. Somebody with no registered device - * has no keys to seal to, so there is nothing the relay could be handed to - * hold, it could only queue plaintext, which is the thing this app exists - * not to do. The consequence is that the sender's phone is the only place - * the message can wait, and therefore that it goes out when THIS device - * next runs, not the instant the recipient signs in. - * - * Which is worth being honest about in the UI: "Sending…" here means - * "waiting on your phone", not "in flight". */ /** - * `text` is the ratchet PLAINTEXT, not necessarily prose. For a text - * message the two are the same string; for a sticker it is the encoded - * content envelope. `flushOutbox` replays it through `conversations.send`, - * which transmits its argument as the plaintext verbatim, so both kinds - * take the same path out. + * `text` is the ratchet PLAINTEXT, not necessarily prose. */ + /** * Fold a reaction onto the message it is about. - * - * One emoji per person. Reacting again replaces; reacting with the - * same one, or with an empty string, takes it back. So the author is - * removed from every emoji first and then added to at most one, an - * accumulate-only version would let one tap each on five emoji stand - * simultaneously, which is not what anybody means by reacting. - * - * Returns false when the message is not in this thread, which is the - * ordinary case for a reaction to something sent before ids existed, or - * to a message this device never received. Silently doing nothing is - * right; inventing a bubble for it would not be. */ - fun applyReaction(threadId: String, messageId: String, author: Long, emoji: String): Boolean { + fun applyReaction( + threadId: String, + messageId: String, + author: Long, + emoji: String, + ): Boolean { val stored = vault.history(threadId) var hit = false for (i in 0 until stored.length()) { @@ -799,20 +719,6 @@ class Session( /** * Apply an inbound edit to the message it names (§6.15). - * - * An edit is never a bubble of its own. It changes one that is - * already there, or it does nothing. Android had the codec and no - * application, so an edit from the iPhone app arrived carrying the new - * wording in `text` and was filed as an ORDINARY MESSAGE, a correction - * showing up as a duplicate beside the typo it was meant to replace, which - * is worse than the typo. - * - * Only the author may edit their own message. Without this check any - * contact sharing a session could rewrite anybody's words in the reader's - * transcript, which is a far better attack than sending a message of their - * own: the words would carry somebody else's name. - * - * Returns whether a message was found and changed. */ fun applyEdit( threadId: String, @@ -826,10 +732,13 @@ class Session( for (i in 0 until stored.length()) { val row = stored.getJSONObject(i) if (row.optString("mid") != messageId) continue - // The author check. `mine` is this device's own message; an - // inbound edit must come from the person whose message it is. - val wroteIt = if (row.optBoolean("mine")) author == userId - else row.optLong("author") == author + // The author check. + val wroteIt = + if (row.optBoolean("mine")) { + author == userId + } else { + row.optLong("author") == author + } if (!wroteIt) break row.put("text", newText).put("edited", at) hit = true @@ -841,40 +750,38 @@ class Session( /** * The message pinned in a thread, or null (§6.19). - * - * An id, never a copy. A stored copy would go stale the moment the - * message was edited, and would survive the message being deleted, a - * banner quoting words that are no longer in the conversation. */ - fun pinnedMessage(threadId: String): String? = - vault.pinnedMessage(threadId) + fun pinnedMessage(threadId: String): String? = vault.pinnedMessage(threadId) /** * Pin a message, or take the pin down with an empty [messageId]. - * - * The last pin wins and there is only ever one. A thread with a list - * of pins is a second inbox; §6.19 is one banner, and two people pinning at - * once resolve by whoever's envelope arrived last, the same rule the - * transcript already uses for everything else. */ - fun applyPin(threadId: String, messageId: String) { + fun applyPin( + threadId: String, + messageId: String, + ) { vault.savePinnedMessage(threadId, messageId.takeIf { it.isNotBlank() }) } /** * React to somebody's message, or take it back with an empty [emoji]. - * - * Applied here first, then sent. A reaction is a one-tap gesture, - * and waiting on a round trip to see your own tap register makes it feel - * broken on a slow connection. A send that fails restores what was there - * before rather than leaving a reaction that exists only on this phone, - * and restoring "nothing" would be wrong for somebody who was replacing - * one emoji or removing one, so the previous value is captured first. */ - fun react(emoji: String, threadId: String, messageId: String, author: Long, - peerUserId: Long?, group: RelayClient.Group?): Boolean { - val previous = history(threadId).firstOrNull { line -> line.id == messageId } - ?.reactions?.entries?.firstOrNull { entry -> userId in entry.value }?.key.orEmpty() + fun react( + emoji: String, + threadId: String, + messageId: String, + author: Long, + peerUserId: Long?, + group: RelayClient.Group?, + ): Boolean { + val previous = + history(threadId) + .firstOrNull { line -> line.id == messageId } + ?.reactions + ?.entries + ?.firstOrNull { entry -> userId in entry.value } + ?.key + .orEmpty() if (!applyReaction(threadId, messageId, userId, emoji)) return false val reference = Content.Reference(messageId, author, "") val body = Content.reaction(emoji, reference, groupId = group?.id) @@ -888,40 +795,33 @@ class Session( /** * Fan one control envelope out to a thread, exactly as `react` does. - * - * Pairwise for a group, because the relay does no fan-out, one send per - * member. Factored out of `react` rather than copied into three new - * callers: three copies of "which sends does this thread need" is three - * places for a group to be handled as a 1:1. */ private fun sendToThread( body: String, peerUserId: Long?, group: RelayClient.Group?, - ): Boolean = runCatching { - when { - group != null -> group.memberIds - .filter { it != userId } - .map { conversations.send(it, body).reachedSomeone } - .any { it } - peerUserId != null -> conversations.send(peerUserId, body).reachedSomeone - else -> false - } - }.getOrDefault(false) + ): Boolean = + runCatching { + when { + group != null -> { + group.memberIds + .filter { it != userId } + .map { conversations.send(it, body).reachedSomeone } + .any { it } + } + + peerUserId != null -> { + conversations.send(peerUserId, body).reachedSomeone + } + + else -> { + false + } + } + }.getOrDefault(false) /** * Change the wording of a message already sent (§6.15). - * - * Only your own, and the check is here rather than only in the UI. A - * menu that hides the control is a convenience; this is the rule. The - * receiving side enforces the same thing (`applyEdit` refuses an edit from - * anybody but the author), so neither end is trusting the other. - * - * Applied locally first, then sent, and rolled back if the send - * fails, the same shape as `react`. Waiting on a round trip to see your - * own correction appear makes it feel broken on a slow connection, and - * leaving a correction that exists only on this phone is worse: you would - * believe you had fixed it. */ fun editMessage( threadId: String, @@ -937,10 +837,7 @@ class Session( if (!applyEdit(threadId, messageId, userId, newText)) return false val body = Content.edit(newText, of = messageId, groupId = group?.id) if (!sendToThread(body, peerUserId, group)) { - // Put the old wording back. `applyEdit` stamps `edited`, so this - // leaves the flag behind, deliberately: the message DID change on - // this device and briefly showed the new words, and pretending - // otherwise would be a second lie on top of the failed send. + // Put the old wording back. applyEdit(threadId, messageId, userId, previous) return false } @@ -949,10 +846,6 @@ class Session( /** * Pin a message in a thread, or take the pin down with an empty id (§6.19). - * - * Anyone in the conversation may pin. A pin is a shared banner, not - * a permission, restricting it to the author would mean the useful message - * in a group is exactly the one nobody but its writer can pin. */ fun pinMessage( threadId: String, @@ -972,18 +865,6 @@ class Session( /** * Pass somebody's words on to another conversation (§6.16). - * - * The forward carries the words and NOT where they came from. No - * author, no original thread, no quote, that is the point of §6.16, - * and it is why this sends a plain text envelope with `fwd` set rather than - * a reply that references the original. A forward that named its source - * would republish a private message's author to somebody who was never in - * that conversation. - * - * An attachment is deliberately not carried yet. Re-sending a blob means - * re-uploading it under a new token, and a forward that silently shared the - * ORIGINAL token would hand the new recipient a capability to a file in a - * conversation they are not part of. */ fun forwardMessage( text: String, @@ -991,73 +872,96 @@ class Session( toGroup: RelayClient.Group?, ): Boolean { if (text.isBlank()) return false - val mid = java.util.UUID.randomUUID().toString() + val mid = + java.util.UUID + .randomUUID() + .toString() // Two builders, because a group message carries its gid inside the - // envelope and a 1:1 one has no field for it. `Content.text` takes no - // groupId at all, passing one is a compile error rather than a silent - // drop, which is how this was caught. - val body = if (toGroup != null) { - Content.groupText(text, groupId = toGroup.id, messageId = mid, - forwarded = true) - } else { - Content.text(text, messageId = mid, forwarded = true) - } + // envelope and a 1:1 one has no field for it. + val body = + if (toGroup != null) { + Content.groupText( + text, + groupId = toGroup.id, + messageId = mid, + forwarded = true, + ) + } else { + Content.text(text, messageId = mid, forwarded = true) + } if (!sendToThread(body, toPeerUserId, toGroup)) return false - val thread = toGroup?.let { Vault.threadForGroup(it.id) } - ?: toPeerUserId?.let { Vault.threadForUser(it) } - ?: return true - record(thread, text, mine = true, messageId = mid, forwarded = true, - delivery = Delivery.DELIVERED) + val thread = + toGroup?.let { Vault.threadForGroup(it.id) } + ?: toPeerUserId?.let { Vault.threadForUser(it) } + ?: return true + record( + thread, + text, + mine = true, + messageId = mid, + forwarded = true, + delivery = Delivery.DELIVERED, + ) return true } - private fun enqueue(peerUserId: Long, text: String, at: Long, - thread: String? = null) { + private fun enqueue( + peerUserId: Long, + text: String, + at: Long, + thread: String? = null, + ) { val pending = vault.outbox() - pending.put(JSONObject() - .put("to", peerUserId).put("text", text).put("at", at) - // Present only for group entries: the bubble to restate lives in - // the GROUP thread, not in a 1:1 thread with this member, which - // may not even exist. - .also { if (thread != null) it.put("thread", thread) }) + pending.put( + JSONObject() + .put("to", peerUserId) + .put("text", text) + .put("at", at) + // Present only for group entries: the bubble to restate lives in + // the GROUP thread, not in a 1:1 thread with this member, which may + // not even exist. + .also { if (thread != null) it.put("thread", thread) }, + ) vault.saveOutbox(pending) } /** - * Try everything waiting. Returns the threads that changed, so the caller - * can redraw them. - * - * Never gives up. There is no expiry and nothing is ever discarded: - * a message stays marked undelivered until the recipient registers a device - * and it goes. Timing out would mean deleting something somebody wrote in - * order to tidy up a label, and the label is not the point, the message - * is. A contact who reinstalls in a month gets it in a month. + * Try everything waiting. */ + /** - * Whether waiting will ever resolve this, mirroring iOS `E2EError.waitingCanFix`. - * - * Matched on the refusal types the trust layer throws, not on message - * text. A string match here would break the first time somebody reworded - * an error, and would break silently, the queue would simply go back to - * retrying for ever. + * Whether waiting will ever resolve this, mirroring iOS + * `E2EError.waitingCanFix`. */ - private fun permanentRefusal(e: Throwable?): Boolean = when (e) { - is io.flamenet.client.IdentityChangedException, - is io.flamenet.client.KeyNotLoggedException, - is io.flamenet.client.KeyLogForkException -> true - else -> false - } + private fun permanentRefusal(e: Throwable?): Boolean = + when (e) { + is io.flamenet.client.IdentityChangedException, + is io.flamenet.client.KeyNotLoggedException, + is io.flamenet.client.KeyLogForkException, + -> true + + else -> false + } /** A short, contentless reason for the reading and the bubble. */ - private fun refusalReason(e: Throwable?): String = when (e) { - is io.flamenet.client.IdentityChangedException -> - "their security keys changed" - is io.flamenet.client.KeyNotLoggedException -> - "their device offered a key the server has no record of" - is io.flamenet.client.KeyLogForkException -> - "this server's record of published keys does not add up" - else -> "the message could not be sent" - } + private fun refusalReason(e: Throwable?): String = + when (e) { + is io.flamenet.client.IdentityChangedException -> { + "their security keys changed" + } + + is io.flamenet.client.KeyNotLoggedException -> { + "their device offered a key the server has no record of" + } + + is io.flamenet.client.KeyLogForkException -> { + "this server's record of published keys does not add up" + } + + else -> { + "the message could not be sent" + } + } fun flushOutbox(): Set { val pending = vault.outbox() @@ -1065,8 +969,7 @@ class Session( val touched = mutableSetOf() val stillWaiting = org.json.JSONArray() - // Group entries that delivered this pass, keyed "thread|at". The bubble - // only flips when the LAST member of its set is reached, see below. + // Group entries that delivered this pass, keyed "thread|at". val groupDelivered = mutableSetOf>() for (i in 0 until pending.length()) { @@ -1076,19 +979,14 @@ class Session( val at = item.optLong("at") val groupThread = item.optString("thread").takeIf { it.isNotBlank() } - // A scheduled message is not due yet. Held rather than sent, and - // held rather than dropped, this loop is also the retry queue, so - // treating "not yet" as "failed" would lose the message entirely. + // A scheduled message is not due yet. val notBefore = item.optLong("not_before", 0L) if (notBefore > System.currentTimeMillis()) { stillWaiting.put(item) continue } - // The failure is CAUGHT, not discarded. `getOrNull()` threw the - // exception away, so this loop could not tell a contact who is - // merely offline from one whose keys changed, and retried both - // for ever. + // The failure is CAUGHT, not discarded. val attempt = runCatching { conversations.send(to, text) } val sent = attempt.getOrNull() if (sent?.reachedSomeone == true) { @@ -1099,17 +997,10 @@ class Session( groupDelivered += groupThread to at } } else if (permanentRefusal(attempt.exceptionOrNull())) { - // Some refusals waiting cannot fix (ios#22). A send - // blocked because the contact's identity keys changed is - // refused identically on every retry until a person accepts - // them, while the bubble goes on promising "will send". - // - // Everything else still retries for ever, deliberately: a - // contact who is offline, or who has not registered a device - // yet, gets the message whenever they return. Giving up there - // would delete something somebody wrote to tidy up a label. + // Some refusals waiting cannot fix (ios#22). val why = refusalReason(attempt.exceptionOrNull()) - io.flamenet.client.DeliveryDiagnostics.sendFailed(why, to) + io.flamenet.client.DeliveryDiagnostics + .sendFailed(why, to) val thread = groupThread ?: Vault.threadForUser(to) restate(thread, at, Delivery.FAILED) touched += thread @@ -1120,17 +1011,13 @@ class Session( vault.saveOutbox(stillWaiting) // A group bubble flips only when nobody is left waiting on it. - // One message fans out to many members and each gets its own entry; - // restating on the first success would mark it delivered while other - // members are still owed it, and "will send" would quietly stop being - // true for them. Until the set drains, PENDING remains the honest - // label, the queue will keep sending. for ((thread, at) in groupDelivered) { var remaining = false for (i in 0 until stillWaiting.length()) { val item = stillWaiting.optJSONObject(i) ?: continue if (item.optString("thread") == thread && item.optLong("at") == at) { - remaining = true; break + remaining = true + break } } if (!remaining) { @@ -1143,13 +1030,12 @@ class Session( /** * Move one already-recorded message to a new state. - * - * Matched on the timestamp it was written with, which is why [send] - * stamps `at` once and uses the same value for the thread and the outbox. - * Matching on the text would restate the wrong bubble the first time - * somebody sends the same word twice. */ - private fun restate(threadId: String, at: Long, state: Delivery) { + private fun restate( + threadId: String, + at: Long, + state: Delivery, + ) { val stored = vault.history(threadId) for (i in 0 until stored.length()) { val m = stored.optJSONObject(i) ?: continue @@ -1163,72 +1049,74 @@ class Session( /** * Send to a group, and file our own copy. - * - * Recorded even when nobody was reachable, a group of one, or a group - * whose other members have no devices yet, is a real situation and losing - * what you typed in it is not acceptable. The delivery counts still tell - * the caller what happened. */ + /** * Send one call signal to a peer. - * - * The result used to be discarded. `sendCallSignal` returns the same - * `Sent` every other send does, how many devices took it, and which - * failed, and this threw it away. So an offer that reached NOBODY (the - * peer signed out, no registered device, a session that would not open) - * was indistinguishable from one that rang: the caller sat watching - * "Calling…" for a signal that had gone nowhere, and there was nothing on - * either device to say so. Exactly the shape of the message-loss bug on the - * text path, on the one path where the UI never shows a delivery banner. - * - * Returns it now, and logs the zero case, because a call is placed from - * inside a WebRTC callback where there is nowhere to put a banner. */ - fun sendCallSignal(peerUserId: Long, signal: io.flamenet.e2e.Content.CallSignal): - Conversations.Sent { + fun sendCallSignal( + peerUserId: Long, + signal: io.flamenet.e2e.Content.CallSignal, + ): Conversations.Sent { val sent = conversations.sendCallSignal(peerUserId, signal) if (!sent.reachedSomeone) { - android.util.Log.w("fnmsg-call", + android.util.Log.w( + "fnmsg-call", "call signal ${signal.action} to $peerUserId reached NO devices: " + - sent.failed.entries.joinToString { "${it.key}=${it.value}" }) + sent.failed.entries.joinToString { "${it.key}=${it.value}" }, + ) } return sent } /** TURN credentials, or null when this relay has none configured. */ + /** * A null here silently downgrades every call to STUN-only. - * `CallManager.iceServers(null)` falls back to Google's STUN server, which - * cannot relay, so two peers behind NAT never connect, the call "just - * fails", and coturn records nothing because it was never contacted. The - * failure was swallowed by `getOrNull()`, so nothing anywhere said why. */ fun turnCredentials(): org.json.JSONObject? = runCatching { relay.turnCredentials() } .onFailure { - android.util.Log.w("fnmsg-call", "no TURN credentials " + - "(${it.javaClass.simpleName}: ${it.message}), this call will use " + - "STUN only and will not connect across NAT", it) - } - .getOrNull() + android.util.Log.w( + "fnmsg-call", + "no TURN credentials " + + "(${it.javaClass.simpleName}: ${it.message}), this call will use " + + "STUN only and will not connect across NAT", + it, + ) + }.getOrNull() - fun sendGroup(group: RelayClient.Group, text: String, - mentions: List = emptyList()): Conversations.Sent { - val mid = java.util.UUID.randomUUID().toString() + fun sendGroup( + group: RelayClient.Group, + text: String, + mentions: List = emptyList(), + ): Conversations.Sent { + val mid = + java.util.UUID + .randomUUID() + .toString() val sent = conversations.sendToGroup(group, text, userId, mentions, messageId = mid) val at = System.currentTimeMillis() - record(Vault.threadForGroup(group.id), text, mine = true, author = userId, at = at, - messageId = mid, - delivery = if (sent.reachedSomeone) Delivery.DELIVERED else Delivery.PENDING) - // The group half of the outbox promise. Members with no - // registered device are filed in the failure map as "user:" by the - // no-device guard; each gets an entry carrying the SAME group body and - // the group thread to restate. A member whose devices took it is not - // queued, resending to them would duplicate the message. - val body = io.flamenet.e2e.Content.groupText( - text, group.id, groupName = vault.groupName(group.id), mentions = mentions, - messageId = mid) - sent.failed.keys.filter { it.startsWith("user:") } + record( + Vault.threadForGroup(group.id), + text, + mine = true, + author = userId, + at = at, + messageId = mid, + delivery = if (sent.reachedSomeone) Delivery.DELIVERED else Delivery.PENDING, + ) + // The group half of the outbox promise. + val body = + io.flamenet.e2e.Content.groupText( + text, + group.id, + groupName = vault.groupName(group.id), + mentions = mentions, + messageId = mid, + ) + sent.failed.keys + .filter { it.startsWith("user:") } .mapNotNull { it.removePrefix("user:").toLongOrNull() } .forEach { member -> enqueue(member, body, at, thread = Vault.threadForGroup(group.id)) @@ -1245,44 +1133,34 @@ class Session( height: Int, ): Conversations.Sent { val sent = conversations.sendGroupAttachment(group, userId, bytes, mime, width, height) - record(Vault.threadForGroup(group.id), "", mine = true, - attachment = sent.attachment, author = userId, - delivery = if (sent.reachedSomeone) Delivery.DELIVERED else Delivery.PENDING) + record( + Vault.threadForGroup(group.id), + "", + mine = true, + attachment = sent.attachment, + author = userId, + delivery = if (sent.reachedSomeone) Delivery.DELIVERED else Delivery.PENDING, + ) return sent } /** - * Poll once, filing anything that arrives. Returns who now has new mail. - * - * Only renderable content is filed. Control messages, a contact's - * delivery key, a gossiped log head, a copy of what another of our own - * devices sent, travel down the same channel, and filing them would put - * raw JSON in the thread. + * Poll once, filing anything that arrives. */ + /** - * Called for each call signal that arrives. The third argument is when the - * relay accepted the envelope (epoch millis, 0 = unknown), an offer that - * aged in the mailbox must not ring for a caller who already gave up. - * - * Set by the view model, at sign-in, not lazily. It was installed - * only when a CallManager was built, and one was built only to PLACE a - * call, so a phone that had never dialled dropped every incoming offer - * here and nothing rang. `isRenderable` excludes call signals, correctly, - * so nothing else would notice that an incoming call had been thrown away. + * Called for each call signal that arrives. */ var onCallSignal: ((Long, io.flamenet.e2e.Content.CallSignal, Long) -> Unit)? = null fun receive(): Set { val everything = conversations.poll() // Control traffic consumed during the poll may have carried group-name - // claims. Checked against the roster here, where the roster is known. + // claims. drainGroupNames() drainGroupInvitations() everything.forEach { message -> - // Local calls only. Signalling is addressed by user id the - // whole way down, the answer, the ICE candidates, the hang-up, - // so a federated offer would ring and then have nowhere to reply - // to. Ignored rather than half-rung. + // Local calls only. if (message.fromAddress == null) { message.content.call?.let { signal -> onCallSignal?.invoke(message.fromUser, signal, message.at) @@ -1297,198 +1175,169 @@ class Session( val storyReactionsIncoming = mutableListOf>() for (message in arrived) { - // A story bypasses the thread entirely (§6.6). It belongs to - // the strip, and filing it as a message would put every story - // somebody posted into a one-to-one conversation they did not have. - // One that arrives already expired, posted while this device was - // offline, is dropped rather than shown late. - // A story that is only words. Checked before the attachment path, - // which has nothing to key on for one. + // A story bypasses the thread entirely (§6.6). val textExpires = message.content.textStoryExpires if (textExpires != null && message.fromAddress == null) { - val story = Vault.Story( - id = message.content.messageId ?: java.util.UUID.randomUUID().toString(), - authorId = message.fromUser, - mine = false, - ref = null, - caption = message.content.text, - postedAt = System.currentTimeMillis(), - expiresAt = textExpires, - ) + val story = + Vault.Story( + id = + message.content.messageId ?: java.util.UUID + .randomUUID() + .toString(), + authorId = message.fromUser, + mine = false, + ref = null, + caption = message.content.text, + postedAt = System.currentTimeMillis(), + expiresAt = textExpires, + ) if (story.isActive(now)) incomingStories += story continue } // An EDIT (§6.15) changes a message; it is never one. - // Android had the codec and no application, so an edit from the - // iPhone app fell straight through this loop carrying its new - // wording in `text` and was filed as an ordinary bubble, a - // correction arriving as a duplicate beside the typo it was meant - // to replace, which is worse than the typo. message.content.editOf?.let { editing -> - val gid = if (message.fromAddress != null) null - else message.content.groupId - val thread = gid?.let(Vault::threadForGroup) - ?: message.fromAddress?.let(Vault::threadForAddress) - ?: Vault.threadForUser(message.fromUser) - if (applyEdit(thread, editing, message.fromUser, - message.content.text.orEmpty())) { + val gid = + if (message.fromAddress != null) { + null + } else { + message.content.groupId + } + val thread = + gid?.let(Vault::threadForGroup) + ?: message.fromAddress?.let(Vault::threadForAddress) + ?: Vault.threadForUser(message.fromUser) + if (applyEdit( + thread, + editing, + message.fromUser, + message.content.text.orEmpty(), + ) + ) { touched += thread } continue } - // A PIN (§6.19) is an id, not a bubble. Same story: without - // this it was filed as a message whose text was empty, so a pin - // from the other app drew a blank bubble in the conversation. + // A PIN (§6.19) is an id, not a bubble. message.content.pins?.let { pinned -> - val gid = if (message.fromAddress != null) null - else message.content.groupId - val thread = gid?.let(Vault::threadForGroup) - ?: message.fromAddress?.let(Vault::threadForAddress) - ?: Vault.threadForUser(message.fromUser) + val gid = + if (message.fromAddress != null) { + null + } else { + message.content.groupId + } + val thread = + gid?.let(Vault::threadForGroup) + ?: message.fromAddress?.let(Vault::threadForAddress) + ?: Vault.threadForUser(message.fromUser) applyPin(thread, pinned) touched += thread continue } - // A reaction to a MESSAGE. Folded onto the message it is about, - // never filed as one of its own, a thread where every thumbs-up - // is a bubble is a thread nobody can read. - // - // Checked before the story branch, which is the narrower case: - // a story reaction carries the same `reaction` field and is told - // apart only by `reactsToStory`. + // A reaction to a MESSAGE. if (message.content.reaction != null && !message.content.reactsToStory) { val about = message.content.about if (about != null) { - val gid = if (message.fromAddress != null) null - else message.content.groupId - val thread = gid?.let(Vault::threadForGroup) - ?: message.fromAddress?.let(Vault::threadForAddress) - ?: Vault.threadForUser(message.fromUser) - if (applyReaction(thread, about.messageId, message.fromUser, - message.content.reaction.orEmpty())) { + val gid = + if (message.fromAddress != null) { + null + } else { + message.content.groupId + } + val thread = + gid?.let(Vault::threadForGroup) + ?: message.fromAddress?.let(Vault::threadForAddress) + ?: Vault.threadForUser(message.fromUser) + if (applyReaction( + thread, + about.messageId, + message.fromUser, + message.content.reaction.orEmpty(), + ) + ) { touched += thread } } continue } - // A reaction to one of OUR stories. Folded onto the story rather - // than filed as a message, and never notified, for the same - // reason a thumbs-up on a message is not. + // A reaction to one of OUR stories. if (message.content.reactsToStory) { val about = message.content.about if (about != null) { - storyReactionsIncoming += Triple( - about.messageId, message.fromUser, message.content.reaction.orEmpty()) + storyReactionsIncoming += + Triple(about.messageId, message.fromUser, message.content.reaction.orEmpty()) } continue } val ref = message.content.attachment if (ref != null && ref.kind == Attachment.STORY && message.fromAddress == null) { - // A story is caught before the group-message branch, so - // without this check a sender could attach the id of a group - // they are not in and have their story shown as having come - // from it. Dropped to null rather than refused: the story - // itself is legitimately theirs, and only the claim about - // where it was posted is unverified. + // A story is caught before the group-message branch, so without + // this check a sender could attach the id of a group they are + // not in and have their story shown as having come from it. val claimed = message.content.groupId - val storyGid = claimed?.takeIf { g -> - groupRoster(g)?.contains(message.fromUser) == true - } - val story = Vault.Story( - id = ref.id, - authorId = message.fromUser, - mine = false, - ref = ref, - caption = message.content.text, - postedAt = System.currentTimeMillis(), - set = message.content.storySet, - gid = storyGid, - ) + val storyGid = + claimed?.takeIf { g -> + groupRoster(g)?.contains(message.fromUser) == true + } + val story = + Vault.Story( + id = ref.id, + authorId = message.fromUser, + mine = false, + ref = ref, + caption = message.content.text, + postedAt = System.currentTimeMillis(), + set = message.content.storySet, + gid = storyGid, + ) if (story.isActive(now)) incomingStories += story continue } // A group message is filed under the GROUP, not the sender. - // Filing by sender scatters one conversation across as many threads - // as it has participants. - // - // Both memberships are checked first (audit H1). Filing on - // the sender's word alone let a contact conjure a group thread we - // are not in, and let anyone we share a session with, including a - // member we REMOVED, whose 1:1 session outlives their membership, - // post into a group they do not belong to. The roster is the - // relay's; a group it does not list for us is not ours. - // A group id from another relay names a group on THEIR relay, - // which our roster check would look up here and either miss or, - // worse, match by coincidence. Groups do not federate yet, so a - // remote sender's group claim is ignored and the message files in - // their own thread. - // The guard that stops this list needing to be complete. - // Every branch above names one kind of non-message. `isRenderable` - // is the engine's own answer to "does this belong in a thread", and - // it is false for reactions, call signals, edits, pins and, the - // case that matters most. ANY control envelope from a newer peer, - // which carries `ctl` and a kind this build has never heard of. - // - // Without it, a control kind added on iOS ships a bubble full of - // JSON into somebody's conversation on every Android install that - // predates it. The engine has had `isRenderable` since §6.15; the - // app simply never consulted it. if (!message.content.isRenderable) continue val gid = if (message.fromAddress != null) null else message.content.groupId if (gid != null) { val known = groupRoster(gid) if (known == null || message.fromUser !in known) { - // Refusing is correct, but refusing in silence is what - // made a sealed group look like a group nobody could talk - // in. Say which group and which sender, never the message. + // Refusing is correct, but refusing in silence is what made + // a sealed group look like a group nobody could talk in. System.err.println( "fnmsg-recv: NOT FILED into group ${gid.take(8)}, " + "sender ${message.fromUser} is not in the roster " + - "(roster ${known?.size ?: -1} member(s))") + "(roster ${known?.size ?: -1} member(s))", + ) continue } // Every group envelope carries the group's name, and this is - // the repair path for it. The announcement sent at creation - // reaches only who was online and only the devices that existed - // then; a phone set up later would otherwise sit on the relay's - // generic placeholder for ever. - // - // Proven senders only, exactly like the announcement. - // `consumeControl` refuses an unproven device, and for a while - // this path did not, so the weaker of the two routes decided - // what a group is called. On an unproven device `fromUser` is a - // claim, and the roster check above then asks "is the person - // they are pretending to be in this group", which answers yes - // for any member they care to name. + // the repair path for it. if (message.senderProven) { - message.content.raw?.optString("gname")?.takeIf { it.isNotEmpty() } + message.content.raw + ?.optString("gname") + ?.takeIf { it.isNotEmpty() } ?.let { adoptGroupName(gid, it) } } } - // A federated message files under its ADDRESS. Their relay - // told ours nothing about who sent it, so `fromUser` is 0, filing - // by it would drop every message from every other relay into one - // thread with no name on it, mixed together. - val thread = gid - ?.let(Vault::threadForGroup) - ?: message.fromAddress?.let(Vault::threadForAddress) - ?: Vault.threadForUser(message.fromUser) - record(thread, message.content.text, mine = false, + // A federated message files under its ADDRESS. + val thread = + gid + ?.let(Vault::threadForGroup) + ?: message.fromAddress?.let(Vault::threadForAddress) + ?: Vault.threadForUser(message.fromUser) + record( + thread, + message.content.text, + mine = false, attachment = message.content.attachment, sticker = message.content.sticker?.let { Sticker(it.pack, it.id) }, author = message.fromUser, - // THEIR id, not one we make up. It is what they will use - // when they edit this message, and what we use when we react - // to it, an id invented here would match nothing on their - // side. + // THEIR id, not one we make up. messageId = message.content.messageId, about = message.content.about, lifetimeSeconds = message.content.lifetimeSeconds ?: 0, // §6.16, so second-hand words are not drawn as the sender's - // own. The engine has read `fwd` off the wire since forwarding - // shipped; the transcript was where it stopped. - forwarded = message.content.isForwarded) + // own. + forwarded = message.content.isForwarded, + ) touched += thread } @@ -1503,21 +1352,18 @@ class Session( return touched } - // -- Stories (§6.6) ------------------------------------------------------ + // -- Stories (§6.6) ------------------------------------------------------. /** * Who reacted to which story, and with what. - * - * One reaction per person, exactly like a message: a second emoji - * replaces the first rather than accumulating, or a reaction becomes a way - * to spam somebody's story. - * - * Session-lived, like `seen`. A reaction to a story that expires tomorrow - * does not need to outlive it. */ private val storyReactions = mutableMapOf>() - fun applyStoryReaction(storyKey: String, who: Long, emoji: String) { + fun applyStoryReaction( + storyKey: String, + who: Long, + emoji: String, + ) { val forStory = storyReactions.getOrPut(storyKey) { mutableMapOf() } if (emoji.isEmpty()) forStory.remove(who) else forStory[who] = emoji if (forStory.isEmpty()) storyReactions.remove(storyKey) @@ -1525,10 +1371,6 @@ class Session( /** * Whether the private story archive is switched on (ios#54). - * - * Injected rather than read from a context here: this layer has no - * Android dependency, and an unset value must read as OFF, the archive - * keeps something the interface said would disappear. */ var archiveEnabled: () -> Boolean = { false } @@ -1540,17 +1382,6 @@ class Session( /** * Post a story to a group (§6.24). - * - * The group's roster is the audience, and `storyAudience` does NOT - * apply. Two different lists making two different statements, who may - * see this account's stories, and who is in a conversation, and they will - * disagree. The roster wins: a story addressed to a group that silently - * dropped members would be a group message part of the group cannot see. - * The caller is responsible for having disclosed that before the first one. - * - * Recorded in the STORY set, never the group thread. `gid` names the - * audience, not a destination, a story rendered into the conversation - * would be a story that is a message, and one that never expires out of it. */ fun postGroupStory( bytes: ByteArray, @@ -1564,22 +1395,31 @@ class Session( ): Int { val recipients = memberIds.filter { it != userId } if (recipients.isEmpty()) return 0 - val posted = conversations.postStory( - bytes, mime, width, height, caption, recipients, - lifetimeSeconds = lifetimeSeconds, groupId = gid) + val posted = + conversations.postStory( + bytes, + mime, + width, + height, + caption, + recipients, + lifetimeSeconds = lifetimeSeconds, + groupId = gid, + ) if (posted.reached > 0) { val now = DeviceClock.expirySeconds() vault.saveStories( - vault.loadStories() + Vault.Story( - id = posted.ref.id, - authorId = userId, - mine = true, - ref = posted.ref, - caption = caption.orEmpty(), - postedAt = System.currentTimeMillis(), - seen = true, - gid = gid, - ), + vault.loadStories() + + Vault.Story( + id = posted.ref.id, + authorId = userId, + mine = true, + ref = posted.ref, + caption = caption.orEmpty(), + postedAt = System.currentTimeMillis(), + seen = true, + gid = gid, + ), now, ) } @@ -1588,14 +1428,13 @@ class Session( /** * File a report about a story (§6.25). - * - * Sends readable content, deliberately and only on the caller's - * instruction. Everything else in this app is sealed from the relay; a - * report is the one thing somebody chooses to hand over, and it has to be - * their choice each time rather than a setting. */ - fun reportStory(story: Vault.Story, reason: String, - includeContent: Boolean, note: String) { + fun reportStory( + story: Vault.Story, + reason: String, + includeContent: Boolean, + note: String, + ) { relay.submitReport( about = story.authorId, kind = "story", @@ -1607,22 +1446,14 @@ class Session( /** * File a report about a MESSAGE (§6.25, android#20 / ios#12). - * - * `kind = "message"` has always been accepted and nothing ever sent - * one. Reporting existed on stories only, so the surface where - * unsolicited contact, spam and harassment actually arrive had no way to - * tell anybody. The person being messaged by a stranger had Block, which - * protects them and tells the operator nothing, so the account moves on - * to the next person. - * - * Same bargain as `reportStory`, and the caller must have obtained - * consent naming it: the relay cannot open a sealed message, so a report - * is actionable only if the reporter hands over what they saw. - * - * The ONE message, never the thread around it. */ - fun reportMessage(text: String, senderId: Long, reason: String, - includeContent: Boolean, note: String) { + fun reportMessage( + text: String, + senderId: Long, + reason: String, + includeContent: Boolean, + note: String, + ) { relay.submitReport( about = senderId, kind = "message", @@ -1634,9 +1465,6 @@ class Session( /** * Told after every 1:1 send whether it was sealed (ios#16). - * - * Injected: the counters live in Android preferences and this layer has - * no Android dependency. */ var onSendSealed: (Boolean, Long) -> Unit = { _, _ -> } @@ -1644,33 +1472,47 @@ class Session( fun myReactionTo(storyKey: String): String? = storyReactions[storyKey]?.get(userId) - fun reactToStory(emoji: String, story: Vault.Story) { + fun reactToStory( + emoji: String, + story: Vault.Story, + ) { if (story.mine) return conversations.reactToStory(emoji, story.id, story.authorId) applyStoryReaction(story.id, userId, emoji) } - fun replyToStory(body: String, story: Vault.Story) { - conversations.replyToStory(body, story.id, story.authorId, - story.caption.take(80)) + fun replyToStory( + body: String, + story: Vault.Story, + ) { + conversations.replyToStory( + body, + story.id, + story.authorId, + story.caption.take(80), + ) } - fun postTextStory(text: String, toUserIds: List, lifetimeSeconds: Double) - : io.flamenet.client.Conversations.TextStoryPosted { + fun postTextStory( + text: String, + toUserIds: List, + lifetimeSeconds: Double, + ): io.flamenet.client.Conversations.TextStoryPosted { val posted = conversations.postTextStory(text, toUserIds, lifetimeSeconds) if (posted.reached > 0) { val now = DeviceClock.expirySeconds() vault.saveStories( - vault.loadStories() + Vault.Story( - id = posted.key, - authorId = userId, - mine = true, - ref = null, - caption = text, - postedAt = System.currentTimeMillis(), - seen = true, - expiresAt = posted.expires, - ), + vault.loadStories() + + Vault.Story( + id = posted.key, + authorId = userId, + mine = true, + ref = null, + caption = text, + postedAt = System.currentTimeMillis(), + seen = true, + expiresAt = posted.expires, + ), now, ) } @@ -1678,15 +1520,15 @@ class Session( } /** - * Muting somebody's stories is NOT muting their messages, and uses its - * own key. Wanting somebody's news to stop is not wanting to stop hearing - * from them, and folding the two together would make "I don't need their - * holiday photos" cost a missed message. It is not blocking either: - * nothing is told to the other person and nothing is refused. + * Muting somebody's stories is NOT muting their messages, and uses its own + * key. */ fun storiesMuted(authorId: Long): Boolean = vault.storyMutes().contains(authorId) - fun setStoriesMuted(authorId: Long, muted: Boolean) { + fun setStoriesMuted( + authorId: Long, + muted: Boolean, + ) { val current = vault.storyMutes().toMutableSet() if (muted) current += authorId else current -= authorId vault.saveStoryMutes(current) @@ -1697,19 +1539,18 @@ class Session( val now = DeviceClock.expirySeconds() val all = vault.loadStories() // ios#54, anything of YOUR OWN that has expired is archived here, - // before `saveStories` drops it. This is the only moment it can be - // caught: expired stories leave on write, so nothing downstream sees - // them again. + // before `saveStories` drops it. if (archiveEnabled()) { val expiredMine = all.filter { it.mine && !it.isActive(now) } if (expiredMine.isNotEmpty()) { vault.saveStoryArchive(Vault.archive(vault.storyArchive(), expiredMine)) } } - val live = all - .filter { it.isActive(now) } - // One place, so every screen agrees about whose stories are hidden. - .filter { it.mine || !storiesMuted(it.authorId) } + val live = + all + .filter { it.isActive(now) } + // One place, so every screen agrees about whose stories are hidden. + .filter { it.mine || !storiesMuted(it.authorId) } // Written back so expired bytes leave the device rather than lingering // until the next post. vault.saveStories(live, now) @@ -1718,13 +1559,16 @@ class Session( /** * Post to several contacts at once. - * - * Recorded locally from the reference we just built, so the author's own - * strip shows it without fetching their own image back from the relay. */ + /** Publish a profile picture and tell every contact where it is. */ - fun setAvatar(bytes: ByteArray, mime: String, width: Int, height: Int, toUserIds: List) = - conversations.setAvatar(bytes, mime, width, height, toUserIds) + fun setAvatar( + bytes: ByteArray, + mime: String, + width: Int, + height: Int, + toUserIds: List, + ) = conversations.setAvatar(bytes, mime, width, height, toUserIds) fun myAvatar() = conversations.myAvatar() @@ -1732,35 +1576,29 @@ class Session( /** * The last line of each thread, for the list. - * - * The kit's row is four things, avatar, name, the last message, and - * a time, and ours carried a name and a presence word. That is most of why - * the screen read as empty: two contacts on a phone-height list with - * nothing on the right-hand side of either row. - * - * Read from the local vault, not the relay. The relay does not send a - * preview and asking it to would leak which conversations are active; the - * history is already decrypted on this device, which is the only place it - * can be read anyway. - * - * An attachment with no caption has no text to show, so it is named rather - * than rendered as a blank second line. */ fun previews(userIds: List): Map = - userIds.mapNotNull { id -> - history(Vault.threadForUser(id)).lastOrNull()?.let { line -> - id to Preview( - text = line.text.ifBlank { - if (line.attachment != null) "Photo" else "" - }, - at = line.at, - mine = line.mine, - ) - } - }.toMap() + userIds + .mapNotNull { id -> + history(Vault.threadForUser(id)).lastOrNull()?.let { line -> + id to + Preview( + text = + line.text.ifBlank { + if (line.attachment != null) "Photo" else "" + }, + at = line.at, + mine = line.mine, + ) + } + }.toMap() /** One thread's last line, reduced to what a row draws. */ - data class Preview(val text: String, val at: Long, val mine: Boolean) + data class Preview( + val text: String, + val at: Long, + val mine: Boolean, + ) /** Every picture we hold, for the UI to render without asking one by one. */ fun avatars(userIds: List): Map = @@ -1769,7 +1607,10 @@ class Session( fun shareAvatarIfNeeded(toUserId: Long) = conversations.shareAvatarIfNeeded(toUserId) /** One row on the Devices screen. */ - data class RegisteredDevice(val deviceId: String, val createdAt: String?) + data class RegisteredDevice( + val deviceId: String, + val createdAt: String?, + ) /** This account's devices, from the relay directory (§8.3). */ fun ownDevices(): List { @@ -1799,31 +1640,39 @@ class Session( /** A live stream this story embeds (§6.13); [bytes] is then the poster. */ streamUrl: String? = null, /** - * Passed explicitly rather than left to the client's default. The - * default is 24 hours and the setting exists precisely so that is not - * the only answer; a call site that omits it silently ignores what the - * person chose. + * Passed explicitly rather than left to the client's default. */ lifetimeSeconds: Double, ): io.flamenet.client.Conversations.StoryPosted { - val posted = conversations.postStory(bytes, mime, width, height, caption, toUserIds, - lifetimeSeconds = lifetimeSeconds, - duration = duration, streamUrl = streamUrl) + val posted = + conversations.postStory( + bytes, + mime, + width, + height, + caption, + toUserIds, + lifetimeSeconds = lifetimeSeconds, + duration = duration, + streamUrl = streamUrl, + ) if (posted.reached > 0) { val now = DeviceClock.expirySeconds() vault.saveStories( - vault.loadStories() + Vault.Story( - id = posted.ref.id, - authorId = userId, - mine = true, - ref = posted.ref, - caption = caption.orEmpty(), - postedAt = System.currentTimeMillis(), - seen = true, - // The poster sees the same gate their contacts will, rather - // than a story that looks different to the person who sent it. - streamUrl = streamUrl, - ), + vault.loadStories() + + Vault.Story( + id = posted.ref.id, + authorId = userId, + mine = true, + ref = posted.ref, + caption = caption.orEmpty(), + postedAt = System.currentTimeMillis(), + seen = true, + // The poster sees the same gate their contacts will, rather + // than a story that looks different to the person who sent + // it. + streamUrl = streamUrl, + ), now, ) } @@ -1832,50 +1681,40 @@ class Session( /** * When a screenshot notice was last sent to each peer (§6.27). - * - * In memory only. The window it enforces is seconds long, so persisting - * it would be storing who screenshotted whom, a record of exactly the - * thing this feature exists to report, on disk, for no benefit. */ private val lastScreenshotNotice = mutableMapOf() /** * Tell this peer their disappearing message was screenshotted (§6.27). - * - * Best effort and deliberately silent on failure. A notice that could - * not be sent is not something to surface to the person who took the - * screenshot, they did not ask for it and cannot act on it. */ - fun announceScreenshot(peerUserId: Long, nowMillis: Long = System.currentTimeMillis()) { + fun announceScreenshot( + peerUserId: Long, + nowMillis: Long = System.currentTimeMillis(), + ) { val timer = vault.disappearSeconds(Vault.threadForUser(peerUserId)) - if (!Content.shouldAnnounceScreenshot( - timer, lastScreenshotNotice[peerUserId], nowMillis)) return + if (!Content.shouldAnnounceScreenshot(timer, lastScreenshotNotice[peerUserId], nowMillis)) { + return + } lastScreenshotNotice[peerUserId] = nowMillis runCatching { conversations.send(peerUserId, Content.screenshot(nowMillis / 1000)) } } /** * Members of a group we are in, or null if the relay does not list it for - * us (audit H1). Cached for a short window: a poll pass can carry many - * group messages and each must not cost a round trip. + * us (audit H1). */ private var groupRosterCache: Pair>>? = null - // -- Sealed groups (§8.9b) ------------------------------------------------ + // -- Sealed groups (§8.9b) + // ------------------------------------------------. /** * Create a group whose roster the relay cannot read. - * - * No member id leaves this device. The relay is handed a random gid, - * an owner public key, one opaque tag per member, and a blob. What it stored - * before was `gid -> user_id`, one readable row per membership. - * - * Reachability is checked HERE now, not by the relay, it used to ask - * "does this account have a device", which required knowing who the members - * were. The client suffers an unreachable member anyway, since fan-out is - * per device. */ - fun createSealedGroup(name: String, memberIds: List): RelayClient.Group { + fun createSealedGroup( + name: String, + memberIds: List, + ): RelayClient.Group { val members = (memberIds + userId).distinct().sorted() for (member in members) { if (member == userId) continue @@ -1889,16 +1728,27 @@ class Session( val owner = SealedRoster.newOwnerKey() val tags = members.associate { it.toString() to SealedRoster.newTag() } - val blob = SealedRoster.seal( - SealedRoster.Contents(members, tags, name), groupKey) + val blob = + SealedRoster.seal(SealedRoster.Contents(members, tags, name), groupKey) val sig = SealedRoster.signRoster(owner.seed, gid, 1, blob) - relay.createSealedGroup(gid, SealedRoster.ownerPublic(owner), blob, - tags.values.sorted(), sig) + relay.createSealedGroup( + gid, + SealedRoster.ownerPublic(owner), + blob, + tags.values.sorted(), + sig, + ) - vault.saveGroupSecrets(gid, Vault.GroupSecrets( - tag = tags[userId.toString()] ?: "", key = groupKey, - ownerPrivate = owner.seed, version = 1)) + vault.saveGroupSecrets( + gid, + Vault.GroupSecrets( + tag = tags[userId.toString()] ?: "", + key = groupKey, + ownerPrivate = owner.seed, + version = 1, + ), + ) vault.saveGroupName(gid, name) announceSealedGroup(gid, tags, groupKey, name, members) @@ -1907,25 +1757,24 @@ class Session( /** * The groups this device can open, which is the only such list anywhere. - * - * The relay is asked by TAG. It cannot be asked "what groups am I in", - * because after sealing it does not know. */ fun sealedGroups(): List { val secrets = vault.allGroupSecrets() if (secrets.isEmpty()) return emptyList() val byTag = secrets.entries.associate { it.value.tag to it.key } - val fetched = runCatching { relay.fetchSealedGroups(byTag.keys.toList()) } - .getOrElse { return emptyList() } + val fetched = + runCatching { relay.fetchSealedGroups(byTag.keys.toList()) } + .getOrElse { return emptyList() } val out = ArrayList() for (row in fetched) { val secret = secrets[row.id] ?: continue // A blob that will not open is skipped, never treated as an empty - // group: dropping every member from a conversation that still exists - // is worse than showing it stale. - val contents = runCatching { SealedRoster.open(row.rosterBlob, secret.key) } - .getOrNull() ?: continue + // group: dropping every member from a conversation that still + // exists is worse than showing it stale. + val contents = + runCatching { SealedRoster.open(row.rosterBlob, secret.key) } + .getOrNull() ?: continue vault.saveGroupSecrets(row.id, secret.copy(version = row.rosterVersion)) if (contents.name.isNotEmpty()) vault.saveGroupName(row.id, contents.name) out += RelayClient.Group(row.id, contents.name, userId, contents.members) @@ -1935,37 +1784,38 @@ class Session( /** * Add or remove members of a sealed group. - * - * Owner only, and the relay checks a signature rather than an - * account. We hold the owner's private key iff we created the group; a - * member who is not the owner has nothing to sign with, and is refused here - * rather than sent to be rejected. - * - * The version must increase. A roster signature stays valid for ever, so - * a stale roster could otherwise be replayed to re-add somebody who was just - * removed, with a signature that verifies. */ - fun updateSealedGroupMembers(gid: String, add: List, remove: List) { + fun updateSealedGroupMembers( + gid: String, + add: List, + remove: List, + ) { val secrets = vault.groupSecrets(gid) - val ownerSeed = secrets?.ownerPrivate - ?: throw IllegalStateException("only the group's owner can change its members") + val ownerSeed = + secrets?.ownerPrivate + ?: throw IllegalStateException("only the group's owner can change its members") - val current = sealedGroups().firstOrNull { it.id == gid } - ?: throw IllegalStateException("unknown group") - val roster = (current.memberIds + add - remove.filter { it != userId }.toSet()) - .distinct().sorted() + val current = + sealedGroups().firstOrNull { it.id == gid } + ?: throw IllegalStateException("unknown group") + val roster = + (current.memberIds + add - remove.filter { it != userId }.toSet()) + .distinct() + .sorted() require(roster.size <= 32) { "groups are capped at 32 members" } // Existing members keep their tags; only newcomers get fresh ones. - // Reusing a tag would hand somebody a token another member had held. - val previous = runCatching { - SealedRoster.open( - relay.fetchSealedGroups(listOf(secrets.tag)).first { it.id == gid }.rosterBlob, - secrets.key) - }.getOrNull()?.tags ?: emptyMap() - val tags = roster.associate { m -> - m.toString() to (previous[m.toString()] ?: SealedRoster.newTag()) - } + val previous = + runCatching { + SealedRoster.open( + relay.fetchSealedGroups(listOf(secrets.tag)).first { it.id == gid }.rosterBlob, + secrets.key, + ) + }.getOrNull()?.tags ?: emptyMap() + val tags = + roster.associate { m -> + m.toString() to (previous[m.toString()] ?: SealedRoster.newTag()) + } val name = vault.groupName(gid) ?: "" val version = secrets.version + 1 @@ -1982,15 +1832,19 @@ class Session( /** Hand each member their tag and the group key, over the ratchet. */ private fun announceSealedGroup( - gid: String, tags: Map, key: ByteArray, - name: String, members: List, + gid: String, + tags: Map, + key: ByteArray, + name: String, + members: List, ) { val mine = vault.loadDelivery()?.key ?: return for (member in members) { val tag = tags[member.toString()] ?: continue - val payload = io.flamenet.e2e.SealedSender.sealedGroupEnvelope( - mine, gid, tag, key, name) - // Our own other devices included, for the same reason names are: + val payload = + io.flamenet.e2e.SealedSender + .sealedGroupEnvelope(mine, gid, tag, key, name) + // Our own other devices included, for the same reason names are. // fan-out excludes us, so a second phone we own is told by nobody. runCatching { conversations.sendPlaintextForGroupInvite(member, payload) } } @@ -1998,28 +1852,14 @@ class Session( /** * The groups this account is in, each carrying the name we hold locally. - * - * The relay's `name` is a placeholder for every group. It stopped - * storing names, it never read them, and a column holding "Chemotherapy - * support" is a column that can be dumped or subpoenaed, and answers with - * one generic word so builds that predate the change do not draw a blank - * row. Everything above this reads `Group.name`, so the substitution - * happens once, here, rather than at every screen that shows a group. */ fun namedGroups(): List { - // The sealed ones cannot come from `listGroups`. The relay holds - // no membership rows for them, so it has nothing to answer with, they - // are found only by presenting the tags this device holds. Without this - // merge a sealed group is invisible on every screen. + // The sealed ones cannot come from `listGroups`. val sealed = sealedGroups() val sealedIds = sealed.map { it.id }.toSet() // Merge rather than replace: a group made before sealing still comes // from `listGroups`, and dropping those would make somebody's older // groups vanish the moment sealing shipped. - // - // And sealed wins on a collision. Its roster came out of a blob the - // owner signed; the relay's answer for the same gid is unauthenticated, - // so preferring it would put the server back in charge of membership. val plain = relay.listGroups().filterNot { it.id in sealedIds } return (sealed + plain).map { group -> vault.groupName(group.id)?.let { group.copy(name = it) } ?: group @@ -2031,20 +1871,12 @@ class Session( /** * Seal a group that already exists in the clear, keeping its gid. - * - * The point of the feature for anybody who already has groups. - * The migration is additive by design, the relay keeps a plaintext roster - * until a client replaces it, so a group made before sealing stays readable - * in the database for ever unless something does this. - * - * Owner only, and the members are read out of the roster we already hold - * rather than asked for, so an upgrade cannot quietly change who is in the - * group. */ fun upgradeGroupToSealed(gid: String): RelayClient.Group { require(vault.groupSecrets(gid) == null) { "that group is already sealed" } - val existing = relay.listGroups().firstOrNull { it.id == gid } - ?: throw IllegalStateException("unknown group") + val existing = + relay.listGroups().firstOrNull { it.id == gid } + ?: throw IllegalStateException("unknown group") require(existing.owner == userId) { "only the group's owner can seal it" } val members = existing.memberIds.distinct().sorted() @@ -2058,12 +1890,23 @@ class Session( // The relay takes the same request as a create: it replaces the // plaintext row in place and deletes its membership rows in the same // transaction. - relay.createSealedGroup(gid, SealedRoster.ownerPublic(owner), blob, - tags.values.sorted(), sig) + relay.createSealedGroup( + gid, + SealedRoster.ownerPublic(owner), + blob, + tags.values.sorted(), + sig, + ) - vault.saveGroupSecrets(gid, Vault.GroupSecrets( - tag = tags[userId.toString()] ?: "", key = groupKey, - ownerPrivate = owner.seed, version = 1)) + vault.saveGroupSecrets( + gid, + Vault.GroupSecrets( + tag = tags[userId.toString()] ?: "", + key = groupKey, + ownerPrivate = owner.seed, + version = 1, + ), + ) if (name.isNotEmpty()) vault.saveGroupName(gid, name) announceSealedGroup(gid, tags, groupKey, name, members) @@ -2072,21 +1915,23 @@ class Session( /** * Membership edits and leaving, routed by whether the group is sealed. - * - * One entry point, so no screen has to branch to be correct. Sending - * a sealed group down the plaintext route asks the relay to edit a table it - * no longer has: the call returns fine and the member is not there. */ - fun updateMembers(gid: String, add: List, remove: List) { - if (isSealed(gid)) updateSealedGroupMembers(gid, add, remove) - else relay.updateGroupMembers(gid, add, remove) + fun updateMembers( + gid: String, + add: List, + remove: List, + ) { + if (isSealed(gid)) { + updateSealedGroupMembers(gid, add, remove) + } else { + relay.updateGroupMembers(gid, add, remove) + } } fun leaveAnyGroup(gid: String) { val secrets = vault.groupSecrets(gid) if (secrets != null) { // A sealed group is left by surrendering the tag that let us in. - // The relay has no account row to remove; the tag IS the membership. relay.leaveSealedGroup(gid, secrets.tag) vault.clearGroupSecrets(gid) } else { @@ -2096,31 +1941,29 @@ class Session( } /** Name a group on this device. The relay is not told. */ - fun nameGroup(gid: String, name: String) { + fun nameGroup( + gid: String, + name: String, + ) { val trimmed = name.trim() if (trimmed.isNotEmpty()) vault.saveGroupName(gid, trimmed) } /** * Push a group's name to its members over their pairwise ratchets. - * - * Rides the `delivery_key` control kind rather than a new one, a new kind - * would arrive on every phone running v5 as a bubble full of raw JSON. See - * `SealedSender.deliveryKeyEnvelope`. - * - * Best-effort per member: one unreachable device must not stop the rest - * of the group learning what the group is called. */ - fun announceGroupName(gid: String, memberIds: List) = - conversations.announceGroupName(gid, memberIds) + fun announceGroupName( + gid: String, + memberIds: List, + ) = conversations.announceGroupName(gid, memberIds) /** * Record a name a member gave a group, if it is news. - * - * Callers check membership first. There is no path here that takes a - * name on the sender's word. */ - private fun adoptGroupName(gid: String, name: String) { + private fun adoptGroupName( + gid: String, + name: String, + ) { val trimmed = name.trim() if (trimmed.isEmpty() || vault.groupName(gid) == trimmed) return vault.saveGroupName(gid, trimmed) @@ -2130,11 +1973,6 @@ class Session( /** * Adopt the group-name claims that arrived as control traffic, checking * each against the relay's roster. - * - * The check is the point. A claim naming a group we are not in - * or one from somebody who is not in it, is dropped. Without that, - * anyone able to reach us could write whatever they liked into our chat - * list under a group heading the reader has no reason to doubt. */ private fun drainGroupNames() { for ((fromUser, gid, name) in conversations.takeClaimedGroupNames()) { @@ -2146,22 +1984,14 @@ class Session( /** * §8.9b, invitations: a tag and the group key, arriving over the ratchet. - * - * The only way these ever reach us. The relay holds neither, by - * construction, so a member who is not told cannot fetch the group at all. - * - * Not subject to the roster check the NAME goes through, and cannot - * be. That check asks the relay who is in the group, and for a group we - * have not joined yet, we cannot fetch it to ask. What guards this instead - * is that an invitation is accepted only for a group we hold no secrets - * for: a stranger cannot overwrite a group we are really in, which would - * make every later roster fail to open and silently empty the conversation. */ private fun drainGroupInvitations() { for ((_, gid, invite) in conversations.takeGroupInvitations()) { if (vault.groupSecrets(gid) != null) continue - vault.saveGroupSecrets(gid, Vault.GroupSecrets( - tag = invite.tag, key = invite.key, ownerPrivate = null, version = 1)) + vault.saveGroupSecrets( + gid, + Vault.GroupSecrets(tag = invite.tag, key = invite.key, ownerPrivate = null, version = 1), + ) if (invite.name.isNotEmpty()) vault.saveGroupName(gid, invite.name) groupNamesUpdated = true } @@ -2169,60 +1999,58 @@ class Session( /** * Set when a group name changed, so the UI knows to re-read the list. - * Control traffic touches no thread, so without a flag a name that arrived - * mid-session stayed invisible until the next sign-in. */ @Volatile var groupNamesUpdated: Boolean = false private fun groupRoster(gid: String): Set? { - // A SEALED group's roster is not the relay's to give. §8.9b - // exists so the relay holds opaque tags and no membership at all, so + // A SEALED group's roster is not the relay's to give. §8.9b exists so + // the relay holds opaque tags and no membership at all, so // `listGroups()` returns an EMPTY member list for one, and the caller // reads that as "the sender is not a member" and drops the message. - // Every message in every sealed group was discarded on arrival, in - // silence, by the check meant to protect it. The sealed roster is the - // authoritative list here and the only one that exists. if (vault.groupSecrets(gid) != null) { return sealedGroups().firstOrNull { it.id == gid }?.memberIds?.toSet() } val now = System.currentTimeMillis() val cached = groupRosterCache if (cached != null && now - cached.first < 30_000) return cached.second[gid] - val fresh = runCatching { - relay.listGroups().associate { it.id to it.memberIds.toSet() } - }.getOrNull() ?: return cached?.second?.get(gid) + val fresh = + runCatching { + relay.listGroups().associate { it.id to it.memberIds.toSet() } + }.getOrNull() ?: return cached?.second?.get(gid) groupRosterCache = now to fresh return fresh[gid] } - // -- Contact list (P1) --------------------------------------------------- + // -- Contact list (P1) ---------------------------------------------------. /** * The roster, from wherever it authoritatively lives. - * - * Off-server mode: the vault's sealed copy IS the list. Otherwise the - * relay's copy is fetched and mirrored into the vault, so the local copy - * is always ready to take over, rides the encrypted history export, and - * serves as the offline fallback when the fetch fails. */ fun roster(): List { val (items, offServer) = vault.loadRoster() - fun stored() = (0 until items.length()).map { - val e = items.getJSONObject(it) - io.flamenet.client.MessengerApi.Buddy( - userId = e.optLong("id"), - displayName = e.optString("name", ""), - handle = e.optString("handle", ""), - presence = "offline", - ) - } + + fun stored() = + (0 until items.length()).map { + val e = items.getJSONObject(it) + io.flamenet.client.MessengerApi.Buddy( + userId = e.optLong("id"), + displayName = e.optString("name", ""), + handle = e.optString("handle", ""), + presence = "offline", + ) + } if (offServer) return stored() return try { val fetched = api.buddies() val mirror = org.json.JSONArray() fetched.forEach { b -> - mirror.put(org.json.JSONObject() - .put("id", b.userId).put("handle", b.handle).put("name", b.displayName)) + mirror.put( + org.json + .JSONObject() + .put("id", b.userId) + .put("handle", b.handle) + .put("name", b.displayName), + ) } vault.saveRoster(mirror, offServer = false) fetched @@ -2233,12 +2061,22 @@ class Session( fun rosterOffServer(): Boolean = vault.loadRoster().second - fun rosterAddLocal(userId: Long, handle: String, name: String) { + fun rosterAddLocal( + userId: Long, + handle: String, + name: String, + ) { val (items, offServer) = vault.loadRoster() for (i in 0 until items.length()) { if (items.getJSONObject(i).optLong("id") == userId) return } - items.put(org.json.JSONObject().put("id", userId).put("handle", handle).put("name", name)) + items.put( + org.json + .JSONObject() + .put("id", userId) + .put("handle", handle) + .put("name", name), + ) vault.saveRoster(items, offServer) } @@ -2254,8 +2092,8 @@ class Session( /** The one-way door: the relay's copy is deleted, the vault's takes over. */ fun forgetRosterOnServer() { - // Mirror FIRST, the door only closes once this device provably - // holds the list. + // Mirror FIRST, the door only closes once this device provably holds + // the list. roster() api.forgetRoster() val (items, _) = vault.loadRoster() diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/SiteConfig.kt b/app/src/main/kotlin/io/flamenet/messenger/app/SiteConfig.kt index 8545fc1..8516a82 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/SiteConfig.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/SiteConfig.kt @@ -4,45 +4,18 @@ import java.net.URI /** * Which relay this install talks to, and where its accounts live. - * - * The three deployment shapes a user can be in, and the only one they have to - * choose is the address: - * - * - Managed, our relay, accounts in the shared service. - * - Self-hosted, shared identity, their relay, accounts in the shared - * service, so one credential still covers Messenger and Files. - * - Self-hosted, local, their relay, its own account table. Not a lesser - * mode: everything works, it simply is not the same login as Files. */ data class SiteConfig( val baseUrl: String, /** * What the relay said about itself, or null until it has answered. - * - * A constructor property on purpose. It used to be a `var` in the - * class body, which left it out of the generated `equals()`, and - * `MutableStateFlow` conflates on equality, so writing back a config that - * differed ONLY by its descriptor was silently dropped. The relay was - * asked, it answered, and the answer went nowhere: the sign-in screen sat - * on "Checking what this server uses…" forever and `accountProvider` stayed - * LOCAL for a shared-accounts relay. Keep it in the constructor and write - * it with `copy(...)`. */ val discovered: io.flamenet.client.RelayDiscovery.Descriptor? = null, /** * The probe was made and came back with nothing. - * - * Without this, "still asking" and "asked and could not reach it" are - * the same state. `discovered` is null in both, so a relay that never - * answers leaves "Checking what this server uses…" on screen forever and - * the form permanently unusable, no error, no retry, nothing to act on. - * Seen 2026-09-05 pointing the app at a plain-HTTP relay, which Android - * blocks outright from API 28: the request never left, and the screen said - * it was still thinking about it. */ val probeFailed: Boolean = false, ) { - enum class AccountProvider { /** The shared FlameNet account service. One credential across products. */ SHARED, @@ -56,10 +29,6 @@ data class SiteConfig( /** * Accept what a person would actually type. - * - * Defaults to https. A bare hostname becoming `http://` would - * hand every request, key material included, to anyone on the path, - * and it would work, which is the worst outcome. */ fun from(typed: String): SiteConfig? { val trimmed = typed.trim().removeSuffix("/") @@ -80,24 +49,14 @@ data class SiteConfig( /** * Where this relay's accounts live. - * - * Asked of the relay, except for the relays that are ours. For - * flamenet.io and its aliases the account service is pinned in the client - * and what they advertise is ignored, so compromising one cannot redirect - * its users' derived secrets. Every other relay is believed: the user typed - * that hostname, and a self-hoster running Messenger and Files together - * needs their own account service to be usable across both. See - * [io.flamenet.client.RelayDiscovery.PINNED_RELAYS]. - * - * Defaults to LOCAL until the descriptor has been fetched, which is the - * safe direction: the passphrase goes only to the server the user typed. */ val accountProvider: AccountProvider - get() = if (discovered?.usesSharedAccounts == true) { - AccountProvider.SHARED - } else { - AccountProvider.LOCAL - } + get() = + if (discovered?.usesSharedAccounts == true) { + AccountProvider.SHARED + } else { + AccountProvider.LOCAL + } val accountServiceUrl: String get() = discovered?.effectiveAccountsUrl ?: SHARED_ACCOUNT_SERVICE @@ -106,13 +65,23 @@ data class SiteConfig( /** What to tell the user this relay does about identity, in their words. */ val identityDescription: String - get() = when { - probeFailed -> - "Couldn't reach that server. Check the address, and note it has " + - "to be https, not http." - discovered == null -> "Checking what this server uses…" - accountProvider == AccountProvider.SHARED -> - "One Flamenet account, shared with Flamenet Files." - else -> "This server keeps its own accounts." - } + get() = + when { + probeFailed -> { + "Couldn't reach that server. Check the address, and note it has " + + "to be https, not http." + } + + discovered == null -> { + "Checking what this server uses…" + } + + accountProvider == AccountProvider.SHARED -> { + "One Flamenet account, shared with Flamenet Files." + } + + else -> { + "This server keeps its own accounts." + } + } } diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/StickerPack.kt b/app/src/main/kotlin/io/flamenet/messenger/app/StickerPack.kt index 7dde283..ec85ec8 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/StickerPack.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/StickerPack.kt @@ -5,21 +5,13 @@ import org.json.JSONObject /** * The shipped sticker set, read from the same manifest the iOS app reads. - * - * One manifest, both clients. `Resources/stickers/flamenet.core.json` is - * copied into Android's assets; neither side hard-codes the list. The only - * protocol bug this project has actually shipped was a client and a server - * disagreeing about a field name with nothing to check it against, and a - * sticker set defined twice is the same shape of mistake waiting to happen. - * - * An unknown id resolves to the fallback, never to nothing. A pack the - * receiver does not have, an older build, a pack added later, must still draw - * something. A sticker that renders as an empty bubble is worse than one that - * renders as the wrong face. */ object StickerPack { - - data class Sticker(val id: String, val glyph: String, val name: String) + data class Sticker( + val id: String, + val glyph: String, + val name: String, + ) const val CORE = "flamenet.core" @@ -35,17 +27,22 @@ object StickerPack { /** * What to draw for a reference. - * - * Returns the fallback for anything unrecognised, a different pack, an id - * from a newer build, because the alternative is a blank bubble. */ - fun glyph(context: Context, pack: String, id: String): String { + fun glyph( + context: Context, + pack: String, + id: String, + ): String { ensure(context) if (pack != CORE) return fallbackGlyph return loaded?.get(id)?.glyph ?: fallbackGlyph } - fun name(context: Context, pack: String, id: String): String { + fun name( + context: Context, + pack: String, + id: String, + ): String { ensure(context) if (pack != CORE) return "Sticker" return loaded?.get(id)?.name ?: "Sticker" @@ -53,15 +50,24 @@ object StickerPack { private fun ensure(context: Context) { if (loaded != null) return - // Failure is empty, not a crash. A missing or malformed asset should - // cost the picker, not the app, and the fallback above keeps every - // sticker somebody has already been sent renderable regardless. - val text = runCatching { - context.assets.open("stickers/$CORE.json").bufferedReader().use { it.readText() } - }.getOrNull() ?: run { loaded = emptyMap(); return } + // Failure is empty, not a crash. + val text = + runCatching { + context.assets + .open("stickers/$CORE.json") + .bufferedReader() + .use { it.readText() } + }.getOrNull() ?: run { + loaded = emptyMap() + return + } - val json = runCatching { JSONObject(text) }.getOrNull() - ?: run { loaded = emptyMap(); return } + val json = + runCatching { JSONObject(text) }.getOrNull() + ?: run { + loaded = emptyMap() + return + } fallbackGlyph = json.optString("fallback").ifBlank { "🙂" } val array = json.optJSONArray("stickers") @@ -70,11 +76,12 @@ object StickerPack { val o = array!!.optJSONObject(i) ?: continue val id = o.optString("id") if (id.isBlank()) continue - list += Sticker( - id = id, - glyph = o.optString("glyph").ifBlank { fallbackGlyph }, - name = o.optString("name").ifBlank { id }, - ) + list += + Sticker( + id = id, + glyph = o.optString("glyph").ifBlank { fallbackGlyph }, + name = o.optString("name").ifBlank { id }, + ) } order = list loaded = list.associateBy { it.id } diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/StoriesFeed.kt b/app/src/main/kotlin/io/flamenet/messenger/app/StoriesFeed.kt index 86d9c0f..452090a 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/StoriesFeed.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/StoriesFeed.kt @@ -16,47 +16,34 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.compose.ui.graphics.asImageBitmap -import io.flamenet.e2e.Vault -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.material3.AlertDialog import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import io.flamenet.e2e.Vault /** * Stories as a feed you scroll, which is the kit's Timeline. - * - * A screen, not a strip. Stories were a horizontal row of circles on the - * Messages screen, enough to tell you something exists and nothing more. The - * kit's Timeline is a column of full-width cards: author, image, caption. A - * card feed cannot sit above a contact list without pushing it off the phone, - * so it gets a tab. The strip stays on Messages as the entry point. - * - * No like or comment bar, and the kit has one. Its post card carries two - * emoji reactions and a share icon; a story here has none of those, there is - * no comment thread, no reaction on a story, and nothing to share it to. Drawing - * the bar and wiring it to nothing would be a lie told in pixels. What a story - * DOES have that the kit's post does not is a deadline, so that is what the - * card says. */ @Composable fun ColumnScope.StoriesFeed( @@ -66,15 +53,16 @@ fun ColumnScope.StoriesFeed( LaunchedEffect(Unit) { model.refreshStories() } var writingWords by remember { mutableStateOf(false) } - // Post from HERE too. The strip on Messages was the only way in, which - // means the tab named Stories could show them but not make one. + // Post from HERE too. val context = androidx.compose.ui.platform.LocalContext.current - val pickStory = androidx.activity.compose.rememberLauncherForActivityResult( - androidx.activity.result.contract.ActivityResultContracts.GetContent() - ) { uri -> - uri ?: return@rememberLauncherForActivityResult - StoryMedia.stage(context, uri, model) - } + val pickStory = + androidx.activity.compose.rememberLauncherForActivityResult( + androidx.activity.result.contract.ActivityResultContracts + .GetContent(), + ) { uri -> + uri ?: return@rememberLauncherForActivityResult + StoryMedia.stage(context, uri, model) + } Row( modifier = Modifier.fillMaxWidth(), @@ -86,30 +74,33 @@ fun ColumnScope.StoriesFeed( modifier = Modifier.weight(1f), ) // A menu, not a picker. - // - // This went straight to the file chooser, so there was no way to reach - // anything else, a story of words was unreachable even once it - // existed, for the same reason it was on iOS. var choosing by remember { mutableStateOf(false) } Box { Icon( KitIcon.Add, contentDescription = "New story", tint = Palette.Accent, - modifier = Modifier - .size(28.dp) - .clickable { choosing = true } - .testTag("new-story"), + modifier = + Modifier + .size(28.dp) + .clickable { choosing = true } + .testTag("new-story"), ) DropdownMenu(expanded = choosing, onDismissRequest = { choosing = false }) { DropdownMenuItem( text = { Text("Photo or video") }, - onClick = { choosing = false; pickStory.launch("*/*") }, + onClick = { + choosing = false + pickStory.launch("*/*") + }, modifier = Modifier.testTag("new-story-photo"), ) DropdownMenuItem( text = { Text("Just words") }, - onClick = { choosing = false; writingWords = true }, + onClick = { + choosing = false + writingWords = true + }, modifier = Modifier.testTag("new-story-text"), ) } @@ -119,13 +110,15 @@ fun ColumnScope.StoriesFeed( if (writingWords) { TextStoryComposer( onDismiss = { writingWords = false }, - onPost = { text -> writingWords = false; model.postTextStory(text) }, + onPost = { text -> + writingWords = false + model.postTextStory(text) + }, ) } Spacer(Modifier.height(Space.sm)) StoriesCards(model, phase) - } /** @@ -138,26 +131,30 @@ private fun StoryCard( phase: MessengerViewModel.Phase.SignedIn, story: Vault.Story, ) { - val author = remember(story.authorId, phase.buddies) { - phase.buddies.firstOrNull { it.userId == story.authorId } - ?.let { it.displayName.ifBlank { it.handle } } - ?: if (story.mine) "You" else "user ${story.authorId}" - } - val remaining = remember(story.id) { - val seconds = story.expires - System.currentTimeMillis() / 1000.0 - when { - story.expires <= 0L -> "no expiry" - seconds <= 0 -> "expired" - seconds < 3600 -> "${(seconds / 60).toInt()} min left" - else -> "${(seconds / 3600).toInt()} h left" + val author = + remember(story.authorId, phase.buddies) { + phase.buddies + .firstOrNull { it.userId == story.authorId } + ?.let { it.displayName.ifBlank { it.handle } } + ?: if (story.mine) "You" else "user ${story.authorId}" + } + val remaining = + remember(story.id) { + val seconds = story.expires - System.currentTimeMillis() / 1000.0 + when { + story.expires <= 0L -> "no expiry" + seconds <= 0 -> "expired" + seconds < 3600 -> "${(seconds / 60).toInt()} min left" + else -> "${(seconds / 3600).toInt()} h left" + } } - } Column( - modifier = Modifier - .fillMaxWidth() - .clickable { model.openStory(story) } - .testTag("story-card-${story.id}"), + modifier = + Modifier + .fillMaxWidth() + .clickable { model.openStory(story) } + .testTag("story-card-${story.id}"), ) { // Header: avatar 40, name 14 medium over a 12 regular description. Row( @@ -165,9 +162,8 @@ private fun StoryCard( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Kit.gutter), ) { - // The author's actual picture when one is held, your own comes - // from myAvatar, a contact's from the avatar map. Initials remain - // the normal case for anyone who never set one. + // The author's actual picture when one is held, your own comes from + // myAvatar, a contact's from the avatar map. Avatar( name = author, online = false, @@ -178,18 +174,18 @@ private fun StoryCard( Column(modifier = Modifier.weight(1f)) { Text(author, fontSize = 14.sp, fontWeight = FontWeight.Medium) // The deadline, in the slot the kit gives a description. - // It is the one fact about a story that changes while you are - // looking at it, and the point of the feature. Text(remaining, fontSize = 12.sp, color = Palette.SecondaryText) } // Unseen is marked here rather than by styling the whole card: a - // feed where read and unread look alike is a feed nobody returns to. + // feed where read and unread look alike is a feed nobody returns + // to. if (!story.seen) { Box( - modifier = Modifier - .size(Kit.presenceDot) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.primary) + modifier = + Modifier + .size(Kit.presenceDot) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary), ) } } @@ -199,34 +195,34 @@ private fun StoryCard( // The image. 375x320 in the kit; an aspect ratio here, because the kit // is drawn on one canvas and this has to hold on every phone. Box( - modifier = Modifier - .fillMaxWidth() - .height(240.dp) - .clip(RoundedCornerShape(Radius.small)) - .background(Palette.SurfaceAlt), + modifier = + Modifier + .fillMaxWidth() + .height(240.dp) + .clip(RoundedCornerShape(Radius.small)) + .background(Palette.SurfaceAlt), contentAlignment = Alignment.Center, ) { // The card's own image, cropped to FILL the box, not the chat // bubble's 260dp thumbnail, which floated small on a full-width - // backboard and read as a broken layout. - // §6.13, an embedded live stream. The gate comes FIRST, because - // tapping play is what connects this device to the stream host; a - // card that started playing on its own would disclose that after - // the fact, which is not a disclosure. `ref` is the poster. + // backboard and read as a broken layout. §6.13, an embedded live + // stream. val stream = story.streamUrl val streamHost = story.streamHost if (stream != null && streamHost != null && - io.flamenet.e2e.StreamDisclosure.acceptable(stream)) { + io.flamenet.e2e.StreamDisclosure + .acceptable(stream) + ) { StoryStreamGate(stream, streamHost) } else { val ref = story.ref when { - // Words alone, set large on a tinted band, the way - // every other messenger draws one. A text story rendered - // as a caption under an empty rectangle reads as a picture - // that failed to load. + // Words alone, set large on a tinted band, the way every + // other messenger draws one. ref == null -> TextStoryCard(story.caption) + ref.mime.startsWith("video/") -> StoryVideoCard(ref, model) + else -> StoryImage(ref, model) } } @@ -244,21 +240,14 @@ private fun StoryCard( /** * React, reply, or stop seeing somebody's stories. - * - * A fixed row of five, not a picker. A story is glanced at and scrolled - * past; anything that takes two taps to say "nice" does not get used, and the - * feature is only worth having if it is faster than typing. - * - * Your OWN story gets the tally instead, you cannot react to yourself, and - * a row of buttons that do nothing is worse than no row. */ @Composable private fun StoryActions( story: Vault.Story, model: MessengerViewModel, - // Passed in rather than resolved here: `StoryCard` already has the - // buddy list and has already done this, and two resolutions of the same - // name are two chances to disagree about who is being reported. + // Passed in rather than resolved here: `StoryCard` already has the buddy + // list and has already done this, and two resolutions of the same name are + // two chances to disagree about who is being reported. authorName: String = "", ) { val quick = listOf("❤️", "😂", "😮", "😢", "🔥") @@ -269,8 +258,10 @@ private fun StoryActions( if (story.mine) { val tally = model.storyTally(story.id) if (tally.isNotEmpty()) { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.testTag("story-tally")) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.testTag("story-tally"), + ) { tally.entries.sortedBy { it.key }.forEach { (emoji, count) -> Text("$emoji $count", fontSize = 13.sp, color = Palette.Mist) } @@ -280,54 +271,63 @@ private fun StoryActions( } val mine = model.myStoryReaction(story.id) - Row(verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { quick.forEach { emoji -> Text( emoji, fontSize = 20.sp, - modifier = Modifier - .clip(RoundedCornerShape(Radius.small)) - .background( - if (mine == emoji) Palette.Accent.copy(alpha = 0.15f) - else androidx.compose.ui.graphics.Color.Transparent - ) - .clickable { - // Tapping the one already sent takes it back. - model.reactToStory(if (mine == emoji) "" else emoji, story) - } - .padding(4.dp) - .testTag("story-react-$emoji"), + modifier = + Modifier + .clip(RoundedCornerShape(Radius.small)) + .background( + if (mine == emoji) { + Palette.Accent.copy(alpha = 0.15f) + } else { + androidx.compose.ui.graphics.Color.Transparent + }, + ).clickable { + // Tapping the one already sent takes it back. + model.reactToStory(if (mine == emoji) "" else emoji, story) + }.padding(4.dp) + .testTag("story-react-$emoji"), ) } Spacer(Modifier.weight(1f)) - Text("Reply", fontSize = 13.sp, color = Palette.Accent, - modifier = Modifier.clickable { replying = true }.testTag("story-reply")) + Text( + "Reply", + fontSize = 13.sp, + color = Palette.Accent, + modifier = Modifier.clickable { replying = true }.testTag("story-reply"), + ) Spacer(Modifier.size(12.dp)) - Text("Mute", fontSize = 13.sp, color = Palette.Mist, - modifier = Modifier.clickable { muting = true }.testTag("story-mute")) - // Reporting a story, which Android could not do. iOS has had it - // since stories shipped; here `MessengerViewModel.reportStory` was - // written, tested by nothing, and called by no screen. A capability - // with no way in is, for Apple's Guideline 1.2 and Play's UGC policy, - // the same as no capability, and a story is the most public thing - // this app carries, so it is the surface those rules are aimed at. - // - // Not on your own story. Reporting yourself to the operator is not - // something anybody means to do, and offering it invites a misfire. + Text( + "Mute", + fontSize = 13.sp, + color = Palette.Mist, + modifier = Modifier.clickable { muting = true }.testTag("story-mute"), + ) + // Reporting a story, which Android could not do. iOS has had it since + // stories shipped; here `MessengerViewModel.reportStory` was written, + // tested by nothing, and called by no screen. if (!story.mine) { Spacer(Modifier.size(12.dp)) - Text("Report", fontSize = 13.sp, color = Palette.Mist, - modifier = Modifier.clickable { reporting = true } - .testTag("story-report")) + Text( + "Report", + fontSize = 13.sp, + color = Palette.Mist, + modifier = + Modifier + .clickable { reporting = true } + .testTag("story-report"), + ) } } if (reporting) { - // The SAME sheet a message report uses, deliberately. Two sheets - // would be two places for the consent wording to drift apart, and the - // consent, that sending a copy hands the operator something readable, - // is the part that matters. + // The SAME sheet a message report uses, deliberately. ReportSheet( authorName = authorName.ifBlank { "this person" }, preview = story.caption, @@ -348,16 +348,18 @@ private fun StoryActions( AlertDialog( onDismissRequest = { replying = false }, title = { Text("Reply privately") }, - // Says where it goes. A reply that looked like a comment under - // the story, readable by whoever else saw it, would be a privacy - // surprise in the wrong direction. + // Says where it goes. text = { Column { - Text("Goes to them in your conversation. Nobody else who " - + "saw this story can read it.", fontSize = 13.sp) + Text( + "Goes to them in your conversation. Nobody else who " + + "saw this story can read it.", + fontSize = 13.sp, + ) Spacer(Modifier.height(Space.sm)) OutlinedTextField( - value = draft, onValueChange = { draft = it }, + value = draft, + onValueChange = { draft = it }, placeholder = { Text("Message") }, modifier = Modifier.fillMaxWidth().testTag("story-reply-field"), ) @@ -365,7 +367,10 @@ private fun StoryActions( }, confirmButton = { TextButton( - onClick = { replying = false; model.replyToStory(draft.trim(), story) }, + onClick = { + replying = false + model.replyToStory(draft.trim(), story) + }, enabled = draft.isNotBlank(), modifier = Modifier.testTag("story-reply-send"), ) { Text("Send") } @@ -378,18 +383,21 @@ private fun StoryActions( AlertDialog( onDismissRequest = { muting = false }, title = { Text("Mute their stories?") }, - // Says what muting is NOT. People reach for it expecting - // something closer to blocking, and are owed the difference before - // they use it rather than after. + // Says what muting is NOT. text = { - Text("Their stories stop showing on this phone. They aren't " - + "told, and their messages still arrive as usual. You " - + "can turn it back on from their contact page.", - fontSize = 13.sp) + Text( + "Their stories stop showing on this phone. They aren't " + + "told, and their messages still arrive as usual. You " + + "can turn it back on from their contact page.", + fontSize = 13.sp, + ) }, confirmButton = { TextButton( - onClick = { muting = false; model.setStoriesMuted(story.authorId, true) }, + onClick = { + muting = false + model.setStoriesMuted(story.authorId, true) + }, modifier = Modifier.testTag("story-mute-confirm"), ) { Text("Mute") } }, @@ -401,7 +409,7 @@ private fun StoryActions( /** * Caption + a deliberate Post for a picked story image, at the shell level so * whichever screen picked it (the strip or the Stories tab) gets the same - * sheet. Picking used to post instantly, no preview, no caption, no way back. + * sheet. */ @Composable fun StoryComposerDialog( @@ -412,26 +420,33 @@ fun StoryComposerDialog( val caption = remember(draft) { androidx.compose.runtime.mutableStateOf("") } val streamUrl = remember(draft) { androidx.compose.runtime.mutableStateOf("") } // Whether the typed URL is one that could be disclosed to a viewer (§6.13). - val streamOk = streamUrl.value.isBlank() || - io.flamenet.e2e.StreamDisclosure.acceptable(streamUrl.value) + val streamOk = + streamUrl.value.isBlank() || + io.flamenet.e2e.StreamDisclosure + .acceptable(streamUrl.value) // Per-story audience, starting where the account setting points. - val mode = remember(draft) { - androidx.compose.runtime.mutableStateOf(phase.storyAudience.mode) - } - val chosen = remember(draft) { - androidx.compose.runtime.mutableStateOf(phase.storyAudience.userIds) - } + val mode = + remember(draft) { + androidx.compose.runtime.mutableStateOf(phase.storyAudience.mode) + } + val chosen = + remember(draft) { + androidx.compose.runtime.mutableStateOf(phase.storyAudience.userIds) + } val context = androidx.compose.ui.platform.LocalContext.current - val image = remember(draft) { - runCatching { - if (draft.duration > 0) - StoryMedia.posterFrame(draft.bytes, context)?.asImageBitmap() - else - // Own draft, but a 50MP photo is just as much bitmap. - ImagePrep.decodeBounded(draft.bytes) - ?.asImageBitmap() - }.getOrNull() - } + val image = + remember(draft) { + runCatching { + if (draft.duration > 0) { + StoryMedia.posterFrame(draft.bytes, context)?.asImageBitmap() + } else { + // Own draft, but a 50MP photo is just as much bitmap. + ImagePrep + .decodeBounded(draft.bytes) + ?.asImageBitmap() + } + }.getOrNull() + } androidx.compose.material3.AlertDialog( onDismissRequest = { model.discardStagedStory() }, title = { Text("New story") }, @@ -442,13 +457,17 @@ fun StoryComposerDialog( androidx.compose.foundation.Image( bitmap = it, contentDescription = "Story preview", - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)), + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)), ) if (draft.duration > 0) { - Text("▶ ${draft.duration.toInt()}s", fontSize = 22.sp, - color = androidx.compose.ui.graphics.Color.White) + Text( + "▶ ${draft.duration.toInt()}s", + fontSize = 22.sp, + color = androidx.compose.ui.graphics.Color.White, + ) } } } @@ -458,11 +477,7 @@ fun StoryComposerDialog( placeholder = { Text("Add a caption…") }, modifier = Modifier.fillMaxWidth().testTag("story-caption"), ) - // Who THIS story goes to. The audience decides who is SENT - // the bytes, there is no relay copy to deny anyone later, - // so the choice belongs here, before posting. Seeded from the - // §6.13, attach a live stream. The picked picture becomes its - // poster: what people see before deciding whether to connect. + // Who THIS story goes to. androidx.compose.material3.OutlinedTextField( value = streamUrl.value, onValueChange = { streamUrl.value = it }, @@ -473,9 +488,7 @@ fun StoryComposerDialog( ) if (streamUrl.value.isNotBlank()) { // Says what a stream costs the people watching, at the - // moment of attaching one. Everything else in a story is - // sealed; this is the one thing that sends viewers to - // somebody else's server. + // moment of attaching one. Text( if (streamOk) { "Your picture becomes the cover. Anyone who taps play connects " + @@ -486,8 +499,12 @@ fun StoryComposerDialog( "name in it." }, fontSize = 12.sp, - color = if (streamOk) Palette.SecondaryText - else MaterialTheme.colorScheme.error, + color = + if (streamOk) { + Palette.SecondaryText + } else { + MaterialTheme.colorScheme.error + }, ) } @@ -509,15 +526,18 @@ fun StoryComposerDialog( if (mode.value != Session.AudienceMode.EVERYONE) { phase.buddies.forEach { buddy -> Row( - modifier = Modifier - .fillMaxWidth() - .clickable { - chosen.value = - if (buddy.userId in chosen.value) chosen.value - buddy.userId - else chosen.value + buddy.userId - } - .padding(vertical = 4.dp) - .testTag("story-aud-${buddy.userId}"), + modifier = + Modifier + .fillMaxWidth() + .clickable { + chosen.value = + if (buddy.userId in chosen.value) { + chosen.value - buddy.userId + } else { + chosen.value + buddy.userId + } + }.padding(vertical = 4.dp) + .testTag("story-aud-${buddy.userId}"), horizontalArrangement = Arrangement.spacedBy(Space.sm), verticalAlignment = Alignment.CenterVertically, ) { @@ -528,12 +548,17 @@ fun StoryComposerDialog( } Text( when (mode.value) { - Session.AudienceMode.EVERYONE -> + Session.AudienceMode.EVERYONE -> { "Goes to all your contacts and disappears after a day." - Session.AudienceMode.ONLY -> + } + + Session.AudienceMode.ONLY -> { "Goes only to the ${chosen.value.size} chosen, and disappears after a day." - Session.AudienceMode.EXCEPT -> + } + + Session.AudienceMode.EXCEPT -> { "Goes to everyone but the ${chosen.value.size} chosen, and disappears after a day." + } }, fontSize = 12.sp, color = Palette.SecondaryText, @@ -549,9 +574,7 @@ fun StoryComposerDialog( streamUrl.value, ) }, - // Refuses to post a stream URL it could not disclose. A - // destination with no host cannot be named, and a viewer cannot - // consent to one they cannot be shown. + // Refuses to post a stream URL it could not disclose. enabled = streamOk, modifier = Modifier.testTag("story-post"), ) { Text("Post") } @@ -564,11 +587,9 @@ fun StoryComposerDialog( ) } - /** * The story cards themselves, everyone's stories, or one honest message when - * there are none. Shared by the Stories tab and the Home screen, so the two - * renderings cannot drift. + * there are none. */ @Composable fun ColumnScope.StoriesCards( @@ -591,9 +612,9 @@ fun ColumnScope.StoriesCards( ) Text("Nothing here yet", fontSize = 16.sp, fontWeight = FontWeight.Medium) Text( - "A story goes to your contacts and disappears after a day, on " - + "your device and theirs, because their app deletes it too. " - + "It is not a secret you can take back.", + "A story goes to your contacts and disappears after a day, on " + + "your device and theirs, because their app deletes it too. " + + "It is not a secret you can take back.", fontSize = 14.sp, color = Palette.SecondaryText, textAlign = androidx.compose.ui.text.style.TextAlign.Center, @@ -608,18 +629,20 @@ fun ColumnScope.StoriesCards( } } - /** - * A story's image at card size: fills the box, cropped. The chat bubble's - * `AttachedImage` deliberately caps at 260dp and letterboxes, right for a - * bubble, wrong for the kit's full-width post card. + * A story's image at card size: fills the box, cropped. */ + /** A story's video, fetched, decrypted, and looping in the card. */ @Composable -private fun StoryVideoCard(ref: io.flamenet.e2e.Attachment.Ref, model: MessengerViewModel) { - val bytes = remember(ref.id) { - androidx.compose.runtime.mutableStateOf(null) - } +private fun StoryVideoCard( + ref: io.flamenet.e2e.Attachment.Ref, + model: MessengerViewModel, +) { + val bytes = + remember(ref.id) { + androidx.compose.runtime.mutableStateOf(null) + } val failed = remember(ref.id) { androidx.compose.runtime.mutableStateOf(false) } LaunchedEffect(ref.id) { val fetched = model.attachmentBytes(ref) @@ -627,57 +650,86 @@ private fun StoryVideoCard(ref: io.flamenet.e2e.Attachment.Ref, model: Messenger } val ready = bytes.value when { - ready != null -> StoryVideoPlayer(ready, cacheKey = ref.id, - modifier = Modifier.fillMaxSize()) - failed.value -> Text("Video unavailable", fontSize = 14.sp, - color = Palette.SecondaryText) - else -> Text("Loading…", fontSize = 14.sp, color = Palette.SecondaryText) + ready != null -> { + StoryVideoPlayer( + ready, + cacheKey = ref.id, + modifier = Modifier.fillMaxSize(), + ) + } + + failed.value -> { + Text( + "Video unavailable", + fontSize = 14.sp, + color = Palette.SecondaryText, + ) + } + + else -> { + Text("Loading…", fontSize = 14.sp, color = Palette.SecondaryText) + } } } @Composable -private fun StoryImage(ref: io.flamenet.e2e.Attachment.Ref, model: MessengerViewModel) { - val bitmap = remember(ref.id) { - androidx.compose.runtime.mutableStateOf(null) - } +private fun StoryImage( + ref: io.flamenet.e2e.Attachment.Ref, + model: MessengerViewModel, +) { + val bitmap = + remember(ref.id) { + androidx.compose.runtime.mutableStateOf(null) + } val failed = remember(ref.id) { androidx.compose.runtime.mutableStateOf(false) } LaunchedEffect(ref.id) { val bytes = model.attachmentBytes(ref) - if (bytes == null) { failed.value = true; return@LaunchedEffect } + if (bytes == null) { + failed.value = true + return@LaunchedEffect + } // Bounded: a received story's bytes are a peer's choice. bitmap.value = ImagePrep.decodeBounded(bytes) if (bitmap.value == null) failed.value = true } val loaded = bitmap.value when { - loaded != null -> androidx.compose.foundation.Image( - bitmap = loaded.asImageBitmap(), - contentDescription = "Story image", - contentScale = androidx.compose.ui.layout.ContentScale.Crop, - modifier = Modifier.fillMaxSize(), - ) - failed.value -> Text("Image unavailable", fontSize = 14.sp, - color = Palette.SecondaryText) - else -> Text("Loading…", fontSize = 14.sp, color = Palette.SecondaryText) + loaded != null -> { + androidx.compose.foundation.Image( + bitmap = loaded.asImageBitmap(), + contentDescription = "Story image", + contentScale = androidx.compose.ui.layout.ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + } + + failed.value -> { + Text( + "Image unavailable", + fontSize = 14.sp, + color = Palette.SecondaryText, + ) + } + + else -> { + Text("Loading…", fontSize = 14.sp, color = Palette.SecondaryText) + } } } /** * A story that is only words. - * - * Set large and centred on a tinted band, matching iOS, a text story drawn as - * a caption under an empty grey rectangle reads as a picture that failed to - * load, which is the opposite of what the sender meant. */ @Composable fun TextStoryCard(text: String) { Box( - modifier = Modifier - .fillMaxWidth() - .height(240.dp) - .clip(RoundedCornerShape(12.dp)) - .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.10f)) - .testTag("story-text"), + modifier = + Modifier + .fillMaxWidth() + .height(240.dp) + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.10f)) + .testTag("story-text"), contentAlignment = Alignment.Center, ) { Text( @@ -691,15 +743,14 @@ fun TextStoryCard(text: String) { } } - /** * Write a story that is only words. - * - * A dialog rather than a screen: a text story is a line or two, and a - * full-screen composer for that is the reason people do not post them. */ @Composable -private fun TextStoryComposer(onDismiss: () -> Unit, onPost: (String) -> Unit) { +private fun TextStoryComposer( + onDismiss: () -> Unit, + onPost: (String) -> Unit, +) { var text by remember { mutableStateOf("") } AlertDialog( onDismissRequest = onDismiss, diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/StoryMedia.kt b/app/src/main/kotlin/io/flamenet/messenger/app/StoryMedia.kt index 2b1bf72..e019653 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/StoryMedia.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/StoryMedia.kt @@ -4,39 +4,31 @@ import android.content.Context import android.media.MediaMetadataRetriever import android.net.Uri import android.widget.VideoView -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.compose.ui.platform.testTag import androidx.compose.ui.viewinterop.AndroidView import java.io.File /** * Staging a picked story, image or video, from either entry point. - * - * Videos are validated and scrubbed, not transcoded: this platform has no - * built-in exporter the way AVFoundation is one, and pulling in a transcoding - * stack is a decision for later. So the caps are enforced honestly, a clip too - * long or too heavy is refused with the reason, and a clip that fits is - * remuxed to drop the container metadata before it goes out. See [MediaScrub]; - * "exactly as picked" used to include the capture location. */ object StoryMedia { - /** Duration in seconds, display dimensions, and the container rotation. */ private data class Meta( val seconds: Double, @@ -47,40 +39,53 @@ object StoryMedia { /** A story is a moment, not a film, same cap as iOS. */ const val MAX_DURATION_S = 30 + /** Under the relay's 10 MB attachment cap, with room for the seal. */ const val MAX_BYTES = 9_500_000 /** * Read the pick, validate it, and stage it on the model. - * Reports refusals through the model's own error surface. */ - fun stage(context: Context, uri: Uri, model: MessengerViewModel) { + fun stage( + context: Context, + uri: Uri, + model: MessengerViewModel, + ) { val mime = context.contentResolver.getType(uri) ?: "image/jpeg" if (mime.startsWith("video/")) { val retriever = MediaMetadataRetriever() - val meta = runCatching { - retriever.setDataSource(context, uri) - val durationMs = retriever - .extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION) - ?.toLongOrNull() ?: 0L - var w = retriever - .extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH) - ?.toIntOrNull() ?: 0 - var h = retriever - .extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT) - ?.toIntOrNull() ?: 0 - val rotation = retriever - .extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION) - ?.toIntOrNull() ?: 0 - // A portrait phone clip is stored landscape plus a rotation. - if (rotation == 90 || rotation == 270) { val t = w; w = h; h = t } - // The rotation is carried out of here now, not just used to - // swap the dimensions: rebuilding the container to strip its - // metadata drops the orientation with everything else, and a - // portrait story would play on its side. - Meta(durationMs / 1000.0, w, h, rotation) - }.getOrNull().also { runCatching { retriever.release() } } + val meta = + runCatching { + retriever.setDataSource(context, uri) + val durationMs = + retriever + .extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION) + ?.toLongOrNull() ?: 0L + var w = + retriever + .extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH) + ?.toIntOrNull() ?: 0 + var h = + retriever + .extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT) + ?.toIntOrNull() ?: 0 + val rotation = + retriever + .extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION) + ?.toIntOrNull() ?: 0 + // A portrait phone clip is stored landscape plus a rotation. + if (rotation == 90 || rotation == 270) { + val t = w + w = h + h = t + } + // The rotation is carried out of here now, not just used to + // swap the dimensions: rebuilding the container to strip its + // metadata drops the orientation with everything else, and a + // portrait story would play on its side. + Meta(durationMs / 1000.0, w, h, rotation) + }.getOrNull().also { runCatching { retriever.release() } } if (meta == null) { model.reportStoryProblem("That video couldn't be read.") @@ -89,21 +94,20 @@ object StoryMedia { val (seconds, w, h, rotation) = meta if (seconds > MAX_DURATION_S + 0.5) { model.reportStoryProblem( - "Stories are capped at $MAX_DURATION_S seconds, this one is " - + "${seconds.toInt()}. Trim it and try again.") + "Stories are capped at $MAX_DURATION_S seconds, this one is " + + "${seconds.toInt()}. Trim it and try again.", + ) return } - val picked = runCatching { - context.contentResolver.openInputStream(uri)?.use { it.readBytes() } - }.getOrNull() + val picked = + runCatching { + context.contentResolver.openInputStream(uri)?.use { it.readBytes() } + }.getOrNull() if (picked == null) { model.reportStoryProblem("That video couldn't be read.") return } - // Where it was shot does not go with it. See [MediaScrub]. - // A failure here refuses the post rather than falling back to the - // original: "we could not remove the location, so we sent it - // anyway" is the one outcome this must never produce. + // Where it was shot does not go with it. val bytes = MediaScrub.videoWithoutMetadata(picked, rotation, context.cacheDir) if (bytes == null) { model.reportStoryProblem("That video couldn't be prepared for sending.") @@ -111,27 +115,24 @@ object StoryMedia { } if (bytes.size > MAX_BYTES) { model.reportStoryProblem( - "That video is too large to send, the cap is about 9 MB. " - + "A shorter or lower-resolution clip will fit.") + "That video is too large to send, the cap is about 9 MB. " + + "A shorter or lower-resolution clip will fit.", + ) return } model.stageStory(bytes, mime, w, h, duration = seconds) return } - val picked = runCatching { - context.contentResolver.openInputStream(uri)?.use { it.readBytes() } - }.getOrNull() + val picked = + runCatching { + context.contentResolver.openInputStream(uri)?.use { it.readBytes() } + }.getOrNull() if (picked == null) { model.reportStoryProblem("That file couldn't be read.") return } - // Prepared, not posted as picked. A story photo was the one - // image path in the app that never went through [ImagePrep], so it was - // uploaded with its EXIF block intact, including, for anything shot on - // the phone, the GPS coordinates. Chat photos have always been - // re-encoded and were safe by consequence; a story, which is shown to - // every contact at once, was not. + // Prepared, not posted as picked. val prepared = ImagePrep.prepare(picked, MediaSettings(context).fullQuality) if (prepared == null) { model.reportStoryProblem("That picture couldn't be read.") @@ -141,7 +142,10 @@ object StoryMedia { } /** The first frame, for the composer's preview of a staged video. */ - fun posterFrame(bytes: ByteArray, context: Context): android.graphics.Bitmap? { + fun posterFrame( + bytes: ByteArray, + context: Context, + ): android.graphics.Bitmap? { val f = File.createTempFile("story-poster", ".mp4", context.cacheDir) return try { f.writeBytes(bytes) @@ -158,22 +162,22 @@ object StoryMedia { /** * Plays a decrypted story video, looping, with sound. - * - * Framework `VideoView` on purpose, playback of one local file needs no - * player library. The decrypted bytes land in the app's cache dir, which is - * the same exposure as a decoded story bitmap in memory plus the OS's own - * cache lifetime. */ @Composable -fun StoryVideoPlayer(bytes: ByteArray, cacheKey: String, modifier: Modifier = Modifier) { +fun StoryVideoPlayer( + bytes: ByteArray, + cacheKey: String, + modifier: Modifier = Modifier, +) { val context = androidx.compose.ui.platform.LocalContext.current val file = remember(cacheKey) { mutableStateOf(null) } LaunchedEffect(cacheKey) { - file.value = runCatching { - val f = File.createTempFile("story-$cacheKey", ".mp4", context.cacheDir) - f.writeBytes(bytes) - f - }.getOrNull() + file.value = + runCatching { + val f = File.createTempFile("story-$cacheKey", ".mp4", context.cacheDir) + f.writeBytes(bytes) + f + }.getOrNull() } val ready = file.value ?: return AndroidView( @@ -193,16 +197,6 @@ fun StoryVideoPlayer(bytes: ByteArray, cacheKey: String, modifier: Modifier = Mo /** * The consent gate in front of an embedded live stream (spec §6.13). - * - * Watching connects this device to the stream host. A live stream is - * continuous and hosted elsewhere, so unlike a link preview there is no - * sender-side trick that avoids it: the URL is hidden from the relay, but the - * connection is not hidden from whoever serves the stream, they learn the - * viewer's IP address and that they watched. - * - * So playback is an explicit tap with the host named, never autoplay. A story - * that started playing on its own would make that disclosure after the fact, - * which is not a disclosure. */ @Composable fun StoryStreamGate( @@ -233,7 +227,8 @@ fun StoryStreamGate( Text("Live stream", fontSize = 18.sp, fontWeight = FontWeight.SemiBold) Spacer(Modifier.height(8.dp)) Text( - io.flamenet.e2e.StreamDisclosure.text(host), + io.flamenet.e2e.StreamDisclosure + .text(host), fontSize = 12.sp, textAlign = androidx.compose.ui.text.style.TextAlign.Center, ) diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/StorySave.kt b/app/src/main/kotlin/io/flamenet/messenger/app/StorySave.kt index 9c6ddff..a5dec10 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/StorySave.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/StorySave.kt @@ -8,38 +8,40 @@ import android.provider.MediaStore /** * Writing one of your own stories out to the device gallery (ios#34). - * - * MediaStore, so no storage permission is asked for. An app writing its - * own entry through `MediaStore` needs no runtime grant on any version this app - * supports. Asking for `WRITE_EXTERNAL_STORAGE` to save one picture - * would request read access to everything the person has, to perform a write, - * a much larger ask than the feature. */ object StorySave { - /** * Save bytes to the gallery under Flamenet's own album. - * - * Returns the display name written, or null if the insert failed. */ - fun save(context: Context, bytes: ByteArray, mime: String, id: String): String? { + fun save( + context: Context, + bytes: ByteArray, + mime: String, + id: String, + ): String? { val isVideo = mime.startsWith("video/") val extension = if (isVideo) "mp4" else "jpg" val name = "flamenet-story-$id.$extension" - val collection = if (isVideo) { - MediaStore.Video.Media.EXTERNAL_CONTENT_URI - } else { - MediaStore.Images.Media.EXTERNAL_CONTENT_URI - } - val values = ContentValues().apply { - put(MediaStore.MediaColumns.DISPLAY_NAME, name) - put(MediaStore.MediaColumns.MIME_TYPE, mime) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - val root = if (isVideo) Environment.DIRECTORY_MOVIES - else Environment.DIRECTORY_PICTURES - put(MediaStore.MediaColumns.RELATIVE_PATH, "$root/Flamenet") + val collection = + if (isVideo) { + MediaStore.Video.Media.EXTERNAL_CONTENT_URI + } else { + MediaStore.Images.Media.EXTERNAL_CONTENT_URI + } + val values = + ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, name) + put(MediaStore.MediaColumns.MIME_TYPE, mime) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val root = + if (isVideo) { + Environment.DIRECTORY_MOVIES + } else { + Environment.DIRECTORY_PICTURES + } + put(MediaStore.MediaColumns.RELATIVE_PATH, "$root/Flamenet") + } } - } val uri = context.contentResolver.insert(collection, values) ?: return null return runCatching { context.contentResolver.openOutputStream(uri)?.use { it.write(bytes) } diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/Theme.kt b/app/src/main/kotlin/io/flamenet/messenger/app/Theme.kt index 36b4872..f0b0cd4 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/Theme.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/Theme.kt @@ -1,7 +1,6 @@ package io.flamenet.messenger.app import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.runtime.remember import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape @@ -11,6 +10,7 @@ import androidx.compose.material3.Typography import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.shadow @@ -21,63 +21,38 @@ import androidx.compose.ui.unit.sp /** * The Flamenet design system. - * - * Taken from the design kit, measured out of the Sketch file. This - * comment used to say the palette came from flamenetmessenger.com and quoted - * `--flame: #ff5a1f`, which had already been retired from the public pages, - * so the app was carrying a brand colour the brand no longer used, on the - * authority of a comment nobody rechecked. The values here are read from the - * kit's own symbols; see `Palette`. - * - * One palette, two apps. iOS carries the same values under the same - * names in `Sources/Theme/Theme.swift`. This file's comment used to claim the - * two were already matched, while iOS was indigo and this was ember, so the - * claim was simply false and nobody noticed because nothing checks it. - * Change a value here and change it there in the same commit. - * - * The look: a light cool-navy ground, white cards with a soft shadow and a - * generous corner radius, one confident accent, quiet neutrals, and space - * between things. Screen structure and every `testTag` are deliberately - * untouched, this is a skin over working screens. */ object Palette { - // The design kit's palette, measured out of the file, not sampled - // from a render and not approximated. The text colours below are the two - // that actually appear in its symbols: #090710 on 165 text layers and - // #505C69 on 164. The accent is the one it uses at 12pt as well as on - // buttons, so it is their link colour, not only their fill. - // - // These names used to say Ember, and held orange. The orange was the - // brand when this app was written and is not any more, the public pages - // carry none of it, so the names go with the colour rather than outliving - // it. A constant called `Ember` holding blue is how a palette drifts back. - val Accent = Color(0xFF2298FF) // buttons, bubbles, links + // The design kit's palette, measured out of the file, not sampled from a + // render and not approximated. + val Accent = Color(0xFF2298FF) // buttons, bubbles, links val AccentBright = Color(0xFF4FACFE) // the kit's lighter primary swatch val AccentSoft = Color(0x1A2298FF) - val Teal = Color(0xFF3BD5B2) // success and presence - val Pink = Color(0xFFED497C) // the kit's alert accent + val Teal = Color(0xFF3BD5B2) // success and presence + val Pink = Color(0xFFED497C) // the kit's alert accent // Neutrals, all from the kit. val Ink = Color(0xFF090710) - val Mist = Color(0xFFF9F9F9) // the page ground - val SurfaceAlt = Color(0xFFF3F3F3) // received bubbles, pills, bands + val Mist = Color(0xFFF9F9F9) // the page ground + val SurfaceAlt = Color(0xFFF3F3F3) // received bubbles, pills, bands val IncomingBubble = Color(0xFFF3F3F3) - // A blue-tinted grey rather than the flat #F3F3F3: an initials avatar - // needs to read as a circle against the surface behind it, and two greys a - // shade apart do not. + + // A blue-tinted grey rather than the flat #F3F3F3: an initials avatar needs + // to read as a circle against the surface behind it, and two greys a shade + // apart do not. val AvatarFill = Color(0xFFE8F1FB) val SecondaryText = Color(0xFF505C69) val Divider = Color(0x1A505C69) - // Semantic + // Semantic. val Success = Color(0xFF3BD5B2) val Warning = Color(0xFFB8860B) val Danger = Color(0xFFED497C) // Dark-mode grounds, these ARE the site's, since the site is dark. - val InkSurface = Color(0xFF121A2E) // --panel - val NightGround = Color(0xFF0A0E1A) // --bg + val InkSurface = Color(0xFF121A2E) // --panel + val NightGround = Color(0xFF0A0E1A) // --bg } /** Corner radii, matching `Theme.Radius` on iOS. */ @@ -89,14 +64,9 @@ object Radius { } /** The 4dp spacing scale, matching `Theme.Space` on iOS. */ + /** * Measurements taken from the design kit, in the kit's own terms. - * - * Insets, never fixed widths. The kit is drawn on a 375pt canvas and its - * numbers are absolute, a 327-wide row is 375 minus two 24pt margins. Copying - * 327 across would break the layout on every phone that is not an iPhone X. - * Everything here is an inset or an intrinsic size, with the width left to the - * layout. See `DESIGN.md`, which both apps read from. */ object Kit { val margin = 24.dp @@ -111,8 +81,10 @@ object Kit { val pillRadius = 25.dp val icon = 24.dp val presenceDot = 12.dp + /** Every bubble corner but the one that points at its author. */ val bubbleRadius = 16.dp + /** Not zero: a hard point reads as a rendering artefact at this size. */ val bubbleTail = 2.dp } @@ -126,117 +98,103 @@ object Space { val xxl = 32.dp } -private val FlamenetShapes = Shapes( - extraSmall = RoundedCornerShape(Radius.small), - small = RoundedCornerShape(Radius.small), - medium = RoundedCornerShape(Radius.medium), - large = RoundedCornerShape(Radius.large), - extraLarge = RoundedCornerShape(Radius.large), -) +private val FlamenetShapes = + Shapes( + extraSmall = RoundedCornerShape(Radius.small), + small = RoundedCornerShape(Radius.small), + medium = RoundedCornerShape(Radius.medium), + large = RoundedCornerShape(Radius.large), + extraLarge = RoundedCornerShape(Radius.large), + ) /** * The type scale. - * - * Sizes only, no `fontFamily`. Bundling a font would need the licence to - * cover redistribution, and the platform default already reads well at these - * sizes. If a face is chosen later it goes here and in the iOS `Theme.Font`. */ + /** * The design kit's type scale, applied globally. - * - * This is the single biggest reason the app did not look like the kit. - * Material 3's defaults are considerably larger than the kit's, and every - * screen inherits them, so a section heading rendered at 24sp where the kit - * says 16, and a body line at 16 where the kit says 14. Restyling individual - * screens against those defaults is pushing against the whole theme; setting - * the scale here fixes every screen at once, including ones nobody has - * touched yet. - * - * Names on the left are Material's; sizes on the right are the kit's. See - * `DESIGN.md`. H1 40, H2 32, H3 24, H4 20, Sub 16/14/12, Body 16/14, - * Description 12/10. */ -private val FlamenetTypography = Typography().let { base -> - base.copy( - headlineLarge = base.headlineLarge.copy(fontSize = 32.sp, fontWeight = FontWeight.Bold), - headlineMedium = base.headlineMedium.copy(fontSize = 24.sp, fontWeight = FontWeight.Bold), - headlineSmall = base.headlineSmall.copy(fontSize = 20.sp, fontWeight = FontWeight.Bold), - titleLarge = base.titleLarge.copy(fontSize = 20.sp, fontWeight = FontWeight.Bold), - // Sub 3, what a section label is, not a headline. - titleMedium = base.titleMedium.copy(fontSize = 16.sp, fontWeight = FontWeight.Medium), - titleSmall = base.titleSmall.copy(fontSize = 14.sp, fontWeight = FontWeight.Medium), - bodyLarge = base.bodyLarge.copy(fontSize = 16.sp), - bodyMedium = base.bodyMedium.copy(fontSize = 14.sp), - bodySmall = base.bodySmall.copy(fontSize = 12.sp), - labelLarge = base.labelLarge.copy(fontSize = 14.sp, fontWeight = FontWeight.Medium), - labelMedium = base.labelMedium.copy(fontSize = 12.sp), - // Description 2, the timestamp above a bubble. - labelSmall = base.labelSmall.copy(fontSize = 10.sp, letterSpacing = 0.sp, - fontWeight = FontWeight.Normal), - ) -} +private val FlamenetTypography = + Typography().let { base -> + base.copy( + headlineLarge = base.headlineLarge.copy(fontSize = 32.sp, fontWeight = FontWeight.Bold), + headlineMedium = base.headlineMedium.copy(fontSize = 24.sp, fontWeight = FontWeight.Bold), + headlineSmall = base.headlineSmall.copy(fontSize = 20.sp, fontWeight = FontWeight.Bold), + titleLarge = base.titleLarge.copy(fontSize = 20.sp, fontWeight = FontWeight.Bold), + // Sub 3, what a section label is, not a headline. + titleMedium = base.titleMedium.copy(fontSize = 16.sp, fontWeight = FontWeight.Medium), + titleSmall = base.titleSmall.copy(fontSize = 14.sp, fontWeight = FontWeight.Medium), + bodyLarge = base.bodyLarge.copy(fontSize = 16.sp), + bodyMedium = base.bodyMedium.copy(fontSize = 14.sp), + bodySmall = base.bodySmall.copy(fontSize = 12.sp), + labelLarge = base.labelLarge.copy(fontSize = 14.sp, fontWeight = FontWeight.Medium), + labelMedium = base.labelMedium.copy(fontSize = 12.sp), + // Description 2, the timestamp above a bubble. + labelSmall = + base.labelSmall.copy( + fontSize = 10.sp, + letterSpacing = 0.sp, + fontWeight = FontWeight.Normal, + ), + ) + } /** * The card: raised surface, generous radius, one soft shadow. - * - * A modifier rather than a component so it can wrap the existing sections - * without restructuring them. The look depends on every card sharing one - * depth, resist giving any single card its own elevation. */ -fun Modifier.flamenetCard( - padding: PaddingValues = PaddingValues(Space.lg), -): Modifier = this - .shadow(3.dp, RoundedCornerShape(Radius.medium), clip = false) - .clip(RoundedCornerShape(Radius.medium)) - .padding(padding) +fun Modifier.flamenetCard(padding: PaddingValues = PaddingValues(Space.lg)): Modifier = + this + .shadow(3.dp, RoundedCornerShape(Radius.medium), clip = false) + .clip(RoundedCornerShape(Radius.medium)) + .padding(padding) @Composable fun MessengerTheme(content: @Composable () -> Unit) { - // Read here rather than passed in, so every entry point picks it up, - // the app has more than one, and a theme applied at only some of them is - // the kind of half-wiring that looks fine until somebody opens a call - // screen from a notification. + // Read here rather than passed in, so every entry point picks it up, the + // app has more than one, and a theme applied at only some of them is the + // kind of half-wiring that looks fine until somebody opens a call screen + // from a notification. val context = androidx.compose.ui.platform.LocalContext.current val preference = remember { DisplaySettings(context).appearance } - val dark = when (preference) { - DisplaySettings.Appearance.LIGHT -> false - DisplaySettings.Appearance.DARK -> true - DisplaySettings.Appearance.SYSTEM -> isSystemInDarkTheme() - } + val dark = + when (preference) { + DisplaySettings.Appearance.LIGHT -> false + DisplaySettings.Appearance.DARK -> true + DisplaySettings.Appearance.SYSTEM -> isSystemInDarkTheme() + } MaterialTheme( - colorScheme = if (dark) { - darkColorScheme( - primary = Palette.AccentBright, - onPrimary = Color.White, - secondary = Palette.Accent, - background = Palette.NightGround, - surface = Palette.InkSurface, - surfaceVariant = Palette.InkSurface, - onBackground = Palette.Mist, - onSurface = Palette.Mist, - onSurfaceVariant = Palette.SecondaryText, - error = Palette.Danger, - outline = Palette.Divider, - ) - } else { - lightColorScheme( - // The kit's #2298FF on white is about 3.3:1, fine for a - // bold button label, not for small text. Anything small and - // blue must use a darker step; the two places that do are the - // section labels and the timestamps, and both are grey. - primary = Palette.Accent, - onPrimary = Color.White, - secondary = Palette.AccentBright, - background = Palette.Mist, - surface = Color.White, - surfaceVariant = Palette.SurfaceAlt, - onBackground = Palette.Ink, - onSurface = Palette.Ink, - onSurfaceVariant = Palette.SecondaryText, - error = Palette.Danger, - outline = Palette.Divider, - ) - }, + colorScheme = + if (dark) { + darkColorScheme( + primary = Palette.AccentBright, + onPrimary = Color.White, + secondary = Palette.Accent, + background = Palette.NightGround, + surface = Palette.InkSurface, + surfaceVariant = Palette.InkSurface, + onBackground = Palette.Mist, + onSurface = Palette.Mist, + onSurfaceVariant = Palette.SecondaryText, + error = Palette.Danger, + outline = Palette.Divider, + ) + } else { + lightColorScheme( + // The kit's #2298FF on white is about 3.3:1, fine for a bold + // button label, not for small text. + primary = Palette.Accent, + onPrimary = Color.White, + secondary = Palette.AccentBright, + background = Palette.Mist, + surface = Color.White, + surfaceVariant = Palette.SurfaceAlt, + onBackground = Palette.Ink, + onSurface = Palette.Ink, + onSurfaceVariant = Palette.SecondaryText, + error = Palette.Danger, + outline = Palette.Divider, + ) + }, shapes = FlamenetShapes, typography = FlamenetTypography, content = content, diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/VoiceNote.kt b/app/src/main/kotlin/io/flamenet/messenger/app/VoiceNote.kt index f0a959f..2071e86 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/VoiceNote.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/VoiceNote.kt @@ -6,20 +6,14 @@ import java.io.File /** * Recording and playing voice notes. - * - * Platform APIs only, `MediaRecorder` and `MediaPlayer`, because a voice note - * is an ordinary encrypted attachment once it is bytes, and everything below - * this file already handles that. Nothing here knows about the relay. - * - * AAC in an MP4 container (`audio/mp4`): playable by the other clients without - * a codec conversation, and small enough that a minute of speech is tens of - * kilobytes rather than megabytes, which matters when every byte is uploaded - * encrypted and stored by somebody paying for it. */ object VoiceNote { - /** What a finished recording produced. */ - data class Recorded(val bytes: ByteArray, val mime: String, val seconds: Double) { + data class Recorded( + val bytes: ByteArray, + val mime: String, + val seconds: Double, + ) { override fun equals(other: Any?): Boolean = other is Recorded && bytes.contentEquals(other.bytes) && mime == other.mime && seconds == other.seconds @@ -31,10 +25,6 @@ object VoiceNote { /** * A recording in progress. - * - * Holds a file the caller must dispose of. Both [stop] and [cancel] - * delete it; a path that does neither leaves plaintext audio in the cache - * directory, which is exactly the thing this product is not supposed to do. */ class Recording internal constructor( private val recorder: MediaRecorder, @@ -43,29 +33,22 @@ object VoiceNote { ) { /** * Stop, and return the bytes. - * - * `MediaRecorder.stop()` throws if it never captured a frame, - * a tap rather than a hold, or a permission that was granted but is - * being denied by a policy. The exception is `RuntimeException` with no - * useful message, and it reads as "recording is broken" when the truth - * is "there was nothing to record". Treated as an empty recording, - * because that is what it is. */ fun stop(): Recorded? { val elapsed = (System.currentTimeMillis() - startedAtMillis) / 1000.0 - val captured = try { - recorder.stop() - true - } catch (_: RuntimeException) { - false - } finally { - recorder.release() - } + val captured = + try { + recorder.stop() + true + } catch (_: RuntimeException) { + false + } finally { + recorder.release() + } val bytes = if (captured && file.exists()) file.readBytes() else ByteArray(0) file.delete() // Below this, the container holds a header and nothing worth - // sending. Better to drop it than to deliver a note that plays - // silence and looks like a delivery bug. + // sending. if (!captured || bytes.size < MIN_USEFUL_BYTES || elapsed < MIN_SECONDS) return null return Recorded(bytes, MIME, elapsed) } @@ -75,9 +58,7 @@ object VoiceNote { try { recorder.stop() } catch (_: RuntimeException) { - // Never started, or stopped with no frames. Nothing to salvage - // and nothing to report, this path is the user changing their - // mind, not a failure. + // Never started, or stopped with no frames. } finally { recorder.release() file.delete() @@ -93,25 +74,22 @@ object VoiceNote { /** * Start recording into the cache directory. - * * @throws IllegalStateException when the microphone is unavailable, most - * often because `RECORD_AUDIO` has not been granted. The caller asks for - * the permission first; this does not, because a library that raises - * dialogs is a library that raises them at the wrong moment. + * often because `RECORD_AUDIO` has not been granted. */ fun start(cacheDir: File): Recording { val file = File.createTempFile("voice-", ".m4a", cacheDir) - val recorder = MediaRecorder().apply { - setAudioSource(MediaRecorder.AudioSource.MIC) - setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) - setAudioEncoder(MediaRecorder.AudioEncoder.AAC) - // Speech, not music. Mono at 32 kbit/s is clearly intelligible and - // roughly a tenth the size of a stereo default nobody asked for. - setAudioChannels(1) - setAudioSamplingRate(44_100) - setAudioEncodingBitRate(32_000) - setOutputFile(file.absolutePath) - } + val recorder = + MediaRecorder().apply { + setAudioSource(MediaRecorder.AudioSource.MIC) + setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) + setAudioEncoder(MediaRecorder.AudioEncoder.AAC) + // Speech, not music. + setAudioChannels(1) + setAudioSamplingRate(44_100) + setAudioEncodingBitRate(32_000) + setOutputFile(file.absolutePath) + } try { recorder.prepare() recorder.start() @@ -125,33 +103,37 @@ object VoiceNote { /** * Play decrypted bytes. - * - * Writes them to a file first, because `MediaPlayer` cannot be handed a - * byte array. The file lives in the cache and is deleted when playback - * ends or is stopped, decrypted audio sitting on disk after the user has - * finished with it is a leak, however briefly. */ - class Player(private val cacheDir: File) { + class Player( + private val cacheDir: File, + ) { private var player: MediaPlayer? = null private var file: File? = null - fun play(bytes: ByteArray, onFinished: () -> Unit) { + fun play( + bytes: ByteArray, + onFinished: () -> Unit, + ) { stop() val target = File.createTempFile("play-", ".m4a", cacheDir).also { it.writeBytes(bytes) } file = target - player = MediaPlayer().apply { - setDataSource(target.absolutePath) - setOnCompletionListener { - stop() - onFinished() + player = + MediaPlayer().apply { + setDataSource(target.absolutePath) + setOnCompletionListener { + stop() + onFinished() + } + prepare() + start() } - prepare() - start() - } } fun stop() { - player?.let { runCatching { it.stop() }; it.release() } + player?.let { + runCatching { it.stop() } + it.release() + } player = null file?.delete() file = null diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/WalletChain.kt b/app/src/main/kotlin/io/flamenet/messenger/app/WalletChain.kt index d4f15de..aafd928 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/WalletChain.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/WalletChain.kt @@ -4,40 +4,34 @@ import android.content.Context /** * Which chain node the wallet reads from. - * - * Pointing at a node is a privacy decision, and the app says so rather - * than choosing quietly. Asking a public RPC endpoint what an address holds - * ties that address to this device's IP in somebody else's logs, every time - * the screen refreshes. Running your own node is the only answer that removes - * it. The default exists so the wallet works at all, not because it is private. - * - * https only. A balance read over plain http is one anybody on the path - * can rewrite, and a wallet showing a number an attacker chose is worse than a - * wallet showing nothing. A configured http endpoint is ignored rather than - * honoured, and the screen falls back to the default. */ object WalletChain { - const val DEFAULT_ENDPOINT = "https://api.mainnet-beta.solana.com" private const val PREFS = "wallet-chain" private const val ENDPOINT = "endpoint" - private fun prefs(context: Context) = - context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + private fun prefs(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) /** The operator's own node, or null when the default is in use. */ fun custom(context: Context): String? = - prefs(context).getString(ENDPOINT, null) + prefs(context) + .getString(ENDPOINT, null) ?.trim() ?.takeIf { it.isNotEmpty() && it.startsWith("https://") } fun endpoint(context: Context): String = custom(context) ?: DEFAULT_ENDPOINT - fun setEndpoint(context: Context, url: String?) { + fun setEndpoint( + context: Context, + url: String?, + ) { val cleaned = url?.trim().orEmpty() - if (cleaned.isEmpty()) prefs(context).edit().remove(ENDPOINT).apply() - else prefs(context).edit().putString(ENDPOINT, cleaned).apply() + if (cleaned.isEmpty()) { + prefs(context).edit().remove(ENDPOINT).apply() + } else { + prefs(context).edit().putString(ENDPOINT, cleaned).apply() + } } /** "Solana mainnet", or the host of whatever was configured. */ @@ -48,25 +42,21 @@ object WalletChain { /** * What a transfer costs, and what must stay behind. - * - * An account below the rent-exempt minimum is reclaimed by the - * network. Emptying a wallet to the last lamport does not leave it empty; - * it deletes it. Anything offering to send "everything" has to stop here, - * and the two numbers are named rather than buried in an expression so that - * the reason survives. */ const val FEE_LAMPORTS = 5_000L const val RENT_EXEMPT_LAMPORTS = 890_880L /** What can actually be sent from a balance, or zero. */ - fun spendable(lamports: Long): Long = - (lamports - FEE_LAMPORTS - RENT_EXEMPT_LAMPORTS).coerceAtLeast(0) + fun spendable(lamports: Long): Long = (lamports - FEE_LAMPORTS - RENT_EXEMPT_LAMPORTS).coerceAtLeast(0) /** Lamports as SOL, for display only. */ fun sol(lamports: Long): String { val whole = lamports / 1_000_000_000L val fraction = lamports % 1_000_000_000L - return "%d.%09d".format(whole, fraction).trimEnd('0').trimEnd('.') + return "%d.%09d" + .format(whole, fraction) + .trimEnd('0') + .trimEnd('.') .ifEmpty { "0" } } } diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/WalletScreen.kt b/app/src/main/kotlin/io/flamenet/messenger/app/WalletScreen.kt index 8ab42c9..ff49949 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/WalletScreen.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/WalletScreen.kt @@ -25,8 +25,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalClipboardManager -import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -38,17 +38,6 @@ import java.util.Locale /** * The wallet. - * - * A balance this app could not read is drawn as an em dash, never as - * zero. Showing 0.00 because a node did not answer tells somebody their money - * is gone, the single worst sentence this screen can say, and one it would say - * on every flaky connection. Null is a different fact from empty and is drawn - * as one. - * - * Both cards are always here once a wallet exists, empty or not. A - * screen that hides the balance until it has one, and hides the history until - * there is one, is a screen that looks broken exactly when somebody is looking - * for reassurance that nothing went wrong. */ @Composable fun ColumnScope.WalletScreen( @@ -60,12 +49,13 @@ fun ColumnScope.WalletScreen( val clipboard = LocalClipboardManager.current Column( - modifier = Modifier - .fillMaxWidth() - .weight(1f) - .verticalScroll(rememberScrollState()) - .padding(Kit.margin) - .testTag("wallet"), + modifier = + Modifier + .fillMaxWidth() + .weight(1f) + .verticalScroll(rememberScrollState()) + .padding(Kit.margin) + .testTag("wallet"), verticalArrangement = Arrangement.spacedBy(Space.md), ) { if (!wallet.exists) { @@ -73,10 +63,7 @@ fun ColumnScope.WalletScreen( return@Column } - // The phrase card sits ABOVE everything about the chain. Somebody - // who has just made a wallet has one job, write these words down, and - // putting a balance in front of it invites them to skip it and come - // back later, which for most people means never. + // The phrase card sits ABOVE everything about the chain. wallet.phrase?.let { phrase -> PhraseCard(phrase, model, clipboard) } BalanceCard(wallet, model, context) @@ -84,8 +71,12 @@ fun ColumnScope.WalletScreen( ActivityCard(wallet) wallet.problem?.let { - Text(it, color = MaterialTheme.colorScheme.error, fontSize = 13.sp, - modifier = Modifier.testTag("wallet-problem")) + Text( + it, + color = MaterialTheme.colorScheme.error, + fontSize = 13.sp, + modifier = Modifier.testTag("wallet-problem"), + ) } if (wallet.phrase == null) { @@ -105,14 +96,12 @@ private fun ColumnScope.NoWallet(model: MessengerViewModel) { Card { Text("No wallet yet", fontWeight = FontWeight.Medium, fontSize = 16.sp) Spacer(Modifier.height(Space.xs)) - // Says what a wallet here IS before offering to make one. The two - // facts that surprise people later are that it has nothing to do with - // their Flamenet account, and that the phrase is the only backup. + // Says what a wallet here IS before offering to make one. Text( - "A wallet on this phone, with keys only you hold. It is not connected " - + "to your Flamenet account, nobody can work out one from the other, " - + "and the recovery phrase is the only way back if this phone is " - + "erased or lost.", + "A wallet on this phone, with keys only you hold. It is not connected " + + "to your Flamenet account, nobody can work out one from the other, " + + "and the recovery phrase is the only way back if this phone is " + + "erased or lost.", fontSize = 13.sp, color = Palette.SecondaryText, ) @@ -137,7 +126,10 @@ private fun ColumnScope.NoWallet(model: MessengerViewModel) { Spacer(Modifier.height(Space.sm)) KitSecondaryButton( text = "Restore from a phrase", - onClick = { model.importWallet(phrase.trim()); phrase = "" }, + onClick = { + model.importWallet(phrase.trim()) + phrase = "" + }, modifier = Modifier.fillMaxWidth(), tag = "import-wallet", ) @@ -145,11 +137,7 @@ private fun ColumnScope.NoWallet(model: MessengerViewModel) { } /** - * Shown once, and the screen says what that means. The words are the - * whole wallet: anybody who reads them owns it, and anybody who loses them - * loses it. Both halves have to be said at the same moment, because saying only - * the first makes people hide it and saying only the second makes them - * screenshot it. + * Shown once, and the screen says what that means. */ @Composable private fun ColumnScope.PhraseCard( @@ -161,10 +149,10 @@ private fun ColumnScope.PhraseCard( Text("Your recovery phrase", fontWeight = FontWeight.Medium, fontSize = 16.sp) Spacer(Modifier.height(Space.xs)) Text( - "Write these words down on paper, in order, and keep them somewhere " - + "safe. Anybody who has them can spend from this wallet. If this " - + "phone is erased or lost and you do not have them, the wallet is " - + "gone, nobody can recover it for you, including us.", + "Write these words down on paper, in order, and keep them somewhere " + + "safe. Anybody who has them can spend from this wallet. If this " + + "phone is erased or lost and you do not have them, the wallet is " + + "gone, nobody can recover it for you, including us.", fontSize = 13.sp, color = Palette.SecondaryText, ) @@ -173,25 +161,24 @@ private fun ColumnScope.PhraseCard( phrase, fontSize = 15.sp, fontWeight = FontWeight.Medium, - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(Radius.small)) - // `Mist`, not `SurfaceAlt`: the card is already SurfaceAlt, - // and two identical greys would leave the words looking like - // body text rather than the one thing on this screen worth - // copying onto paper. - .background(Palette.Mist) - .padding(Space.sm) - .testTag("wallet-phrase"), + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(Radius.small)) + // `Mist`, not `SurfaceAlt`: the card is already SurfaceAlt, and + // two identical greys would leave the words looking like body + // text rather than the one thing on this screen worth copying + // onto paper. + .background(Palette.Mist) + .padding(Space.sm) + .testTag("wallet-phrase"), ) Spacer(Modifier.height(Space.sm)) Row(horizontalArrangement = Arrangement.spacedBy(Space.sm)) { KitSecondaryButton( text = "Copy", // Copying puts the phrase on a clipboard every other app can - // read. Offered because people will otherwise photograph the - // screen, which is worse and permanent, but it is the second - // option, not the first, and paper is what the text asks for. + // read. onClick = { clipboard.setText(AnnotatedString(phrase)) }, modifier = Modifier.weight(1f), tag = "copy-phrase", @@ -223,9 +210,7 @@ private fun ColumnScope.BalanceCard( } Spacer(Modifier.height(Space.xs)) Text( - // An em dash, never "0.00". Null means this app could not read - // the balance; zero means the account is empty. Drawing the first - // as the second tells somebody their money is gone. + // An em dash, never "0.00". wallet.lamports?.let { "${WalletChain.sol(it)} SOL" } ?: ", ", fontSize = 28.sp, fontWeight = FontWeight.Medium, @@ -233,8 +218,8 @@ private fun ColumnScope.BalanceCard( ) if (wallet.lamports == null && !wallet.busy) { Text( - "Not read yet. Nothing has been lost, this is what the app knows, " - + "not what the wallet holds.", + "Not read yet. Nothing has been lost, this is what the app knows, " + + "not what the wallet holds.", fontSize = 12.sp, color = Palette.SecondaryText, modifier = Modifier.testTag("balance-unknown"), @@ -265,13 +250,10 @@ private fun ColumnScope.ReceiveCard( modifier = Modifier.fillMaxWidth().testTag("wallet-address"), ) Spacer(Modifier.height(Space.xs)) - // Says the address changes BEFORE somebody saves the old one - // somewhere. A fresh address per payment is the one linkability a - // wallet can prevent, and an address that quietly changed would look - // like a bug to anybody who had written it down. + // Says the address changes BEFORE somebody saves the old one somewhere. Text( - "A new address each time you ask, so payments to you are not all " - + "tied together on a public ledger. Older addresses keep working.", + "A new address each time you ask, so payments to you are not all " + + "tied together on a public ledger. Older addresses keep working.", fontSize = 12.sp, color = Palette.SecondaryText, ) @@ -301,17 +283,27 @@ private fun ColumnScope.ActivityCard(wallet: MessengerViewModel.WalletState) { val history = wallet.history when { // Same rule as the balance: unknown is not the same as none. - history == null -> Text( - "Not read yet.", - fontSize = 13.sp, color = Palette.SecondaryText, - modifier = Modifier.testTag("activity-unknown"), - ) - history.isEmpty() -> Text( - "Nothing yet.", - fontSize = 13.sp, color = Palette.SecondaryText, - modifier = Modifier.testTag("activity-empty"), - ) - else -> history.forEach { entry -> ActivityRow(entry) } + history == null -> { + Text( + "Not read yet.", + fontSize = 13.sp, + color = Palette.SecondaryText, + modifier = Modifier.testTag("activity-unknown"), + ) + } + + history.isEmpty() -> { + Text( + "Nothing yet.", + fontSize = 13.sp, + color = Palette.SecondaryText, + modifier = Modifier.testTag("activity-empty"), + ) + } + + else -> { + history.forEach { entry -> ActivityRow(entry) } + } } } } @@ -331,8 +323,6 @@ private fun ActivityRow(entry: io.flamenet.client.ChainRpc.Entry) { ) Text( // "Just now" for a transaction the node has not dated yet. - // It has it and has not assigned a block time; showing 1970 - // would be the epoch leaking onto somebody's screen. entry.blockTimeSeconds?.let { SimpleDateFormat("d MMM HH:mm", Locale.getDefault()) .format(Date(it * 1000)) @@ -341,11 +331,14 @@ private fun ActivityRow(entry: io.flamenet.client.ChainRpc.Entry) { color = Palette.SecondaryText, ) } - // A failed transaction says so. Not showing it would have somebody - // believe a payment went through. + // A failed transaction says so. if (entry.failed) { - Text("Failed", fontSize = 12.sp, color = Palette.Pink, - modifier = Modifier.testTag("tx-failed")) + Text( + "Failed", + fontSize = 12.sp, + color = Palette.Pink, + modifier = Modifier.testTag("tx-failed"), + ) } } } @@ -353,11 +346,12 @@ private fun ActivityRow(entry: io.flamenet.client.ChainRpc.Entry) { @Composable private fun ColumnScope.Card(content: @Composable ColumnScope.() -> Unit) { Column( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(Radius.medium)) - .background(Palette.SurfaceAlt) - .padding(Space.md), + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(Radius.medium)) + .background(Palette.SurfaceAlt) + .padding(Space.md), content = content, ) } diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/AppLockBackoffTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/AppLockBackoffTest.kt index 472452a..287949e 100644 --- a/app/src/test/kotlin/io/flamenet/messenger/app/AppLockBackoffTest.kt +++ b/app/src/test/kotlin/io/flamenet/messenger/app/AppLockBackoffTest.kt @@ -6,35 +6,12 @@ import kotlin.test.assertTrue /** * Slowing down somebody who keeps trying (ios#41, Android half). - * - * Android had the lock and none of the back-off. iOS has counted failed - * unlocks and imposed a growing wait since ios#41; here every attempt was free, - * so somebody holding an unlocked phone could work through PINs as fast as the - * prompt would reappear. Two platforms disagreeing about a security guarantee is - * how one of them ends up being the one that leaks. - * - * What this defends and what it does not. It slows a person holding the - * phone. It is not a defence of the data, the vault and the ratchet keys are - * protected by the Android keystore, exactly as `AppLock`'s own note says, and - * no amount of back-off changes that. - * - * The two ends of the schedule are what matter, and both are tested: an - * ordinary slip costs nothing, and the wait has a ceiling. - * - * The schedule is a pure function, so it is tested without a `Context`. The - * parts that need SharedPreferences are exercised on device in - * `CrudSurfaceTest`; putting the arithmetic behind an instrumented test would - * have meant the rule nobody could check quickly was the rule that decides - * whether somebody is locked out of their own messages. */ class AppLockBackoffTest { - - // ---- The schedule --------------------------------------------------- + // ---- The schedule ---------------------------------------------------. /** - * Three, not one. A mistyped PIN or a fingerprint that did not read is - * ordinary, and making the first slip cost a wait would punish the owner far - * more often than anybody else. + * Three, not one. */ @Test fun `an ordinary slip costs nothing`() { @@ -52,10 +29,7 @@ class AppLockBackoffTest { } /** - * The ceiling is the important half. An unbounded back-off locks - * somebody out of their own messages permanently, which is a worse outcome - * than the one it defends against: there is no reset, no support desk, and - * the data is on their own phone. + * The ceiling is the important half. */ @Test fun `the wait is capped`() { @@ -65,14 +39,9 @@ class AppLockBackoffTest { } /** - * The overflow the Swift version cannot have and Kotlin can. iOS - * computes `30 * pow(2, steps)` in a Double, which saturates to infinity and - * still caps correctly. Shifting a Long does not: `30L shl 60` wraps to a - * negative number, and `minOf(negative, cap)` returns the negative, a - * "wait" in the past, which reads as no wait at all and quietly removes the - * whole feature at exactly the point somebody is grinding attempts. - * - * The steps are clamped before the shift. This is the test that says so. + * The overflow the Swift version cannot have and Kotlin can. iOS computes + * `30 * pow(2, steps)` in a Double, which saturates to infinity and still + * caps correctly. */ @Test fun `an absurd failure count still returns the ceiling, not a negative wait`() { @@ -89,17 +58,19 @@ class AppLockBackoffTest { var previous = 0L for (failures in 0..40) { val wait = AppLock.delayMillis(failures) - assertTrue(wait >= previous, - "delayMillis($failures) = $wait dropped below $previous") + assertTrue( + wait >= previous, + "delayMillis($failures) = $wait dropped below $previous", + ) previous = wait } } - // ---- How it is said ------------------------------------------------- + // ---- How it is said -------------------------------------------------. /** - * A lock screen that says "try again in 240 seconds" is one people read - * as broken. The unit follows the size of the number. + * A lock screen that says "try again in 240 seconds" is one people read as + * broken. */ @Test fun `the wait is said in whichever unit reads naturally`() { @@ -123,11 +94,6 @@ class AppLockBackoffTest { /** * The wording rule, lifted out so it can be checked without a `Context`. - * - * Kept byte-identical to `AppLock.waitMessage` on purpose, if the two - * drift, this test is asserting about a string nobody ever sees. It is a - * copy because a `Context` is the only thing standing between the real one - * and a plain JVM test, and the arithmetic is worth more than the purity. */ private fun sayWait(seconds: Int): String = if (seconds >= 60) { diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/AttachmentPurgeTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/AttachmentPurgeTest.kt index 7c30432..67cf4d7 100644 --- a/app/src/test/kotlin/io/flamenet/messenger/app/AttachmentPurgeTest.kt +++ b/app/src/test/kotlin/io/flamenet/messenger/app/AttachmentPurgeTest.kt @@ -5,6 +5,11 @@ import io.flamenet.client.RelayClient import io.flamenet.e2e.Attachment import io.flamenet.e2e.DeviceIdentity import io.flamenet.e2e.Vault +import okhttp3.mockwebserver.MockWebServer +import org.json.JSONObject +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config import java.io.File import javax.crypto.KeyGenerator import javax.crypto.SecretKey @@ -14,47 +19,51 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue -import okhttp3.mockwebserver.MockWebServer -import org.json.JSONObject -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config /** * Cached plaintext goes when nothing refers to it any more. - * - * `attachmentBytes` writes DECRYPTED bytes and nothing removed them, so - * a disappearing message expired, the bubble went, and the photo stayed on disk - * indefinitely, the exact failure the feature exists to prevent (android#11, - * spec §6.11). - * - * The dangerous direction here is the opposite of the bug. A sweep that - * deletes too much loses somebody's photo, which is worse than the leak. So - * most of these are about what must SURVIVE. */ @RunWith(RobolectricTestRunner::class) @Config(sdk = [34]) class AttachmentPurgeTest { - private lateinit var server: MockWebServer private lateinit var cache: File @BeforeTest fun start() { server = MockWebServer().also { it.start() } - cache = File.createTempFile("cache", "").let { it.delete(); it.mkdirs(); it } + cache = + File.createTempFile("cache", "").let { + it.delete() + it.mkdirs() + it + } + } + + @AfterTest fun stop() { + server.shutdown() + cache.deleteRecursively() } - @AfterTest fun stop() { server.shutdown(); cache.deleteRecursively() } private class MapStorage : Vault.Storage { private val records = mutableMapOf() + override fun read(name: String) = records[name] - override fun write(name: String, bytes: ByteArray) { records[name] = bytes } - override fun delete(name: String) { records.remove(name) } + + override fun write( + name: String, + bytes: ByteArray, + ) { + records[name] = bytes + } + + override fun delete(name: String) { + records.remove(name) + } + override fun names() = records.keys.toList() } - private fun key(): SecretKey = - KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() + private fun key(): SecretKey = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() private lateinit var vault: Vault @@ -62,25 +71,39 @@ class AttachmentPurgeTest { val identity = DeviceIdentity.create() vault = Vault(MapStorage(), key()).also { it.saveIdentity(identity) } val base = server.url("/").toString() - return Session("alice", 1, identity, vault, + return Session( + "alice", + 1, + identity, + vault, RelayClient(base, "alice", "app pass word"), - MessengerApi(base, "alice", "app pass word")) + MessengerApi(base, "alice", "app pass word"), + ) } - private fun ref(id: String) = Attachment.Ref( - kind = Attachment.IMAGE, id = id, - key = ByteArray(32) { 3 }, digest = ByteArray(32) { 4 }, - bytes = 9, mime = "image/jpeg", width = 1, height = 1, - ) + private fun ref(id: String) = + Attachment.Ref( + kind = Attachment.IMAGE, + id = id, + key = ByteArray(32) { 3 }, + digest = ByteArray(32) { 4 }, + bytes = 9, + mime = "image/jpeg", + width = 1, + height = 1, + ) /** A cache file as `attachmentBytes` would have written it, aged past the grace. */ - private fun cached(id: String, ageMillis: Long = 10 * 60_000L): File = + private fun cached( + id: String, + ageMillis: Long = 10 * 60_000L, + ): File = File(cache, "att-" + id.replace('/', '_')).apply { writeBytes("decrypted".toByteArray()) setLastModified(System.currentTimeMillis() - ageMillis) } - // ---- The bug ---------------------------------------------------------- + // ---- The bug ----------------------------------------------------------. @Test fun `an expired message's cached plaintext is deleted`() { @@ -92,13 +115,18 @@ class AttachmentPurgeTest { assertFalse(orphan.exists(), "the plaintext of an expired message is still on disk") } - // ---- What must survive ------------------------------------------------ + // ---- What must survive ------------------------------------------------. @Test fun `an attachment a live message still refers to survives`() { val s = session() - vault.append("user:2", JSONObject().put("at", 1L).put("text", "") - .put("att", ref("still-shown").toJson())) + vault.append( + "user:2", + JSONObject() + .put("at", 1L) + .put("text", "") + .put("att", ref("still-shown").toJson()), + ) val live = cached("still-shown") assertEquals(0, s.purgeOrphanedAttachments(cache)) @@ -113,9 +141,20 @@ class AttachmentPurgeTest { @Test fun `an archived story's attachment survives, long after it expired`() { val s = session() - vault.saveStoryArchive(listOf(Vault.Story( - id = "s1", authorId = 1L, mine = true, ref = ref("archived"), - caption = "", postedAt = 0L, seen = true, expiresAt = 1.0))) + vault.saveStoryArchive( + listOf( + Vault.Story( + id = "s1", + authorId = 1L, + mine = true, + ref = ref("archived"), + caption = "", + postedAt = 0L, + seen = true, + expiresAt = 1.0, + ), + ), + ) val archived = cached("archived") assertEquals(0, s.purgeOrphanedAttachments(cache)) @@ -128,33 +167,34 @@ class AttachmentPurgeTest { val s = session() vault.saveAvatar(7L, ref("their-face")) vault.saveMyAvatar(ref("my-face")) - val a = cached("their-face"); val b = cached("my-face") + val a = cached("their-face") + val b = cached("my-face") assertEquals(0, s.purgeOrphanedAttachments(cache)) assertTrue(a.exists() && b.exists(), "an avatar was deleted as an orphan") } /** - * An id containing a slash, which is the reason the transformation - * exists. `attachmentBytes` writes the file as `att- _>`, so - * membership has to be tested against the SAME mangling. Compare raw ids to - * mangled filenames and nothing matches, every attachment with a slash in - * its id is deleted while still on screen. - * - * Added after a mutation test: removing the transformation broke nothing, - * because every other id here is slash-free. The guard was untested by the - * suite that was meant to be guarding it. + * An id containing a slash, which is the reason the transformation exists. */ @Test fun `an id containing a slash is matched against its mangled filename`() { val s = session() - vault.append("user:2", JSONObject().put("at", 1L).put("text", "") - .put("att", ref("blobs/2026/09/live-one").toJson())) + vault.append( + "user:2", + JSONObject() + .put("at", 1L) + .put("text", "") + .put("att", ref("blobs/2026/09/live-one").toJson()), + ) val live = cached("blobs/2026/09/live-one") assertEquals("att-blobs_2026_09_live-one", live.name) - assertEquals(0, s.purgeOrphanedAttachments(cache), - "an attachment whose id contains a slash was treated as an orphan") + assertEquals( + 0, + s.purgeOrphanedAttachments(cache), + "an attachment whose id contains a slash was treated as an orphan", + ) assertTrue(live.exists()) } @@ -180,12 +220,10 @@ class AttachmentPurgeTest { assertTrue(other.exists(), "a file that is not an attachment cache entry was deleted") } - // ---- Failing closed --------------------------------------------------- + // ---- Failing closed ---------------------------------------------------. /** - * If the in-use set cannot be built, NOTHING is deleted. A partial - * answer to "what is still in use" is the one input that turns this from a - * cleanup into data loss. + * If the in-use set cannot be built, NOTHING is deleted. */ @Test fun `an unreadable vault record stops the sweep entirely`() { @@ -193,20 +231,26 @@ class AttachmentPurgeTest { val orphan = cached("would-have-gone") // A record the vault cannot open at all. vault.append("user:9", JSONObject().put("at", 1L)) - val storage = Vault::class.java.getDeclaredField("storage") - .apply { isAccessible = true }.get(vault) as Vault.Storage + val storage = + Vault::class.java + .getDeclaredField("storage") + .apply { isAccessible = true } + .get(vault) as Vault.Storage storage.write("history/user:9", ByteArray(30) { it.toByte() }) - assertEquals(0, s.purgeOrphanedAttachments(cache), - "the sweep ran on a partial view of what is in use") + assertEquals( + 0, + s.purgeOrphanedAttachments(cache), + "the sweep ran on a partial view of what is in use", + ) assertTrue(orphan.exists()) } - // ---- The throttle ----------------------------------------------------- + // ---- The throttle -----------------------------------------------------. /** - * Building the in-use set reads every vault record, and this is called - * from the poll loop, so a second call moments later does nothing. + * Building the in-use set reads every vault record, and this is called from + * the poll loop, so a second call moments later does nothing. */ @Test fun `a second sweep moments later does not re-read the vault`() { @@ -216,7 +260,9 @@ class AttachmentPurgeTest { cached("second") assertEquals(0, s.purgeOrphanedAttachments(cache), "the throttle did not hold") // ...and it does run again once the interval has passed. - assertEquals(1, s.purgeOrphanedAttachments( - cache, now = System.currentTimeMillis() + 11 * 60_000L)) + assertEquals( + 1, + s.purgeOrphanedAttachments(cache, now = System.currentTimeMillis() + 11 * 60_000L), + ) } } diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/ChangedKeyIsExplainedTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/ChangedKeyIsExplainedTest.kt index 5464053..dc61d3e 100644 --- a/app/src/test/kotlin/io/flamenet/messenger/app/ChangedKeyIsExplainedTest.kt +++ b/app/src/test/kotlin/io/flamenet/messenger/app/ChangedKeyIsExplainedTest.kt @@ -6,25 +6,8 @@ import kotlin.test.assertTrue /** * A refused-because-the-key-changed failure has to say so (android#20). - * - * Reported from a real session: "the calls wouldn't connect because the - * keys were changed. it wasn't obvious why the call failed." It was not - * obvious because nothing said it. The call's signalling lambda was - * `runCatching { … }` with no `onFailure`, so `IdentityChangedException`, the - * send path refusing to encrypt to an unreviewed identity, which is TOFU - * working, vanished. The offer never left and the screen sat in DIALLING. - * - * And the fix is on a screen the caller is not looking at. Accepting a - * changed key is a banner in the CHAT; somebody calling from the Calls tab - * never sees it. So the message has to name the conversation, not just the - * problem. - * - * iOS has said "Their security code changed" on this path since it was - * built. This is a parity gap, not a new idea, which is why the assertion is - * about the substance of the sentence rather than its exact wording. */ class ChangedKeyIsExplainedTest { - private val message = describeFailure(IdentityChangedException(7L)) @Test @@ -37,10 +20,7 @@ class ChangedKeyIsExplainedTest { } /** - * The actionable half. A refusal somebody cannot act on is the same - * as no explanation: they retry the same thing and are refused again. This - * file's neighbours in `describe()` all say what to DO, and that rule is - * written down there, this one has to keep it. + * The actionable half. */ @Test fun `it says where to go and what to do`() { diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/DestructiveLabelsTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/DestructiveLabelsTest.kt index a6ca122..94d2427 100644 --- a/app/src/test/kotlin/io/flamenet/messenger/app/DestructiveLabelsTest.kt +++ b/app/src/test/kotlin/io/flamenet/messenger/app/DestructiveLabelsTest.kt @@ -6,29 +6,11 @@ import kotlin.test.assertTrue /** * A destructive button must not claim more than it does (android#18). - * - * "Unsend" did not unsend. The message menu offered it on your own - * messages and called `deleteMessage`, which reaches `vault.deleteLine` and - * stops there. No envelope leaves the phone; the recipient's copy stays exactly - * where it is. Somebody who sent to the wrong person tapped it, watched the - * bubble vanish, and had no way to find out. - * - * Worse than an overclaiming page, which is why this is a test and not - * a one-line fix left to stay fixed. A page that overclaims can be read - * sceptically. A button that reports success cannot: the feedback IS the claim, - * and it arrives at the exact moment the person is most relieved to believe it. - * - * A source scan, and coarse on purpose. It cannot prove every label is honest. - * It proves this particular word cannot come back on a local-only action - * without somebody deleting a test that explains why, and it is a plain JVM - * test, so it costs no emulator. */ class DestructiveLabelsTest { - private fun uiSources(): List { // From `app/build/classes/...` back to the module root, whatever the - // working directory happens to be. Resolving against a relative path - // is how this kind of test silently scans nothing. + // working directory happens to be. var dir = File(System.getProperty("user.dir")) while (!File(dir, "app/src/main/kotlin").isDirectory && dir.parentFile != null) { dir = dir.parentFile @@ -38,24 +20,16 @@ class DestructiveLabelsTest { } /** - * Comments are stripped, and that is required rather than tidy. The - * comment explaining this bug necessarily quotes the word it is about, and - * a guard that flags its own explanation is a guard somebody deletes. + * Comments are stripped, and that is required rather than tidy. */ private fun codeLines(body: String): List = - body.lines() + body + .lines() .filterNot { it.trimStart().startsWith("//") || it.trimStart().startsWith("*") } .map { it.substringBefore("//") } /** - * The word promises the recipient's copy is gone. Nothing in this app - * can do that yet: it would take a signed control envelope naming the `mid`, - * which the recipient acts on, and even then it is a *request*, so whatever - * the button eventually says must not promise more than "we asked them to - * remove it." - * - * When that lands, this test is the thing to update, deliberately, in the - * same change. + * The word promises the recipient's copy is gone. */ @Test fun `no button says Unsend`() { @@ -75,9 +49,7 @@ class DestructiveLabelsTest { } /** - * The walk is the part most likely to rot. A scan that finds no files - * passes every assertion in it, so this pins that it is actually reading - * the screen the bug was on. + * The walk is the part most likely to rot. */ @Test fun `the scan actually reaches the chat screen`() { diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/DisplaySettingsTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/DisplaySettingsTest.kt index e209c34..273b474 100644 --- a/app/src/test/kotlin/io/flamenet/messenger/app/DisplaySettingsTest.kt +++ b/app/src/test/kotlin/io/flamenet/messenger/app/DisplaySettingsTest.kt @@ -5,21 +5,11 @@ import kotlin.test.assertEquals /** * The display preferences Android was missing. - * - * Only the pure decisions are here. Whether a switch is wired to the dot, - * the timestamp and the theme is not something a JVM test can see, that is - * what the instrumented suite and a person looking at the screen are for. What - * IS worth pinning is the arithmetic, because it has a default that must not - * move and a clamp that must not be skipped. */ class DisplaySettingsTest { - /** - * 16sp with the setting off, because that is what the kit specifies - * for a message body. A "larger type" switch that quietly restyled every - * message when it was turned OFF would be a worse bug than the absence it - * fixes, and the number is easy to get wrong because it lives in one file - * and is drawn in another. + * 16sp with the setting off, because that is what the kit specifies for a + * message body. */ @Test fun `message text is the kit size until larger type is asked for`() { @@ -28,16 +18,17 @@ class DisplaySettingsTest { } /** - * An unrecognised stored value reads as `system`, the state somebody - * who never touched this has. A value from a future build, or a corrupted - * preference, must not leave the app in no theme at all. + * An unrecognised stored value reads as `system`, the state somebody who + * never touched this has. */ @Test fun `an unknown appearance falls back to matching the phone`() { assertEquals(DisplaySettings.Appearance.SYSTEM, DisplaySettings.Appearance.from(null)) assertEquals(DisplaySettings.Appearance.SYSTEM, DisplaySettings.Appearance.from("")) - assertEquals(DisplaySettings.Appearance.SYSTEM, - DisplaySettings.Appearance.from("solarized-from-a-later-build")) + assertEquals( + DisplaySettings.Appearance.SYSTEM, + DisplaySettings.Appearance.from("solarized-from-a-later-build"), + ) } @Test diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/EditForwardPinWireTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/EditForwardPinWireTest.kt index cb95b76..edefeea 100644 --- a/app/src/test/kotlin/io/flamenet/messenger/app/EditForwardPinWireTest.kt +++ b/app/src/test/kotlin/io/flamenet/messenger/app/EditForwardPinWireTest.kt @@ -9,24 +9,11 @@ import kotlin.test.assertTrue /** * What the three new controls actually put on the wire (§6.15/§6.16/§6.19). - * - * The privacy rule for a forward is the one worth testing, because it is - * the one a "helpful" change would break. §6.16 says a forward carries the - * words and nothing about where they came from. The obvious improvement, - * carrying a reference so the reader can see the original, would republish a - * private message's author to somebody who was never in that conversation. So - * the absence of that field is asserted, not just the presence of the flag. - * - * These check the ENVELOPE. The `Session` methods that build and apply them - * need a vault and a keystore, so their behaviour is covered on device; what is - * checkable here is the shape that crosses to the other app, which is where an - * interop bug would live. */ class EditForwardPinWireTest { - private fun parse(json: String): Content = Content.parse(json)!! - // ---- Forward (§6.16) ------------------------------------------------- + // ---- Forward (§6.16) -------------------------------------------------. /** * The point of the feature: forwarded, and no trail back. @@ -46,9 +33,7 @@ class EditForwardPinWireTest { } /** - * A forward gets a NEW id, not the original's. Reusing it would make the - * copy a target for edits and reactions aimed at the original, one of - * which would silently rewrite the other. + * A forward gets a NEW id, not the original's. */ @Test fun `a forward is a new message, not the old one re-sent`() { @@ -60,16 +45,21 @@ class EditForwardPinWireTest { /** A forward into a group carries the gid, and still no origin. */ @Test fun `a group forward carries the group and still no origin`() { - val sent = parse( - Content.groupText("passed along", groupId = "gid-1", - messageId = "mid-new", forwarded = true), - ) + val sent = + parse( + Content.groupText( + "passed along", + groupId = "gid-1", + messageId = "mid-new", + forwarded = true, + ), + ) assertEquals("gid-1", sent.groupId) assertTrue(sent.isForwarded) assertNull(sent.about, "a group forward is carrying a reference to its source") } - // ---- Edit (§6.15) ---------------------------------------------------- + // ---- Edit (§6.15) ----------------------------------------------------. @Test fun `an edit names the message it replaces and carries the new wording`() { @@ -88,12 +78,10 @@ class EditForwardPinWireTest { assertEquals("mid-1", sent.editOf) } - // ---- Pin (§6.19) ----------------------------------------------------- + // ---- Pin (§6.19) -----------------------------------------------------. /** - * An id and never a copy. A copy would go stale the moment the message - * was edited and would outlive it being deleted, a banner quoting words - * that are no longer in the conversation. + * An id and never a copy. */ @Test fun `a pin names an id and carries no copy of the message`() { @@ -118,12 +106,10 @@ class EditForwardPinWireTest { assertEquals("mid-1", sent.pins) } - // ---- The three do not collide ---------------------------------------- + // ---- The three do not collide ----------------------------------------. /** - * Each envelope sets exactly one of the three fields. A message that was - * both an edit and a pin would be applied twice by a receiver that checks - * them in sequence, and the order of those checks is not part of the spec. + * Each envelope sets exactly one of the three fields. */ @Test fun `each control sets exactly one of the three fields`() { diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/EmptyVersusFailedTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/EmptyVersusFailedTest.kt index 20f12ce..a06ceb8 100644 --- a/app/src/test/kotlin/io/flamenet/messenger/app/EmptyVersusFailedTest.kt +++ b/app/src/test/kotlin/io/flamenet/messenger/app/EmptyVersusFailedTest.kt @@ -5,26 +5,19 @@ import org.junit.Assert.assertTrue import org.junit.Test /** - * "Nobody here yet" is a claim about the ACCOUNT. It must not be made about a - * request that failed. - * - * Both roster load sites swallow failures with - * `runCatching { … }.getOrDefault(emptyList())`, so a hiccup, an expired token - * or a rate limit all produced an empty list, indistinguishable from a real - * empty account. The same swallow-and-look-empty shape hid a decode bug that - * made groups invisible on iOS. + * "Nobody here yet" is a claim about the ACCOUNT. */ class EmptyVersusFailedTest { - - private fun phase(loaded: Boolean) = MessengerViewModel.Phase.SignedIn( - handle = "someone", - userId = 1, - deviceId = "d", - relay = "relay.example", - status = "", - buddies = emptyList(), - buddiesLoaded = loaded, - ) + private fun phase(loaded: Boolean) = + MessengerViewModel.Phase.SignedIn( + handle = "someone", + userId = 1, + deviceId = "d", + relay = "relay.example", + status = "", + buddies = emptyList(), + buddiesLoaded = loaded, + ) @Test fun `an empty roster that loaded is not the same state as one that failed`() { @@ -35,11 +28,17 @@ class EmptyVersusFailedTest { fun `a failed load does not default to claiming the roster loaded`() { // The default matters: a phase built without saying so must not assert // that the fetch succeeded. - val untouched = MessengerViewModel.Phase.SignedIn( - handle = "someone", userId = 1, deviceId = "d", - relay = "relay.example", status = "", + val untouched = + MessengerViewModel.Phase.SignedIn( + handle = "someone", + userId = 1, + deviceId = "d", + relay = "relay.example", + status = "", + ) + assertTrue( + "the default must be 'not loaded', so the copy stays honest", + !untouched.buddiesLoaded, ) - assertTrue("the default must be 'not loaded', so the copy stays honest", - !untouched.buddiesLoaded) } } diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/FileLevelsTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/FileLevelsTest.kt index bf45ece..86680e5 100644 --- a/app/src/test/kotlin/io/flamenet/messenger/app/FileLevelsTest.kt +++ b/app/src/test/kotlin/io/flamenet/messenger/app/FileLevelsTest.kt @@ -6,19 +6,8 @@ import kotlin.test.assertTrue /** * The classification vocabulary the local vault offers. - * - * This is a change detector, and that is exactly what it should be. The - * ORDINAL is what travels with a file, the word is only how it is drawn, so - * renumbering these would file somebody's Restricted document as Internal on - * the other phone. Nothing would error and neither screen would look wrong; the - * only symptom is a document sitting at a level nobody chose. - * - * These four, in this order, are `Vocabularies.local` in - * `Sources/Views/Files/FilesView.swift` on iOS. If that list changes, both have - * to change together and this test is where somebody is made to notice. */ class FileLevelsTest { - @Test fun `the four local levels match the ordinals iOS files with`() { assertEquals( @@ -39,12 +28,11 @@ class FileLevelsTest { for ((ordinal, name) in FileLevels.LOCAL_LEVELS) { assertEquals(name, FileLevels.levelName(ordinal)) } - // A file can arrive from a client with a wider vocabulary. Drawing an - // empty chip for it would show a row with no classification at all, - // which reads as "unclassified", the most permissive thing it could - // possibly mean, and a guess. - assertTrue(FileLevels.levelName(9).contains("9"), - "an unknown level must still say WHICH level, not render as blank") + // A file can arrive from a client with a wider vocabulary. + assertTrue( + FileLevels.levelName(9).contains("9"), + "an unknown level must still say WHICH level, not render as blank", + ) assertTrue(FileLevels.levelName(-1).isNotBlank()) } } diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/FileShareWireTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/FileShareWireTest.kt index 03ccd57..f697fd5 100644 --- a/app/src/test/kotlin/io/flamenet/messenger/app/FileShareWireTest.kt +++ b/app/src/test/kotlin/io/flamenet/messenger/app/FileShareWireTest.kt @@ -9,26 +9,23 @@ import kotlin.test.assertTrue /** * What a document sent from Files actually puts on the wire. - * - * The classification is the only thing that survives the trip. The - * recipient's phone files the document at whatever number arrives, there is - * nothing else to consult and no server to ask, so a label dropped in transit - * does not error anywhere: the file simply arrives Unclassified and gets filed - * that way, on the most permissive setting it could possibly have. - * - * These check the ENVELOPE. `Session.sendFile` needs a relay and a vault, so - * the send itself is covered on device; the shape that crosses to the other app - * is what an interop bug would live in, and it is checkable here. */ class FileShareWireTest { - - private fun ref(classification: Int, compartments: List, name: String = "Q3.txt") = - Attachment.Ref( - kind = Attachment.FILE, id = "blob-1", - key = ByteArray(32) { 1 }, digest = ByteArray(32) { 2 }, - bytes = 4096, mime = "application/octet-stream", - filename = name, classification = classification, compartments = compartments, - ) + private fun ref( + classification: Int, + compartments: List, + name: String = "Q3.txt", + ) = Attachment.Ref( + kind = Attachment.FILE, + id = "blob-1", + key = ByteArray(32) { 1 }, + digest = ByteArray(32) { 2 }, + bytes = 4096, + mime = "application/octet-stream", + filename = name, + classification = classification, + compartments = compartments, + ) private fun roundTrip(r: Attachment.Ref): Attachment.Ref { val parsed = assertNotNull(Content.parse(Content.attachment(r))) @@ -45,10 +42,6 @@ class FileShareWireTest { /** * Unclassified must round-trip as a real value, not as an absence. - * Zero is the default for a missing field, so a sender that dropped the key - * and a sender that meant zero are indistinguishable at the other end. This - * pins that the zero case still parses as a file rather than as something - * the recipient has to guess about. */ @Test fun `an unclassified file is still a file, not a missing label`() { @@ -60,9 +53,6 @@ class FileShareWireTest { /** * A file envelope always carries a caption, even when nobody wrote one. - * Builds predating this attachment kind show the caption and drop the rest, - * so an empty one reaches them as "[unsupported attachment]" with no clue - * what was sent. */ @Test fun `a file with no caption still says what it is to an older build`() { @@ -75,16 +65,14 @@ class FileShareWireTest { @Test fun `a caption somebody wrote is not replaced by the filename`() { - val parsed = assertNotNull( - Content.parse(Content.attachment(ref(1, emptyList()), caption = "the numbers"))) + val parsed = + assertNotNull(Content.parse(Content.attachment(ref(1, emptyList()), caption = "the numbers"))) assertEquals("the numbers", parsed.text) } /** - * Compartment order is normalised on the way out, so two senders who - * chose the same areas produce the same bytes. Unsorted, the label would - * still be *correct* and would still encode differently, which matters - * because the canonical encoding is authenticated data elsewhere. + * Compartment order is normalised on the way out, so two senders who chose + * the same areas produce the same bytes. */ @Test fun `compartments arrive in a stable order`() { @@ -92,16 +80,21 @@ class FileShareWireTest { } /** - * An image must NOT gain a label. `label` is written only for the file - * kind, a photo carrying `cls: 0` would tell the other end there is a - * classification here to respect, and there is not. + * An image must NOT gain a label. */ @Test fun `an image carries no label at all`() { - val image = Attachment.Ref( - kind = Attachment.IMAGE, id = "b", key = ByteArray(32), digest = ByteArray(32), - bytes = 10, mime = "image/jpeg", width = 4, height = 4, - ) + val image = + Attachment.Ref( + kind = Attachment.IMAGE, + id = "b", + key = ByteArray(32), + digest = ByteArray(32), + bytes = 10, + mime = "image/jpeg", + width = 4, + height = 4, + ) val json = org.json.JSONObject(Content.attachment(image)) assertTrue(!json.has("cls"), "an image envelope is claiming a classification") assertTrue(!json.has("cmp"), "an image envelope is claiming compartments") diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/FilesUsesTheAccountPassphraseTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/FilesUsesTheAccountPassphraseTest.kt index 6c2889d..25a51fe 100644 --- a/app/src/test/kotlin/io/flamenet/messenger/app/FilesUsesTheAccountPassphraseTest.kt +++ b/app/src/test/kotlin/io/flamenet/messenger/app/FilesUsesTheAccountPassphraseTest.kt @@ -6,23 +6,8 @@ import kotlin.test.assertTrue /** * Files asks for the Flamenet passphrase, not a second secret. - * - * The screen used to say the files were encrypted "with a passphrase of - * their own", and iOS never said anything like it. iOS says "your - * passphrase" and takes whatever is typed, so people use their Flamenet one - * and it works. Android's copy sent them off to invent something else, and - * forgetting it is a one-way door: the label keys are derived from the - * identity it protects and exist nowhere else, so every vaulted file becomes - * permanently unreadable. - * - * This is about the words, and the words are the whole feature here. - * Nothing in the derivation changed: it is still a free-choice string - * protecting the identity record, so anyone who already set a different one - * keeps opening their files with it. A guard on the copy is therefore the only - * guard there can be. */ class FilesUsesTheAccountPassphraseTest { - private fun source(name: String): String { var dir = File(System.getProperty("user.dir")) while (!File(dir, "app/src/main/kotlin").isDirectory && dir.parentFile != null) { @@ -51,9 +36,7 @@ class FilesUsesTheAccountPassphraseTest { } /** - * The exact phrase that caused it. Kept as a literal because a person - * rewording this screen should have to read the comment above the string - * and decide deliberately, not discover the consequence from a user. + * The exact phrase that caused it. */ @Test fun `no screen tells somebody to invent a second secret`() { diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/GroupNameClaimTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/GroupNameClaimTest.kt index 7a074c0..d247538 100644 --- a/app/src/test/kotlin/io/flamenet/messenger/app/GroupNameClaimTest.kt +++ b/app/src/test/kotlin/io/flamenet/messenger/app/GroupNameClaimTest.kt @@ -9,6 +9,9 @@ import io.flamenet.e2e.Vault import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer import okhttp3.mockwebserver.RecordedRequest +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config import javax.crypto.KeyGenerator import javax.crypto.SecretKey import kotlin.test.AfterTest @@ -16,32 +19,16 @@ import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config /** * The roster gate that turns a group-name CLAIM into a stored name. - * - * This is the check, and it lives here rather than in `Conversations`. - * The layer that consumes control traffic does not know who is in which group, - * so it queues claims and `Session` decides. Anyone who can reach us can send - * one naming any gid they like; adopting on the sender's word would put - * whatever a stranger wants into somebody's chat list, under a group heading - * the reader has no reason to doubt. - * - * It was tested only through `Conversations`, which asserts the claim is - * NOT adopted there, and says nothing about what happens next. The half that - * decides was covered one layer removed on Android while iOS had it directly. - * That is the gap this closes. */ -// Robolectric for the same reason as OutboxTest: `:app` is an Android -// module, so its unit-test classpath has the stubbed `org.json` that returns -// null from everything, and the vault stores JSON. +// Robolectric for the same reason as OutboxTest: `:app` is an Android module, +// so its unit-test classpath has the stubbed `org.json` that returns null from +// everything, and the vault stores JSON. @RunWith(RobolectricTestRunner::class) @Config(sdk = [34]) class GroupNameClaimTest { - private lateinit var server: MockWebServer /** Who the relay will say is in the group, for this run. */ @@ -49,68 +36,101 @@ class GroupNameClaimTest { /** * An empty inbox and whatever roster the test asked for. - * - * Answers by PATH rather than by queue order. `receive()` polls, and - * sending or draining can ask for a log head on the way, a fixture matched - * on order hands one of those the other's body and fails on a parse error - * a long way from the cause. */ private inner class Relay : okhttp3.mockwebserver.Dispatcher() { override fun dispatch(request: RecordedRequest): MockResponse { val path = request.path.orEmpty() return when { - "/e2e/log/" in path -> MockResponse().setResponseCode(404) - "/e2e/groups" in path -> json( - """{"groups":[{"gid":"g1","name":"Group","owner":7,"members":[""" + - roster.joinToString(",") { """{"user_id":$it}""" } + "]}]}") - "/e2e/messages" in path -> json("""{"messages":[]}""") - else -> json("{}") + "/e2e/log/" in path -> { + MockResponse().setResponseCode(404) + } + + "/e2e/groups" in path -> { + json( + """{"groups":[{"gid":"g1","name":"Group","owner":7,"members":[""" + + roster.joinToString(",") { """{"user_id":$it}""" } + "]}]}", + ) + } + + "/e2e/messages" in path -> { + json("""{"messages":[]}""") + } + + else -> { + json("{}") + } } } } @BeforeTest fun start() { - server = MockWebServer().also { it.dispatcher = Relay(); it.start() } + server = + MockWebServer().also { + it.dispatcher = Relay() + it.start() + } + } + + @AfterTest fun stop() { + server.shutdown() } - @AfterTest fun stop() { server.shutdown() } private class MapStorage : Vault.Storage { private val records = mutableMapOf() + override fun read(name: String) = records[name] - override fun write(name: String, bytes: ByteArray) { records[name] = bytes } - override fun delete(name: String) { records.remove(name) } + + override fun write( + name: String, + bytes: ByteArray, + ) { + records[name] = bytes + } + + override fun delete(name: String) { + records.remove(name) + } + override fun names() = records.keys.toList() } - private fun key(): SecretKey = - KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() + private fun key(): SecretKey = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() private fun session(): Pair { val identity = DeviceIdentity.create() val vault = Vault(MapStorage(), key()).also { it.saveIdentity(identity) } val base = server.url("/").toString() - val s = Session( - handle = "alice", - userId = 1, - identity = identity, - vault = vault, - relay = RelayClient(base, "alice", "app pass word"), - api = MessengerApi(base, "alice", "app pass word"), - ) + val s = + Session( + handle = "alice", + userId = 1, + identity = identity, + vault = vault, + relay = RelayClient(base, "alice", "app pass word"), + api = MessengerApi(base, "alice", "app pass word"), + ) return s to vault } - private fun json(body: String) = MockResponse() - .setResponseCode(200) - .setHeader("Content-Type", "application/json") - .setBody(body) + private fun json(body: String) = + MockResponse() + .setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody(body) /** Put a claim in the queue the way an arriving announcement does. */ - private fun claim(session: Session, fromUser: Long, gid: String, name: String) { + private fun claim( + session: Session, + fromUser: Long, + gid: String, + name: String, + ) { session.conversations.consumeControlForTesting( fromUser, - Content.parse(SealedSender.deliveryKeyEnvelope( - ByteArray(32), groupId = gid, groupName = name))) + Content.parse( + SealedSender.deliveryKeyEnvelope(ByteArray(32), groupId = gid, groupName = name), + ), + ) } @Test @@ -125,8 +145,8 @@ class GroupNameClaimTest { } /** - *A stranger naming a group they are not in must get - * nowhere, however well-formed their envelope is. + * A stranger naming a group they are not in must get nowhere, however well- + * formed their envelope is. */ @Test fun `a name from a non-member is refused`() { @@ -140,8 +160,7 @@ class GroupNameClaimTest { } /** - * A gid the relay does not list for us is not our group. Adopting it - * would let anyone create an entry in our chat list out of nothing. + * A gid the relay does not list for us is not our group. */ @Test fun `a name for a group we are not in is refused`() { @@ -155,9 +174,7 @@ class GroupNameClaimTest { } /** - * Draining is not the same as accepting. A refused claim must not sit in - * the queue waiting to be retried against a roster that might later differ - * and it must not be re-checked on every poll for ever. + * Draining is not the same as accepting. */ @Test fun `a refused claim is consumed, not left to retry`() { @@ -168,7 +185,7 @@ class GroupNameClaimTest { session.receive() assertNull(vault.groupName("g1")) - // Now the stranger IS a member. The old claim must not be waiting. + // Now the stranger IS a member. roster = listOf(1L, 7L, 99L) session.receive() assertNull(vault.groupName("g1")) diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/GroupPinResolvesTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/GroupPinResolvesTest.kt index e3176e1..339a469 100644 --- a/app/src/test/kotlin/io/flamenet/messenger/app/GroupPinResolvesTest.kt +++ b/app/src/test/kotlin/io/flamenet/messenger/app/GroupPinResolvesTest.kt @@ -4,6 +4,10 @@ import io.flamenet.client.MessengerApi import io.flamenet.client.RelayClient import io.flamenet.e2e.DeviceIdentity import io.flamenet.e2e.Vault +import okhttp3.mockwebserver.MockWebServer +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config import javax.crypto.KeyGenerator import javax.crypto.SecretKey import kotlin.test.AfterTest @@ -12,43 +16,43 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertNull -import okhttp3.mockwebserver.MockWebServer -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config /** * A pin in a GROUP resolves to the message it names (§6.19). - * - * Written to settle a published claim. The spec says a group pin *"is - * carried correctly on the wire but cannot resolve yet, because a group - * transcript does not record"* a message id. `Session.sendGroup` mints a `mid` - * and passes it to `record(...)`, so the stated reason is already false, but - * relay#46's rule is that a claim is flipped by a run, not by reading the code, - * and nothing exercised the group case. - * - * The 1:1 case was never in doubt. This is specifically about a group - * thread, because that is what the spec singles out. */ @RunWith(RobolectricTestRunner::class) @Config(sdk = [34]) class GroupPinResolvesTest { - private lateinit var server: MockWebServer - @BeforeTest fun start() { server = MockWebServer().also { it.start() } } - @AfterTest fun stop() { server.shutdown() } + @BeforeTest fun start() { + server = MockWebServer().also { it.start() } + } + + @AfterTest fun stop() { + server.shutdown() + } private class MapStorage : Vault.Storage { private val records = mutableMapOf() + override fun read(name: String) = records[name] - override fun write(name: String, bytes: ByteArray) { records[name] = bytes } - override fun delete(name: String) { records.remove(name) } + + override fun write( + name: String, + bytes: ByteArray, + ) { + records[name] = bytes + } + + override fun delete(name: String) { + records.remove(name) + } + override fun names() = records.keys.toList() } - private fun key(): SecretKey = - KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() + private fun key(): SecretKey = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() private val gid = "abc123" private val thread = Vault.threadForGroup("abc123") @@ -57,17 +61,25 @@ class GroupPinResolvesTest { val identity = DeviceIdentity.create() val vault = Vault(MapStorage(), key()).also { it.saveIdentity(identity) } val base = server.url("/").toString() - return Session("alice", 1, identity, vault, + return Session( + "alice", + 1, + identity, + vault, RelayClient(base, "alice", "app pass word"), - MessengerApi(base, "alice", "app pass word")) + MessengerApi(base, "alice", "app pass word"), + ) } @Test fun `a group transcript records a message id`() { val s = session() s.record(thread, "in a group", mine = true, author = 1L, messageId = "gm1") - assertEquals("gm1", s.history(thread).single().id, - "a group line carries no id, the spec's stated reason would hold") + assertEquals( + "gm1", + s.history(thread).single().id, + "a group line carries no id, the spec's stated reason would hold", + ) } @Test @@ -82,14 +94,15 @@ class GroupPinResolvesTest { // The resolve: the banner needs the MESSAGE, not just its id. val target = s.history(thread).firstOrNull { it.id == pinned } - assertEquals("worth keeping", assertNotNull(target).text, - "the pin named a message the group transcript could not find") + assertEquals( + "worth keeping", + assertNotNull(target).text, + "the pin named a message the group transcript could not find", + ) } /** - * An id, never a copy. A stored copy would go stale the moment the - * message was edited and would survive it being deleted, a banner quoting - * words no longer in the conversation. + * An id, never a copy. */ @Test fun `the pin follows an edit rather than quoting the old words`() { @@ -100,8 +113,11 @@ class GroupPinResolvesTest { val pinned = assertNotNull(s.pinnedMessage(thread)) val target = s.history(thread).firstOrNull { it.id == pinned } - assertEquals("after", assertNotNull(target).text, - "the banner is quoting words that are no longer in the conversation") + assertEquals( + "after", + assertNotNull(target).text, + "the banner is quoting words that are no longer in the conversation", + ) } @Test diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/InboundControlTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/InboundControlTest.kt index 6b106da..a5b2b21 100644 --- a/app/src/test/kotlin/io/flamenet/messenger/app/InboundControlTest.kt +++ b/app/src/test/kotlin/io/flamenet/messenger/app/InboundControlTest.kt @@ -8,33 +8,14 @@ import kotlin.test.assertTrue /** * Edits, pins and unknown control envelopes must never become bubbles * (ios#6.15/#6.16/#6.19, Android half). - * - * The bug this closes was an interop bug, not a missing feature. The - * engine has parsed `editOf`, `pins` and `fwd` for as long as they have - * existed, and it exposes `isRenderable`, "does this belong in a thread", as - * the single answer. `Session`'s inbound loop never consulted it. - * - * So an edit from the iPhone app arrived carrying its new wording in `text` and - * was filed as an ORDINARY MESSAGE: a correction drawn as a duplicate beside the - * typo it was meant to replace. A pin was filed as a bubble whose text was - * empty. And any control envelope from a newer peer, which carries `ctl` and a - * kind this build has never heard of, would have drawn a bubble full of JSON - * in somebody's conversation. - * - * These test the ENGINE's classification, which is what the app now branches - * on. The app-side application (`applyEdit`, `applyPin`) needs a `Vault` and a - * keystore, so it is covered on device in `FeatureUseTest`; what is checkable - * here is the decision that used to be missing. */ class InboundControlTest { - private fun parse(json: String): Content = Content.parse(json)!! - // ---- What must not be drawn ----------------------------------------- + // ---- What must not be drawn -----------------------------------------. /** - * The case that shipped a duplicate bubble. An edit carries the new - * wording, so it looks exactly like a message to anything that does not ask. + * The case that shipped a duplicate bubble. */ @Test fun `an edit is not renderable, even though it carries text`() { @@ -61,27 +42,24 @@ class InboundControlTest { } /** - * The case an allowlist cannot cover, and the reason the app branches - * on `isRenderable` rather than on a list of kinds it knows. A control - * envelope from a NEWER peer has a kind nothing here has heard of. Keying on - * known kinds ships a bubble full of JSON to every older install the first - * time a new control message is added on the other platform. + * The case an allowlist cannot cover, and the reason the app branches on + * `isRenderable` rather than on a list of kinds it knows. */ @Test fun `a control envelope from a newer peer is not renderable`() { - val fromTheFuture = parse( - """{"fnc":1,"kind":"something-invented-later","ctl":1,"text":"{}"}""" - ) + val fromTheFuture = + parse( + """{"fnc":1,"kind":"something-invented-later","ctl":1,"text":"{}"}""", + ) assertTrue(fromTheFuture.isControl, "the ctl flag was not honoured") - assertFalse(fromTheFuture.isRenderable, - "an unknown control kind would be drawn as a message") + assertFalse( + fromTheFuture.isRenderable, + "an unknown control kind would be drawn as a message", + ) } /** - * Both shapes of the flag. `org.json`'s `optBoolean` does not coerce a - * number, so an engine sending `"ctl":1` rather than `"ctl":true` would slip - * past a reader that checked only one, which is how this class of bug got - * shipped once already. + * Both shapes of the flag. */ @Test fun `the control flag is honoured as a number and as a boolean`() { @@ -89,11 +67,10 @@ class InboundControlTest { assertFalse(parse("""{"fnc":1,"kind":"x","ctl":true,"text":"{}"}""").isRenderable) } - // ---- What must still be drawn --------------------------------------- + // ---- What must still be drawn ---------------------------------------. /** - * The guard must not swallow ordinary messages. A `continue` in the wrong - * place is a conversation that silently stops receiving. + * The guard must not swallow ordinary messages. */ @Test fun `an ordinary message is still renderable`() { @@ -103,31 +80,33 @@ class InboundControlTest { @Test fun `a reply is still renderable`() { - val reply = parse( - Content.text("answering", messageId = "mid-2", - about = Content.Reference("mid-1", 7L, "the question")), - ) + val reply = + parse( + Content.text( + "answering", + messageId = "mid-2", + about = Content.Reference("mid-1", 7L, "the question"), + ), + ) assertTrue(reply.isRenderable) assertTrue(reply.about?.messageId == "mid-1") } /** - * A forward IS a message. It is second-hand words, not a control - * envelope, the flag changes how it is drawn, not whether it is. + * A forward IS a message. */ @Test fun `a forwarded message is renderable, and says so`() { - val forwarded = parse( - Content.text("passed along", messageId = "mid-3", forwarded = true), - ) + val forwarded = + parse( + Content.text("passed along", messageId = "mid-3", forwarded = true), + ) assertTrue(forwarded.isRenderable, "a forward was treated as control") assertTrue(forwarded.isForwarded, "the fwd flag was lost") } /** - * And the same both-shapes rule for `fwd`, for the same reason. An engine - * writing `1` where this one writes `true` would have its forwards drawn as - * the sender's own words. + * And the same both-shapes rule for `fwd`, for the same reason. */ @Test fun `the forwarded flag is honoured as a number and as a boolean`() { diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/InboundEditAppliedTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/InboundEditAppliedTest.kt index 5147ee3..6ecbedf 100644 --- a/app/src/test/kotlin/io/flamenet/messenger/app/InboundEditAppliedTest.kt +++ b/app/src/test/kotlin/io/flamenet/messenger/app/InboundEditAppliedTest.kt @@ -4,6 +4,10 @@ import io.flamenet.client.MessengerApi import io.flamenet.client.RelayClient import io.flamenet.e2e.DeviceIdentity import io.flamenet.e2e.Vault +import okhttp3.mockwebserver.MockWebServer +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config import javax.crypto.KeyGenerator import javax.crypto.SecretKey import kotlin.test.AfterTest @@ -12,46 +16,43 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue -import okhttp3.mockwebserver.MockWebServer -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config /** * An inbound edit changes the message it names (§6.15). - * - * Written to settle a claim, not to add coverage. The published spec - * says Android *"cannot yet apply one, because its transcript does not record a - * `mid` for each line"*, and that both blocks replies and reactions too. The - * transcript does record a `mid`, `MessageReactionTest` folds a reaction onto - * a message by id, so the stated reason is already false. - * - * But relay#46's rule is the point: do not flip a claim because the code - * looks present; flip it because a run proved it, and say which run. - * `applyEdit` was implemented and wired with nothing exercising it, because - * `InboundControlTest` covers the codec and says in its own header that the - * app-side application needs a `Vault` and is out of scope there. This is that - * run. */ @RunWith(RobolectricTestRunner::class) @Config(sdk = [34]) class InboundEditAppliedTest { - private lateinit var server: MockWebServer - @BeforeTest fun start() { server = MockWebServer().also { it.start() } } - @AfterTest fun stop() { server.shutdown() } + @BeforeTest fun start() { + server = MockWebServer().also { it.start() } + } + + @AfterTest fun stop() { + server.shutdown() + } private class MapStorage : Vault.Storage { private val records = mutableMapOf() + override fun read(name: String) = records[name] - override fun write(name: String, bytes: ByteArray) { records[name] = bytes } - override fun delete(name: String) { records.remove(name) } + + override fun write( + name: String, + bytes: ByteArray, + ) { + records[name] = bytes + } + + override fun delete(name: String) { + records.remove(name) + } + override fun names() = records.keys.toList() } - private fun key(): SecretKey = - KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() + private fun key(): SecretKey = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() private val me = 1L private val them = 2L @@ -61,13 +62,17 @@ class InboundEditAppliedTest { val identity = DeviceIdentity.create() val vault = Vault(MapStorage(), key()).also { it.saveIdentity(identity) } val base = server.url("/").toString() - return Session("alice", me, identity, vault, + return Session( + "alice", + me, + identity, + vault, RelayClient(base, "alice", "app pass word"), - MessengerApi(base, "alice", "app pass word")) + MessengerApi(base, "alice", "app pass word"), + ) } - private fun Session.textOf(mid: String): String? = - history(thread).firstOrNull { it.id == mid }?.text + private fun Session.textOf(mid: String): String? = history(thread).firstOrNull { it.id == mid }?.text @Test fun `an edit from the author rewrites the message it names`() { @@ -87,14 +92,14 @@ class InboundEditAppliedTest { s.record(thread, "before", mine = false, author = them, messageId = "m1") s.applyEdit(thread, "m1", author = them, newText = "after") assertEquals("after", s.textOf("m1")) - assertTrue(s.history(thread).single().editedAt != null, - "the bubble has no way to show that it was edited") + assertTrue( + s.history(thread).single().editedAt != null, + "the bubble has no way to show that it was edited", + ) } /** - * An edit from anyone but the author is refused. Without this, one - * contact could rewrite another's words in a transcript, which reads to - * the person as the sender having said something they never said. + * An edit from anyone but the author is refused. */ @Test fun `an edit from somebody who did not write it is refused`() { diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/MentionPickerTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/MentionPickerTest.kt index 187b725..04d4d6d 100644 --- a/app/src/test/kotlin/io/flamenet/messenger/app/MentionPickerTest.kt +++ b/app/src/test/kotlin/io/flamenet/messenger/app/MentionPickerTest.kt @@ -7,17 +7,18 @@ import kotlin.test.assertNull /** * The two decisions behind the `@` picker, tested away from Compose. - * - * Both of these are about somebody being rung, or not being rung, by a - * message. A picker that opens at the wrong moment is an annoyance; a mention - * that reaches the wrong person, or fails to reach the right one, is the - * feature not working. */ class MentionPickerTest { - - private fun buddy(id: Long, handle: String, display: String = "") = - MessengerApi.Buddy(userId = id, handle = handle, displayName = display, - presence = "offline") + private fun buddy( + id: Long, + handle: String, + display: String = "", + ) = MessengerApi.Buddy( + userId = id, + handle = handle, + displayName = display, + presence = "offline", + ) // MARK: - When the list is open @@ -40,8 +41,7 @@ class MentionPickerTest { /** * The list closes on a space, and closing has to be driven by the text - * rather than by a flag. Somebody deleting their way back past the `@` - * expects it gone, and a flag has to be remembered to be cleared. + * rather than by a flag. */ @Test fun `a space after the at closes the list`() { @@ -70,9 +70,7 @@ class MentionPickerTest { /** * The relay stores ids and no names, so a member who is not in your - * contacts has nothing to show. They stay mentionable under their id, - * being un-mentionable is worse than being ugly, because the alternative - * is a group where some people cannot be reached. + * contacts has nothing to show. */ @Test fun `a member who is not a contact is still mentionable`() { diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/MessageReactionTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/MessageReactionTest.kt index f24ef67..8172f0b 100644 --- a/app/src/test/kotlin/io/flamenet/messenger/app/MessageReactionTest.kt +++ b/app/src/test/kotlin/io/flamenet/messenger/app/MessageReactionTest.kt @@ -6,6 +6,9 @@ import io.flamenet.e2e.DeviceIdentity import io.flamenet.e2e.Vault import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config import javax.crypto.KeyGenerator import javax.crypto.SecretKey import kotlin.test.AfterTest @@ -16,46 +19,56 @@ import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config /** * Reactions, and the message id everything else will hang off. - * - * The id is the point. A reaction, an edit and a pin all say "this - * one", and until every message carried an identifier there was nothing to - * point at, which is why all three sat in the codec with no way to reach - * them. The rules pinned here are the ones that decide whether a reaction - * lands on the right message, on the wrong one, or on nothing. */ @RunWith(RobolectricTestRunner::class) @Config(sdk = [34]) class MessageReactionTest { - private lateinit var server: MockWebServer private class NoKeyLog : okhttp3.mockwebserver.QueueDispatcher() { override fun dispatch(request: okhttp3.mockwebserver.RecordedRequest): MockResponse = - if ("/e2e/log/" in request.path.orEmpty()) MockResponse().setResponseCode(404) - else super.dispatch(request) + if ("/e2e/log/" in request.path.orEmpty()) { + MockResponse().setResponseCode(404) + } else { + super.dispatch(request) + } } @BeforeTest fun start() { - server = MockWebServer().also { it.dispatcher = NoKeyLog(); it.start() } + server = + MockWebServer().also { + it.dispatcher = NoKeyLog() + it.start() + } + } + + @AfterTest fun stop() { + server.shutdown() } - @AfterTest fun stop() { server.shutdown() } private class MapStorage : Vault.Storage { private val records = mutableMapOf() + override fun read(name: String) = records[name] - override fun write(name: String, bytes: ByteArray) { records[name] = bytes } - override fun delete(name: String) { records.remove(name) } + + override fun write( + name: String, + bytes: ByteArray, + ) { + records[name] = bytes + } + + override fun delete(name: String) { + records.remove(name) + } + override fun names() = records.keys.toList() } - private fun key(): SecretKey = - KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() + private fun key(): SecretKey = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() private fun session(): Session { val identity = DeviceIdentity.create() @@ -71,10 +84,11 @@ class MessageReactionTest { ) } - private fun json(body: String) = MockResponse() - .setResponseCode(200) - .setHeader("Content-Type", "application/json") - .setBody(body) + private fun json(body: String) = + MockResponse() + .setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody(body) private val peer = 99L private val thread get() = Vault.threadForUser(peer) @@ -82,9 +96,8 @@ class MessageReactionTest { // MARK: - The id /** - * A sent message carries an id, and the copy in our own transcript - * carries the SAME one. Two ids for one message means anything the other - * side says about it arrives about a message we do not have. + * A sent message carries an id, and the copy in our own transcript carries + * the SAME one. */ @Test fun `a sent message and its local copy share one id`() { @@ -99,9 +112,7 @@ class MessageReactionTest { } /** - * The queued retry has to be the SAME message, id included. Queuing the - * bare text meant the copy that eventually landed was a different message - * from the one already in this transcript. + * The queued retry has to be the SAME message, id included. */ @Test fun `the queued retry carries the same id as the local copy`() { @@ -112,9 +123,15 @@ class MessageReactionTest { val mid = session.history(thread).single().id assertNotNull(mid) - val queued = session.vault.outbox().getJSONObject(0).getString("text") - assertTrue(queued.contains(mid!!), - "the queued copy does not carry the id the transcript recorded") + val queued = + session.vault + .outbox() + .getJSONObject(0) + .getString("text") + assertTrue( + queued.contains(mid!!), + "the queued copy does not carry the id the transcript recorded", + ) } // MARK: - Applying one @@ -130,15 +147,12 @@ class MessageReactionTest { val line = session.history(thread).single() assertEquals(mapOf("❤️" to setOf(peer)), line.reactions) - // And it stays one message. A reaction filed as a message of its - // own is how a thread becomes unreadable. + // And it stays one message. assertEquals(1, session.history(thread).size) } /** - * One emoji per person. Reacting again replaces rather than - * accumulates, otherwise one tap each on five emoji all stand at once, - * which is not what anybody means by reacting. + * One emoji per person. */ @Test fun `reacting again replaces the previous one`() { @@ -180,9 +194,8 @@ class MessageReactionTest { } /** - * A reaction to a message this device does not have does nothing, and - * says so. Inventing a bubble for it would put a message in the thread - * that nobody wrote. + * A reaction to a message this device does not have does nothing, and says + * so. */ @Test fun `a reaction to a message we do not have is ignored, not invented`() { @@ -196,9 +209,8 @@ class MessageReactionTest { } /** - * A line stored before ids existed has none, and must not be given one - * on read. Two devices would invent different ids for the same message, - * and a reaction would land on nothing, or worse, on the wrong thing. + * A line stored before ids existed has none, and must not be given one on + * read. */ @Test fun `a message stored before ids existed keeps no id`() { diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/MetadataScrubTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/MetadataScrubTest.kt index 10d0a3f..e4e5b0c 100644 --- a/app/src/test/kotlin/io/flamenet/messenger/app/MetadataScrubTest.kt +++ b/app/src/test/kotlin/io/flamenet/messenger/app/MetadataScrubTest.kt @@ -7,35 +7,25 @@ import kotlin.test.assertTrue /** * A photo sent as a file must lose its location, like one sent as a photo. - * - * The bug this pins was found by an audit, not by a test, because no test - * went down the file path at all (android#17, ios#69). The picker path was - * checked and correct; `Session.sendFile` handed stored bytes straight to - * `sendAttachment`. The two doors disagreed, and the safer one was the one that - * looked like a shortcut. - * - * The format sniffing is what is tested here, not the re-encode. - * `BitmapFactory` needs a device, so the pixel half belongs to the instrumented - * suite; iOS covers the round trip with a real GPS-tagged JPEG. What a JVM test - * CAN prove is the decision, and the decision is where the bug was, because - * the old code never asked the question at all. */ class MetadataScrubTest { - private fun bytes(vararg v: Int) = v.map { it.toByte() }.toByteArray() @Test fun `an image is recognised from its first bytes`() { assertEquals(MetadataScrub.ImageKind.JPEG, MetadataScrub.kind(bytes(0xFF, 0xD8, 0xFF, 0xE0))) - assertEquals(MetadataScrub.ImageKind.PNG, - MetadataScrub.kind(bytes(0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A))) + assertEquals( + MetadataScrub.ImageKind.PNG, + MetadataScrub.kind(bytes(0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A)), + ) assertEquals(MetadataScrub.ImageKind.GIF, MetadataScrub.kind(bytes(0x47, 0x49, 0x46, 0x38))) assertEquals(MetadataScrub.ImageKind.TIFF, MetadataScrub.kind(bytes(0x49, 0x49, 0x2A, 0x00))) assertEquals(MetadataScrub.ImageKind.TIFF, MetadataScrub.kind(bytes(0x4D, 0x4D, 0x00, 0x2A))) } - /** Both markers, with the size field between them, matching only the - * RIFF header would claim every WAV file is an image. */ + /** + * Both markers, with the size field between them, matching only the. + */ @Test fun `webp needs both of its markers`() { val webp = bytes(0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x50) @@ -47,20 +37,17 @@ class MetadataScrubTest { /** HEIC is an ISO-BMFF box, so the brand at offset 8 is what decides. */ @Test fun `heic is recognised and its cousins are not`() { - fun ftyp(brand: String) = bytes(0, 0, 0, 0x18, 0x66, 0x74, 0x79, 0x70) + - brand.toByteArray(Charsets.US_ASCII) + fun ftyp(brand: String) = + bytes(0, 0, 0, 0x18, 0x66, 0x74, 0x79, 0x70) + + brand.toByteArray(Charsets.US_ASCII) assertEquals(MetadataScrub.ImageKind.HEIC, MetadataScrub.kind(ftyp("heic"))) assertEquals(MetadataScrub.ImageKind.HEIC, MetadataScrub.kind(ftyp("mif1"))) - // An MP4 shares the ftyp box. Claiming it is an image would send it - // through a bitmap decoder, which fails, and then be passed through - // anyway, harmless here, but the check should still be honest. + // An MP4 shares the ftyp box. assertNull(MetadataScrub.kind(ftyp("isom"))) } /** - * Only images are touched. "Send this exact file" is what a file send - * means; a scrubber that mangles somebody's PDF is worse than the leak it - * was fixing. + * Only images are touched. */ @Test fun `anything that is not an image goes byte for byte`() { diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/NavDestinationsTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/NavDestinationsTest.kt index cf266a7..d731c82 100644 --- a/app/src/test/kotlin/io/flamenet/messenger/app/NavDestinationsTest.kt +++ b/app/src/test/kotlin/io/flamenet/messenger/app/NavDestinationsTest.kt @@ -8,42 +8,31 @@ import kotlin.test.assertTrue /** * What is in the tab bar. - * - * This test exists because nothing could see the bar. It was a hand-written - * run of `NavButton` calls inside a Composable, so its contents were invisible to - * every test in the project: a destination could be dropped in a refactor and - * nothing would go red. That is not hypothetical. Files and Wallet lived only - * inside the Account screen, most of a scroll down, while iOS carried both in - * its bar. Somebody testing the Android build reasonably concluded they had not - * been built at all. - * - * Pinning the exact list is the point, not an accident of writing it this - * way. A test that only asserted "at least four tabs" would have passed - * throughout the disagreement it is here to prevent. */ class NavDestinationsTest { - /** - * The order matches iOS, which puts Files and Wallet between Stories - * and Account. The same destination in the same place on both phones is - * most of what "the apps stay in sync" means to somebody holding one of - * each. + * The order matches iOS, which puts Files and Wallet between Stories and + * Account. */ @Test fun `the bar is the seven destinations iOS has, in the same order`() { assertEquals( listOf( - Screen.CONTACTS, Screen.MESSAGES, Screen.CALLS, Screen.STORIES, - Screen.FILES, Screen.WALLET, Screen.ACCOUNT, + Screen.CONTACTS, + Screen.MESSAGES, + Screen.CALLS, + Screen.STORIES, + Screen.FILES, + Screen.WALLET, + Screen.ACCOUNT, ), NavDestinations.tabs(storiesHidden = false).map { it.screen }, ) } /** - * Supervision removes the surface rather than showing an empty one, - * and removes only that one. A parental control that quietly took Files or - * the wallet with it would be a data-access change nobody asked for. + * Supervision removes the surface rather than showing an empty one, and + * removes only that one. */ @Test fun `supervision hides stories and nothing else`() { @@ -56,25 +45,27 @@ class NavDestinationsTest { } /** - * The tags are how the instrumented suite finds the bar. A renamed tag - * is a test that stops testing and still passes, because - * `onNodeWithTag(...).performClick()` on a node that does not exist is - * caught, but `nodesWithTag(...).isEmpty()` guards throughout that suite - * turn a missing tag into a silent early return. + * The tags are how the instrumented suite finds the bar. */ @Test fun `every tab carries the tag the instrumented suite looks for`() { assertEquals( - listOf("nav-contacts", "nav-messages", "nav-calls", "nav-stories", - "nav-files", "nav-wallet", "nav-account"), + listOf( + "nav-contacts", + "nav-messages", + "nav-calls", + "nav-stories", + "nav-files", + "nav-wallet", + "nav-account", + ), NavDestinations.tabs(storiesHidden = false).map { it.tag }, ) } /** - * The label is not decoration: the bar draws icons only, so this string - * is the accessibility name and the only thing TalkBack reads. A blank one - * makes the tab an unnamed button. + * The label is not decoration: the bar draws icons only, so this string is + * the accessibility name and the only thing TalkBack reads. */ @Test fun `every tab is named for a screen reader`() { @@ -84,10 +75,8 @@ class NavDestinationsTest { } /** - * Every destination a person can reach from the bar has to be somewhere - * the app can actually draw. `NOTIFICATIONS` is deliberately NOT in the bar - * it left on 2026-08-28 and is reached by tapping the Account badge, so - * this checks the bar is a subset of the screens, not equal to them. + * Every destination a person can reach from the bar has to be somewhere the + * app can actually draw. */ @Test fun `no tab points at a screen that does not exist`() { diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/NetworkStatusLineTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/NetworkStatusLineTest.kt index 8fdc2a5..8d2f329 100644 --- a/app/src/test/kotlin/io/flamenet/messenger/app/NetworkStatusLineTest.kt +++ b/app/src/test/kotlin/io/flamenet/messenger/app/NetworkStatusLineTest.kt @@ -7,16 +7,8 @@ import kotlin.test.assertTrue /** * What the Network section says is happening. - * - * A settings screen that reads the stored line and calls it a status is - * the failure this feature is built around. A saved bridge that is not there - * leaves the app connecting directly; the preference cannot tell you that and - * a completed handshake can. `statusLine` is lifted out of the Composable - * precisely so this can be asserted, a string built inline in a `Text(...)` is - * a string nothing can test. */ class NetworkStatusLineTest { - private val bridge = Obfs4Bridge("198.51.100.7", 4444, ByteArray(20), ByteArray(32)) @Test @@ -26,9 +18,7 @@ class NetworkStatusLineTest { } /** - * The load-bearing case. A bridge is saved and nothing has gone - * through it. Reporting that as protection is exactly the claim the screen - * exists to stop making. + * The load-bearing case. */ @Test fun `a saved bridge that has carried nothing does not claim to be working`() { @@ -47,9 +37,7 @@ class NetworkStatusLineTest { } /** - * Every state has to repeat that the relay is unaffected. Somebody who - * reads "wrapped so nobody can tell what it is" and stops there has been - * told a true thing that leads to a false conclusion. + * Every state has to repeat that the relay is unaffected. */ @Test fun `the working state does not imply anything is hidden from the server`() { diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/NotificationTextTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/NotificationTextTest.kt index 1ae38ec..f1b5ac5 100644 --- a/app/src/test/kotlin/io/flamenet/messenger/app/NotificationTextTest.kt +++ b/app/src/test/kotlin/io/flamenet/messenger/app/NotificationTextTest.kt @@ -6,28 +6,20 @@ import kotlin.test.assertFalse /** * What a notification is allowed to say. - * - * The default is the load-bearing part. Previews off means a lock screen - * says how many messages arrived and nothing else, no name, no words. That was - * the behaviour before this setting existed, and nobody may acquire a name on - * their lock screen by installing an update. - * - * The text is built by `NotificationSettings` rather than inline in the - * `NotificationCompat.Builder` chain precisely so it can be asserted here. A - * string built inside that chain is a string nothing can test, and what is - * being decided is whether somebody's name reaches a lock screen. */ class NotificationTextTest { + // Calls the SHIPPING functions. + private fun messageText( + previews: Boolean, + threads: Int, + from: String?, + ) = NotificationSettings.messageText(previews, threads, from) - // Calls the SHIPPING functions. The first version of this file - // reimplemented the rules against a fake and passed perfectly while - // asserting nothing about the code that runs, two identically-wrong ends - // agreeing, which is the shape this codebase has been bitten by before. - private fun messageText(previews: Boolean, threads: Int, from: String?) = - NotificationSettings.messageText(previews, threads, from) - - private fun replyText(previews: Boolean, count: Int, from: String?) = - NotificationSettings.replyReminderText(previews, count, from) + private fun replyText( + previews: Boolean, + count: Int, + from: String?, + ) = NotificationSettings.replyReminderText(previews, count, from) @Test fun `with previews off no name reaches the lock screen`() { @@ -44,9 +36,7 @@ class NotificationTextTest { } /** - * Several threads means no name even with previews on. Naming one of - * three would be arbitrary, and naming all of them turns a notification - * into a contact list on the lock screen. + * Several threads means no name even with previews on. */ @Test fun `several threads are counted, never named`() { @@ -55,9 +45,8 @@ class NotificationTextTest { } /** - * A caller that has no name must not produce "New message from null" or - * a dangling "from". The service cannot always resolve one, a group, a - * cross-relay peer, or a contact it has never seen. + * A caller that has no name must not produce "New message from null" or a + * dangling "from". */ @Test fun `a missing name falls back to the count rather than showing a hole`() { diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/OutboxTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/OutboxTest.kt index 19539aa..e91bc39 100644 --- a/app/src/test/kotlin/io/flamenet/messenger/app/OutboxTest.kt +++ b/app/src/test/kotlin/io/flamenet/messenger/app/OutboxTest.kt @@ -6,6 +6,9 @@ import io.flamenet.e2e.DeviceIdentity import io.flamenet.e2e.Vault import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config import javax.crypto.KeyGenerator import javax.crypto.SecretKey import kotlin.test.AfterTest @@ -13,71 +16,63 @@ import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config /** * A message to somebody who is signed out waits, and goes when they come back. - * - * The question this answers is "how does the sender know to try again". - * They should not have to. Marking a message undelivered and leaving it there - * puts the work on the person who did nothing wrong, they would have to - * remember, notice their friend was back, and retype it. So the message is - * held and retried, and the only thing the sender ever does is write it once. - * - * Nothing expires. There is no TTL and nothing is discarded: a contact - * who reinstalls in a month gets it in a month. Timing out would mean deleting - * something somebody wrote to tidy up a label. - * - * The honest limit, which the UI wording has to respect: the queue is on - * the sender's phone, because an envelope is sealed to a device's keys and - * somebody with no device has none to seal to. So it goes out the next time - * THIS app runs and polls, not the instant the recipient signs in. */ -// Robolectric, for one boring reason: `:app` is an Android module, so its -// unit-test classpath has the stubbed `org.json` that returns null from every -// call. The vault stores JSON, so without a real implementation every one of -// these fails inside `saveIdentity` before reaching anything worth testing. +// Robolectric, for one boring reason: `:app` is an Android module, so its unit- +// test classpath has the stubbed `org.json` that returns null from every call. @RunWith(RobolectricTestRunner::class) @Config(sdk = [34]) class OutboxTest { - private lateinit var server: MockWebServer /** * A relay from before §19, answering the queue for everything else. - * - * Not incidental setup. Sending now asks for a transparency-log head on - * its own, because key checking is automatic on any relay that serves one, - * so a queue matched purely by ORDER would hand the head request the device - * listing and fail on a parse error miles from the cause. This says plainly - * what the fixture is: a deployment with no log, where these tests are - * about the outbox and nothing else. */ private class NoKeyLog : okhttp3.mockwebserver.QueueDispatcher() { override fun dispatch(request: okhttp3.mockwebserver.RecordedRequest): MockResponse = // `contains`, not `startsWith`: every route sits under the REST // mount, so the recorded path begins with that prefix. - if ("/e2e/log/" in request.path.orEmpty()) MockResponse().setResponseCode(404) - else super.dispatch(request) + if ("/e2e/log/" in request.path.orEmpty()) { + MockResponse().setResponseCode(404) + } else { + super.dispatch(request) + } } @BeforeTest fun start() { - server = MockWebServer().also { it.dispatcher = NoKeyLog(); it.start() } + server = + MockWebServer().also { + it.dispatcher = NoKeyLog() + it.start() + } + } + + @AfterTest fun stop() { + server.shutdown() } - @AfterTest fun stop() { server.shutdown() } private class MapStorage : Vault.Storage { private val records = mutableMapOf() + override fun read(name: String) = records[name] - override fun write(name: String, bytes: ByteArray) { records[name] = bytes } - override fun delete(name: String) { records.remove(name) } + + override fun write( + name: String, + bytes: ByteArray, + ) { + records[name] = bytes + } + + override fun delete(name: String) { + records.remove(name) + } + override fun names() = records.keys.toList() } - private fun key(): SecretKey = - KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() + private fun key(): SecretKey = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() private fun session(): Session { val identity = DeviceIdentity.create() @@ -93,17 +88,18 @@ class OutboxTest { ) } - private fun json(body: String) = MockResponse() - .setResponseCode(200) - .setHeader("Content-Type", "application/json") - .setBody(body) + private fun json(body: String) = + MockResponse() + .setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody(body) private val peer = 99L @Test fun `a message to somebody with no device is kept, not lost and not dropped on the sender`() { val session = session() - server.enqueue(json("""{"devices":[]}""")) // they are signed out + server.enqueue(json("""{"devices":[]}""")) // they are signed out session.send(peer, "are you around later?") @@ -116,8 +112,11 @@ class OutboxTest { assertEquals(Session.Delivery.PENDING, thread[0].delivery) // And it is queued, so nobody has to remember to send it again. - assertEquals(1, session.vault.outbox().length(), - "the message must be waiting in the outbox") + assertEquals( + 1, + session.vault.outbox().length(), + "the message must be waiting in the outbox", + ) } @Test @@ -126,12 +125,15 @@ class OutboxTest { server.enqueue(json("""{"devices":[]}""")) session.send(peer, "still nobody home") - server.enqueue(json("""{"devices":[]}""")) // still signed out + server.enqueue(json("""{"devices":[]}""")) // still signed out val touched = session.flushOutbox() assertTrue(touched.isEmpty(), "nothing changed, so nothing to redraw") - assertEquals(1, session.vault.outbox().length(), - "a failed retry must not discard the message") + assertEquals( + 1, + session.vault.outbox().length(), + "a failed retry must not discard the message", + ) assertEquals( Session.Delivery.PENDING, session.history(Vault.threadForUser(peer))[0].delivery, @@ -149,22 +151,15 @@ class OutboxTest { server.enqueue(MockResponse().setResponseCode(500)) session.flushOutbox() - assertEquals(1, session.vault.outbox().length(), - "a transport failure must not eat the message either") + assertEquals( + 1, + session.vault.outbox().length(), + "a transport failure must not eat the message either", + ) } /** * A refusal waiting cannot fix must STOP (ios#22). - * - * The outbox retries for ever, which is right for a contact who is offline - * or has no device yet. It is wrong for a send the trust layer refuses: - * the peer's identity keys changed, so every retry is refused identically - * until a person accepts them, while the bubble goes on promising - * "will send", which is a promise the app cannot keep. - * - * Asserted on the OUTBOX and the BUBBLE together. A version that - * dropped the entry but left the bubble pending would pass a test that - * only checked one, and would read as "still sending" for ever. */ @Test fun `a send refused for changed keys stops retrying and says why`() { @@ -179,52 +174,68 @@ class OutboxTest { val bundleOf = { id: DeviceIdentity -> val opk = id.generateOneTimePreKeys(1).first() id.registrationWire().put( - "opk", org.json.JSONObject() + "opk", + org.json + .JSONObject() .put("id", opk.id) - .put("pub", android.util.Base64.encodeToString( - opk.publicKey, android.util.Base64.NO_WRAP))) + .put( + "pub", + android.util.Base64.encodeToString(opk.publicKey, android.util.Base64.NO_WRAP), + ), + ) } val first = bundleOf(them) - server.enqueue(json(org.json.JSONObject() - .put("devices", org.json.JSONArray().put(first)).toString())) - server.enqueue(json(org.json.JSONObject() - .put("devices", org.json.JSONArray().put(first)).toString())) + server.enqueue( + json( + org.json + .JSONObject() + .put("devices", org.json.JSONArray().put(first)) + .toString(), + ), + ) + server.enqueue( + json( + org.json + .JSONObject() + .put("devices", org.json.JSONArray().put(first)) + .toString(), + ), + ) server.enqueue(json("""{"ok":true}""")) session.flushOutbox() assertEquals(0, session.vault.outbox().length()) - // Now they reinstall: a DIFFERENT identity under the same user. The - // trust layer refuses, and no amount of retrying changes that. - server.enqueue(json("""{"devices":[]}""")) // queues the second message + // Now they reinstall: a DIFFERENT identity under the same user. + server.enqueue(json("""{"devices":[]}""")) // queues the second message session.send(peer, "you there?") assertEquals(1, session.vault.outbox().length()) val second = bundleOf(DeviceIdentity.create()) - val newDirectory = org.json.JSONObject() - .put("devices", org.json.JSONArray().put(second)).toString() + val newDirectory = + org.json + .JSONObject() + .put("devices", org.json.JSONArray().put(second)) + .toString() // Enqueued generously: the refusal happens at the directory, but the // exact number of calls before it is not what this test is about. repeat(4) { server.enqueue(json(newDirectory)) } session.flushOutbox() - assertEquals(0, session.vault.outbox().length(), - "a permanently refused send must not sit in the queue for ever") + assertEquals( + 0, + session.vault.outbox().length(), + "a permanently refused send must not sit in the queue for ever", + ) val line = session.history(Vault.threadForUser(peer)).last { it.mine } - assertEquals(Session.Delivery.FAILED, line.delivery, - "the bubble must stop promising it will send") + assertEquals( + Session.Delivery.FAILED, + line.delivery, + "the bubble must stop promising it will send", + ) } /** - * The promise, end to end. Everything above only proves the message - * is not lost; this proves it is eventually sent, which is the part the - * sender is being asked to trust. Both halves are asserted deliberately: a - * version that delivered the message but left the bubble reading - * "undelivered" would pass a test that only checked the outbox, and would - * look broken to the person reading the thread. - * - * Serves a REAL bundle built from a second identity, not a hand-written - * fake, so the send has to complete X3DH against it. A fake bundle - * would agree with whatever the client did, including the wrong thing. + * The promise, end to end. */ @Test fun `when they register a device the queued message goes and the bubble updates`() { @@ -236,27 +247,50 @@ class OutboxTest { // They reinstall: a device with real keys appears. val them = DeviceIdentity.create() val opk = them.generateOneTimePreKeys(1).first() - val bundle = them.registrationWire().put( - "opk", org.json.JSONObject() - .put("id", opk.id) - // `Bytes` is internal to :engine, so base64 by hand rather - // than widening its visibility for a test. - .put("pub", android.util.Base64.encodeToString( - opk.publicKey, android.util.Base64.NO_WRAP))) + val bundle = + them.registrationWire().put( + "opk", + org.json + .JSONObject() + .put("id", opk.id) + // `Bytes` is internal to :engine, so base64 by hand rather than + // widening its visibility for a test. + .put( + "pub", + android.util.Base64.encodeToString(opk.publicKey, android.util.Base64.NO_WRAP), + ), + ) // The send asks the directory first (free), THEN the key route (which // pops a prekey), two requests now, same payload shape. - server.enqueue(json(org.json.JSONObject() - .put("devices", org.json.JSONArray().put(bundle)).toString())) - server.enqueue(json(org.json.JSONObject() - .put("devices", org.json.JSONArray().put(bundle)).toString())) - server.enqueue(json("""{"ok":true}""")) // the envelope post + server.enqueue( + json( + org.json + .JSONObject() + .put("devices", org.json.JSONArray().put(bundle)) + .toString(), + ), + ) + server.enqueue( + json( + org.json + .JSONObject() + .put("devices", org.json.JSONArray().put(bundle)) + .toString(), + ), + ) + server.enqueue(json("""{"ok":true}""")) // the envelope post val touched = session.flushOutbox() - assertEquals(0, session.vault.outbox().length(), - "a delivered message must leave the outbox") - assertTrue(Vault.threadForUser(peer) in touched, - "the thread has to be redrawn or the bubble keeps its old label") + assertEquals( + 0, + session.vault.outbox().length(), + "a delivered message must leave the outbox", + ) + assertTrue( + Vault.threadForUser(peer) in touched, + "the thread has to be redrawn or the bubble keeps its old label", + ) assertEquals( Session.Delivery.DELIVERED, session.history(Vault.threadForUser(peer))[0].delivery, @@ -265,11 +299,10 @@ class OutboxTest { } /** - * The sticker version of the promise, because it was broken separately: - * a sticker to somebody signed out was marked "Undelivered, will send" - * and then never entered the outbox, the label was a lie for exactly one - * content kind. This asserts all three parts: queued, delivered AS A - * STICKER once a device exists, and the bubble restated. + * The sticker version of the promise, because it was broken separately: a + * sticker to somebody signed out was marked "Undelivered, will send" and + * then never entered the outbox, the label was a lie for exactly one + * content kind. */ @Test fun `a sticker to somebody with no device is queued and later delivered as a sticker`() { @@ -281,47 +314,70 @@ class OutboxTest { assertEquals(1, thread.size) assertEquals("wave", thread[0].sticker?.id, "the bubble is a sticker, not text") assertEquals(Session.Delivery.PENDING, thread[0].delivery) - assertEquals(1, session.vault.outbox().length(), - "the sticker must be waiting in the outbox") + assertEquals( + 1, + session.vault.outbox().length(), + "the sticker must be waiting in the outbox", + ) // They reinstall. val them = DeviceIdentity.create() val opk = them.generateOneTimePreKeys(1).first() - val bundle = them.registrationWire().put( - "opk", org.json.JSONObject() - .put("id", opk.id) - .put("pub", android.util.Base64.encodeToString( - opk.publicKey, android.util.Base64.NO_WRAP))) + val bundle = + them.registrationWire().put( + "opk", + org.json + .JSONObject() + .put("id", opk.id) + .put( + "pub", + android.util.Base64.encodeToString(opk.publicKey, android.util.Base64.NO_WRAP), + ), + ) // Directory first, then keys, see the note in the first test. - server.enqueue(json(org.json.JSONObject() - .put("devices", org.json.JSONArray().put(bundle)).toString())) - server.enqueue(json(org.json.JSONObject() - .put("devices", org.json.JSONArray().put(bundle)).toString())) + server.enqueue( + json( + org.json + .JSONObject() + .put("devices", org.json.JSONArray().put(bundle)) + .toString(), + ), + ) + server.enqueue( + json( + org.json + .JSONObject() + .put("devices", org.json.JSONArray().put(bundle)) + .toString(), + ), + ) server.enqueue(json("""{"ok":true}""")) session.flushOutbox() assertEquals(0, session.vault.outbox().length()) - assertEquals(Session.Delivery.DELIVERED, - session.history(Vault.threadForUser(peer))[0].delivery, - "the sticker bubble must stop saying undelivered") - assertEquals("wave", - session.history(Vault.threadForUser(peer))[0].sticker?.id, - "restating the delivery must not turn the sticker into text") + assertEquals( + Session.Delivery.DELIVERED, + session.history(Vault.threadForUser(peer))[0].delivery, + "the sticker bubble must stop saying undelivered", + ) + assertEquals( + "wave", + session.history(Vault.threadForUser(peer))[0].sticker?.id, + "restating the delivery must not turn the sticker into text", + ) } /** - * The group half of the promise, with its completion rule made - * observable: the bubble flips only when the LAST queued member is - * reached. Restating on the first success would mark the message delivered - * while other members are still owed it, "will send" would quietly stop - * being true for exactly the people it was still true for. + * The group half of the promise, with its completion rule made observable. + * the bubble flips only when the LAST queued member is reached. */ @Test fun `a group message waits for every queued member before the bubble flips`() { val session = session() - val group = io.flamenet.client.RelayClient.Group( - id = "g1", name = "test", owner = 1, memberIds = listOf(101L, 102L)) + val group = + io.flamenet.client.RelayClient + .Group(id = "g1", name = "test", owner = 1, memberIds = listOf(101L, 102L)) // Both members signed out: two device lookups, both empty. server.enqueue(json("""{"devices":[]}""")) @@ -330,46 +386,84 @@ class OutboxTest { val thread = Vault.threadForGroup("g1") assertEquals(Session.Delivery.PENDING, session.history(thread)[0].delivery) - assertEquals(2, session.vault.outbox().length(), - "one entry per member nothing reached") + assertEquals( + 2, + session.vault.outbox().length(), + "one entry per member nothing reached", + ) // Member 101 registers; 102 is still away. val m1 = DeviceIdentity.create() val opk1 = m1.generateOneTimePreKeys(1).first() - val m1Wire = org.json.JSONObject().put("devices", - org.json.JSONArray().put(m1.registrationWire().put("opk", - org.json.JSONObject().put("id", opk1.id).put("pub", - android.util.Base64.encodeToString(opk1.publicKey, - android.util.Base64.NO_WRAP))))).toString() - server.enqueue(json(m1Wire)) // the directory - server.enqueue(json(m1Wire)) // the key route + val m1Wire = + org.json + .JSONObject() + .put( + "devices", + org.json.JSONArray().put( + m1.registrationWire().put( + "opk", + org.json.JSONObject().put("id", opk1.id).put( + "pub", + android.util.Base64.encodeToString( + opk1.publicKey, + android.util.Base64.NO_WRAP, + ), + ), + ), + ), + ).toString() + server.enqueue(json(m1Wire)) // the directory + server.enqueue(json(m1Wire)) // the key route server.enqueue(json("""{"ok":true}""")) - server.enqueue(json("""{"devices":[]}""")) // 102 still gone + server.enqueue(json("""{"devices":[]}""")) // 102 still gone session.flushOutbox() - assertEquals(1, session.vault.outbox().length(), - "the reached member leaves the queue; the other stays") - assertEquals(Session.Delivery.PENDING, session.history(thread)[0].delivery, - "one member reached is not the group delivered") + assertEquals( + 1, + session.vault.outbox().length(), + "the reached member leaves the queue; the other stays", + ) + assertEquals( + Session.Delivery.PENDING, + session.history(thread)[0].delivery, + "one member reached is not the group delivered", + ) // Member 102 comes back. val m2 = DeviceIdentity.create() val opk2 = m2.generateOneTimePreKeys(1).first() - val m2Wire = org.json.JSONObject().put("devices", - org.json.JSONArray().put(m2.registrationWire().put("opk", - org.json.JSONObject().put("id", opk2.id).put("pub", - android.util.Base64.encodeToString(opk2.publicKey, - android.util.Base64.NO_WRAP))))).toString() - server.enqueue(json(m2Wire)) // the directory - server.enqueue(json(m2Wire)) // the key route + val m2Wire = + org.json + .JSONObject() + .put( + "devices", + org.json.JSONArray().put( + m2.registrationWire().put( + "opk", + org.json.JSONObject().put("id", opk2.id).put( + "pub", + android.util.Base64.encodeToString( + opk2.publicKey, + android.util.Base64.NO_WRAP, + ), + ), + ), + ), + ).toString() + server.enqueue(json(m2Wire)) // the directory + server.enqueue(json(m2Wire)) // the key route server.enqueue(json("""{"ok":true}""")) val touched = session.flushOutbox() assertEquals(0, session.vault.outbox().length()) assertTrue(thread in touched, "the GROUP thread is what gets redrawn") assertEquals(Session.Delivery.DELIVERED, session.history(thread)[0].delivery) - assertEquals("meeting moved to three", session.history(thread)[0].text, - "the group body must not be replaced by the wire form") + assertEquals( + "meeting moved to three", + session.history(thread)[0].text, + "the group body must not be replaced by the wire form", + ) } @Test @@ -380,14 +474,14 @@ class OutboxTest { server.enqueue(json("""{"devices":[]}""")) session.send(peer, "same words") - // Two identical messages. `restate` matches on the timestamp the - // send stamped, not on the text, matching on text would restate - // whichever came first every time somebody repeats themselves. + // Two identical messages. val outbox = session.vault.outbox() assertEquals(2, outbox.length()) val first = outbox.getJSONObject(0).getLong("at") val second = outbox.getJSONObject(1).getLong("at") - assertTrue(first != second || session.history(Vault.threadForUser(peer)).size == 2, - "two sends must be two distinguishable entries") + assertTrue( + first != second || session.history(Vault.threadForUser(peer)).size == 2, + "two sends must be two distinguishable entries", + ) } } diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/PendingCallTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/PendingCallTest.kt index 1b7aa9c..11d919b 100644 --- a/app/src/test/kotlin/io/flamenet/messenger/app/PendingCallTest.kt +++ b/app/src/test/kotlin/io/flamenet/messenger/app/PendingCallTest.kt @@ -11,19 +11,12 @@ import kotlin.test.assertTrue /** * The parked incoming offer, which is what makes a call answerable from a * locked phone. - * - * The ringing notification is `ongoing`, so it cannot be swiped away by - * accident mid-ring, which also means it never dismisses itself, and every - * path that ends a call has to cancel it: declined, answered, the caller hung - * up, and nobody picked up. The last one is the one with no event behind it, - * and [PendingIncomingCall.isStale] is how it is noticed. */ class PendingCallTest { - @AfterTest fun tidy() = PendingIncomingCall.clearAll() - private fun offer(id: String = "call-1") = Content.CallSignal( - action = Content.CallSignal.OFFER, callId = id, sdp = "v=0", reason = null, video = false) + private fun offer(id: String = "call-1") = + Content.CallSignal(action = Content.CallSignal.OFFER, callId = id, sdp = "v=0", reason = null, video = false) @Test fun `nothing is stale when nothing is parked`() { @@ -40,15 +33,12 @@ class PendingCallTest { /** Without this the lock screen keeps offering to answer a dead call. */ @Test fun `an offer older than the dial timeout is stale`() { - PendingIncomingCall.park( - 42L, offer(), System.currentTimeMillis() - CallManager.DIAL_TIMEOUT_MS - 1_000) + PendingIncomingCall.park(42L, offer(), System.currentTimeMillis() - CallManager.DIAL_TIMEOUT_MS - 1_000) assertTrue(PendingIncomingCall.isStale()) } /** - * Asking must not consume. A staleness check that swallowed a live offer - * would drop a call that was still ringing, the exact opposite of the bug - * it exists to prevent. + * Asking must not consume. */ @Test fun `checking staleness does not take the offer`() { diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/PresenceTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/PresenceTest.kt index 96485d6..7c25b1d 100644 --- a/app/src/test/kotlin/io/flamenet/messenger/app/PresenceTest.kt +++ b/app/src/test/kotlin/io/flamenet/messenger/app/PresenceTest.kt @@ -6,20 +6,8 @@ import kotlin.test.assertTrue /** * Presence, and the vocabulary it shares with the relay. - * - * The bug this guards shipped here and hid for months. This screen has - * always offered `busy` and `offline` chips. The relay's status route parsed - * with a plain `rawValue:` init inside an `if let` with no `else`, so both were - * accepted with 200 and stored nothing, and `setPresence` then set its local - * state from the value it had *asked* for. Tapping "offline" lit the chip, - * changed nothing, and left the user visible to every contact, a privacy - * control that reported success and did nothing. - * - * Both ends are fixed. These assertions are what stops the vocabulary drifting - * apart again, since nothing in either build checks it. */ class PresenceTest { - /** Every chip must be a word `ChosenStatus` in flamenet-relay accepts. */ @Test fun `the chips are exactly the statuses the relay accepts`() { @@ -31,8 +19,8 @@ class PresenceTest { /** * The relay stores `offline` as `invisible`, the same choice under two - * names, so the reply has to be mapped back or no chip appears selected - * and the screen looks broken right after a successful change. + * names, so the reply has to be mapped back or no chip appears selected and + * the screen looks broken right after a successful change. */ @Test fun `invisible is shown as offline`() { @@ -56,6 +44,5 @@ class PresenceTest { assertEquals("dnd", displayPresenceFor("dnd")) } - private fun displayPresenceFor(stored: String): String = - if (stored == "invisible") "offline" else stored + private fun displayPresenceFor(stored: String): String = if (stored == "invisible") "offline" else stored } diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/RecoveryActuallyRecoversTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/RecoveryActuallyRecoversTest.kt index b4dd26d..abe138e 100644 --- a/app/src/test/kotlin/io/flamenet/messenger/app/RecoveryActuallyRecoversTest.kt +++ b/app/src/test/kotlin/io/flamenet/messenger/app/RecoveryActuallyRecoversTest.kt @@ -7,26 +7,8 @@ import kotlin.test.assertTrue /** * The one button on a dead-end screen has to be a way out. - * - * "Erase and start fresh" erased nothing. The `VaultUnreadable` screen, - * shown when the keys on this phone cannot be opened, offered exactly one - * action, and it called `model::signOut`. `signOut` defaults to - * `erasingDevice = false`, the door that deliberately KEEPS the device so - * coming back is silent. So it set the phase to SignedOut, left the unreadable - * vault exactly where it was, and the next `restore()` opened it, failed again, - * and came straight back to the same screen. - * - * It looked like it worked. The screen changed. That is the whole - * problem with a recovery action that does not recover: the feedback is the - * claim, and the person only finds out on the next launch, or, as here, when - * they try to sign in and land back where they started. - * - * A source scan, coarse on purpose, in the same shape as - * `DestructiveLabelsTest`. It cannot prove the recovery is correct. It proves - * this particular wrong call cannot come back. */ class RecoveryActuallyRecoversTest { - private fun source(name: String): String { val f = File("src/main/kotlin/io/flamenet/messenger/app/$name") assertTrue(f.isFile, "cannot find $name, this test reads the sources") @@ -61,9 +43,7 @@ class RecoveryActuallyRecoversTest { } /** - * And that the discard actually destroys something. A method with the - * right name that does not reach the vault would satisfy the test above and - * fail the person in exactly the same way. + * And that the discard actually destroys something. */ @Test fun `discarding an unreadable vault destroys it and reopens a fresh one`() { @@ -71,12 +51,18 @@ class RecoveryActuallyRecoversTest { val start = s.indexOf("fun discardUnreadableVault(") assertTrue(start > 0, "discardUnreadableVault is gone") val body = s.substring(start, minOf(start + 1200, s.length)) - assertTrue(body.contains("KeystoreVault.destroy"), - "discardUnreadableVault never destroys the vault") - assertTrue(body.contains("KeystoreVault.open"), + assertTrue( + body.contains("KeystoreVault.destroy"), + "discardUnreadableVault never destroys the vault", + ) + assertTrue( + body.contains("KeystoreVault.open"), "discardUnreadableVault destroys the vault without opening a fresh one, " + - "so the next sign-in has nowhere to store keys") - assertTrue(body.contains("Phase.SignedOut"), - "discardUnreadableVault does not leave the app on the sign-in screen") + "so the next sign-in has nowhere to store keys", + ) + assertTrue( + body.contains("Phase.SignedOut"), + "discardUnreadableVault does not leave the app on the sign-in screen", + ) } } diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/RegisteredLineTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/RegisteredLineTest.kt index 5e590d7..ba5e084 100644 --- a/app/src/test/kotlin/io/flamenet/messenger/app/RegisteredLineTest.kt +++ b/app/src/test/kotlin/io/flamenet/messenger/app/RegisteredLineTest.kt @@ -7,15 +7,8 @@ import kotlin.test.assertTrue /** * The device row's "Registered " line. - * - * This shipped rendering its own source code. The string was written - * with an escaped dollar, so the template was never evaluated and the screen - * showed `Registered ${fmt.format(instant)}` to the user. Nothing could warn - * about it, escaping a dollar is legitimate, just never there, and nothing - * could test it either, because it was built inline inside a `Text(...)`. */ class RegisteredLineTest { - @Test fun `a real timestamp becomes a date, not template source`() { val line = registeredLine("2026-08-29T14:35:00Z") diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/ReplyWireTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/ReplyWireTest.kt index 93b7f6a..19e13f1 100644 --- a/app/src/test/kotlin/io/flamenet/messenger/app/ReplyWireTest.kt +++ b/app/src/test/kotlin/io/flamenet/messenger/app/ReplyWireTest.kt @@ -8,15 +8,8 @@ import kotlin.test.assertTrue /** * Starting a reply, on the wire (android#16). - * - * The receiving half already worked, `InboundControlTest` has "a reply - * is still renderable", and `Content.Reference` was in use for reactions. What - * was missing was any way to START one: no Reply in the menu, no composer - * state, and `Session.send` had nowhere to put a reference. So these tests are - * about the outbound envelope, which is the half that did not exist. */ class ReplyWireTest { - private fun parse(json: String) = Content.parse(json) @Test @@ -31,11 +24,7 @@ class ReplyWireTest { } /** - * The preview travels, and this is why. A recipient who joined the - * thread later, or cleared their history, has never seen the message being - * answered. A reply rendering as "> [missing]" is worse than one that shows - * what was said, so the sender's own copy of the quoted words goes with - * it, and dropping the field to save bytes is not a free optimisation. + * The preview travels, and this is why. */ @Test fun `the quoted words travel with the reply`() { @@ -45,9 +34,8 @@ class ReplyWireTest { } /** - * A message with nothing extra to carry still goes out as raw bytes, so - * an older peer reads it as text. Adding a reply must not have changed - * that for messages that are not replies. + * A message with nothing extra to carry still goes out as raw bytes, so an + * older peer reads it as text. */ @Test fun `a message that is not a reply is still a bare body`() { diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/ScheduledSendTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/ScheduledSendTest.kt index 59d8f5d..bdd61ac 100644 --- a/app/src/test/kotlin/io/flamenet/messenger/app/ScheduledSendTest.kt +++ b/app/src/test/kotlin/io/flamenet/messenger/app/ScheduledSendTest.kt @@ -8,23 +8,20 @@ import kotlin.test.assertTrue /** * Scheduled send: the drain rule. - * - * "Not due yet" must mean KEEP, never fail. The outbox loop is also the - * retry queue, so a scheduled entry that fell through the failure path would be - * dropped outright, the message would simply never exist. This asserts the - * predicate the drain uses, which is the whole of the risk. */ class ScheduledSendTest { - private val now = 1_700_000_000_000L /** The drain's rule, mirrored: hold while `not_before` is in the future. */ - private fun isHeld(item: JSONObject, at: Long) = item.optLong("not_before", 0L) > at + private fun isHeld( + item: JSONObject, + at: Long, + ) = item.optLong("not_before", 0L) > at @Test fun `an entry with no schedule sends immediately`() { - // Entries queued before scheduling existed carry no `not_before`, - // and must not be held for a date they have no value for. + // Entries queued before scheduling existed carry no `not_before`, and + // must not be held for a date they have no value for. assertTrue(!isHeld(JSONObject().put("to", 1).put("text", "hi"), now)) } @@ -50,13 +47,15 @@ class ScheduledSendTest { /** Soonest-first ordering is what the banner names and Cancel removes. */ @Test fun `scheduled entries are ordered soonest first`() { - val pending = JSONArray() - .put(JSONObject().put("to", 1).put("text", "later").put("not_before", now + 7_200_000)) - .put(JSONObject().put("to", 1).put("text", "sooner").put("not_before", now + 60_000)) - val ordered = (0 until pending.length()) - .map { pending.getJSONObject(it) } - .filter { it.optLong("not_before") > now } - .sortedBy { it.optLong("not_before") } + val pending = + JSONArray() + .put(JSONObject().put("to", 1).put("text", "later").put("not_before", now + 7_200_000)) + .put(JSONObject().put("to", 1).put("text", "sooner").put("not_before", now + 60_000)) + val ordered = + (0 until pending.length()) + .map { pending.getJSONObject(it) } + .filter { it.optLong("not_before") > now } + .sortedBy { it.optLong("not_before") } assertEquals("sooner", ordered.first().optString("text")) } } diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/ScreenshotTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/ScreenshotTest.kt index 5289fdf..62b2f7a 100644 --- a/app/src/test/kotlin/io/flamenet/messenger/app/ScreenshotTest.kt +++ b/app/src/test/kotlin/io/flamenet/messenger/app/ScreenshotTest.kt @@ -2,13 +2,13 @@ package io.flamenet.messenger.app import android.graphics.Bitmap import android.graphics.Canvas +import androidx.activity.ComponentActivity import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.activity.ComponentActivity import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.unit.dp import io.flamenet.client.MessengerApi @@ -21,278 +21,374 @@ import org.robolectric.annotation.GraphicsMode import java.io.File /** - * Renders screens to PNG on the JVM. No emulator, no device, no 8 GB AVD. - * - * This exists because looking at a layout should not cost an emulator. - * Verifying the design pass meant booting an AVD, installing an APK, tapping - * to a screen and pulling a screencap, and the emulator was killed by memory - * pressure four times in one session, each time mid-check. Robolectric's - * native graphics mode draws the same Compose pixels on the JVM in seconds. - * - * Not assertions, output. These write files and pass as long as - * nothing throws. That is the honest description: a screenshot test that - * asserts against a stored baseline is a different thing, worth adding once - * the design has stopped moving. Right now the design IS moving, and a - * baseline would be re-blessed on every change, which proves nothing. - * - * Rendered at the kit's own canvas, 375x812, so a PNG here can be put - * beside the Sketch artboard and compared directly rather than mentally - * rescaled from a 1080x2400 phone. - * - * Output lands in `app/build/screenshots`. + * Renders screens to PNG on the JVM. */ @RunWith(RobolectricTestRunner::class) @GraphicsMode(GraphicsMode.Mode.NATIVE) @Config(sdk = [34], qualifiers = "w375dp-h812dp-normal-long-notround-any-440dpi-keyshidden-nonav") class ScreenshotTest { - - // An Activity rule, not `createComposeRule()`. The plain rule hosts - // the tree in a bare ComposeView, and `captureToImage` then goes down the - // window-capture path looking for a real window to redraw, which under - // Robolectric never signals, and the whole thing dies as - // "Condition still not satisfied after 2000 ms". That reads like a hung - // composition and is nothing of the sort. + // An Activity rule, not `createComposeRule()`. @get:Rule val compose = createAndroidComposeRule() private val out = File("build/screenshots").also { it.mkdirs() } /** Your address, with the QR the Kotlin encoder produces. */ - @Test fun addressCard() = shoot("address-card") { - AddressCard( - fnid = "fnid:kx7q2m4n8p3r5t7v9wxyz0123456789abcdefghjkmn", - relay = "relay.flamenet.io", - ) - } + @Test fun addressCard() = + shoot("address-card") { + AddressCard( + fnid = "fnid:kx7q2m4n8p3r5t7v9wxyz0123456789abcdefghjkmn", + relay = "relay.flamenet.io", + ) + } /** The same card when the address could not be derived. */ - @Test fun addressCardMissing() = shoot("address-card-missing") { - AddressCard(fnid = null, relay = "relay.flamenet.io") - } + @Test fun addressCardMissing() = + shoot("address-card-missing") { + AddressCard(fnid = null, relay = "relay.flamenet.io") + } /** * A whole screen, with a fabricated phase and an idle view model. - * - * The screens take their state as `phase` and only touch the model when - * something is tapped, so an unstarted model is enough to render them. It - * does open the Keystore vault in `init`, under Robolectric that fails - * inside a `runCatching` on a background dispatcher, which is why this - * works at all rather than by design. If it starts hanging, that is the - * first place to look. */ - @Test fun contactsScreen() = shootFullScreen("screen-contacts") { model, phase -> - androidx.compose.foundation.layout.Column { SignedIn(model, phase) } - } + @Test fun contactsScreen() = + shootFullScreen("screen-contacts") { model, phase -> + androidx.compose.foundation.layout + .Column { SignedIn(model, phase) } + } - @Test fun accountScreen() = shootFullScreen("screen-account") { model, phase -> - androidx.compose.foundation.layout.Column { AccountScreen(model, phase) } - } + @Test fun accountScreen() = + shootFullScreen("screen-account") { model, phase -> + androidx.compose.foundation.layout + .Column { AccountScreen(model, phase) } + } /** The feed, with a couple of stories on it. */ - @Test fun storiesFeed() = shootFullScreen("screen-stories") { model, phase -> - androidx.compose.foundation.layout.Column { StoriesFeed(model, phase) } - } - - @Test fun storiesFeedEmpty() = shoot("screen-stories-empty") { - val app = androidx.test.core.app.ApplicationProvider - .getApplicationContext() - androidx.compose.foundation.layout.Column { - StoriesFeed(MessengerViewModel(app), samplePhase().copy(stories = emptyList())) + @Test fun storiesFeed() = + shootFullScreen("screen-stories") { model, phase -> + androidx.compose.foundation.layout + .Column { StoriesFeed(model, phase) } } - } - @Test fun callsScreen() = shootFullScreen("screen-calls") { model, phase -> - androidx.compose.foundation.layout.Column { CallsScreen(model, phase) } - } - - @Test fun callsEmpty() = shoot("screen-calls-empty") { - val app = androidx.test.core.app.ApplicationProvider - .getApplicationContext() - androidx.compose.foundation.layout.Column { - CallsScreen(MessengerViewModel(app), - samplePhase().copy(calls = emptyList())) + @Test fun storiesFeedEmpty() = + shoot("screen-stories-empty") { + val app = + androidx.test.core.app.ApplicationProvider + .getApplicationContext() + androidx.compose.foundation.layout.Column { + StoriesFeed(MessengerViewModel(app), samplePhase().copy(stories = emptyList())) + } } - } - @Test fun alertsScreen() = shootFullScreen("screen-alerts") { model, phase -> - androidx.compose.foundation.layout.Column { NotificationsScreen(model, phase) } - } + @Test fun callsScreen() = + shootFullScreen("screen-calls") { model, phase -> + androidx.compose.foundation.layout + .Column { CallsScreen(model, phase) } + } + + @Test fun callsEmpty() = + shoot("screen-calls-empty") { + val app = + androidx.test.core.app.ApplicationProvider + .getApplicationContext() + androidx.compose.foundation.layout.Column { + CallsScreen( + MessengerViewModel(app), + samplePhase().copy(calls = emptyList()), + ) + } + } + + @Test fun alertsScreen() = + shootFullScreen("screen-alerts") { model, phase -> + androidx.compose.foundation.layout + .Column { NotificationsScreen(model, phase) } + } /** - * A conversation. The one screen with no render until now, it was checked - * on the emulator before the palette changed, and not since. + * A conversation. */ - @Test fun chatScreen() = shootFullScreen("screen-chat") { model, phase -> - androidx.compose.foundation.layout.Column { - ChatScreen(model, sampleChat(), error = null, buddies = phase.buddies) + @Test fun chatScreen() = + shootFullScreen("screen-chat") { model, phase -> + androidx.compose.foundation.layout.Column { + ChatScreen(model, sampleChat(), error = null, buddies = phase.buddies) + } } - } - private fun sampleChat() = MessengerViewModel.Phase.Chat( - threadId = "t1", - title = "Hoang Nguyen", - peerId = 33, - trust = io.flamenet.client.IdentityTrust.PINNED, - safetyNumber = "12345 67890 12345 67890 12345 67890", - lines = listOf( - Session.Line("sup", mine = true, at = 1_756_300_200_000), - Session.Line("Notta", mine = false, at = 1_756_300_260_000), - Session.Line( - "A longer one, so the corner that points at its author is " - + "visible on a bubble that actually wraps.", - mine = false, at = 1_756_300_320_000), - Session.Line("And the same on this side.", mine = true, at = 1_756_300_380_000, - delivery = Session.Delivery.DELIVERED), - // The state that used to be invisible: sent while they were signed - // out, so no device took it. It waits in the outbox and says so. - Session.Line("Are you around later?", mine = true, at = 1_756_300_440_000, - delivery = Session.Delivery.PENDING), - // A sticker draws bare, no bubble, so this catches the case where - // it falls through to the text path and renders as an empty one. - Session.Line("", mine = false, at = 1_756_300_500_000, - sticker = Session.Sticker("flamenet.core", "wave")), - ), - ) + private fun sampleChat() = + MessengerViewModel.Phase.Chat( + threadId = "t1", + title = "Hoang Nguyen", + peerId = 33, + trust = io.flamenet.client.IdentityTrust.PINNED, + safetyNumber = "12345 67890 12345 67890 12345 67890", + lines = + listOf( + Session.Line("sup", mine = true, at = 1_756_300_200_000), + Session.Line("Notta", mine = false, at = 1_756_300_260_000), + Session.Line( + "A longer one, so the corner that points at its author is " + + "visible on a bubble that actually wraps.", + mine = false, + at = 1_756_300_320_000, + ), + Session.Line( + "And the same on this side.", + mine = true, + at = 1_756_300_380_000, + delivery = Session.Delivery.DELIVERED, + ), + // The state that used to be invisible: sent while they were signed + // out, so no device took it. + Session.Line( + "Are you around later?", + mine = true, + at = 1_756_300_440_000, + delivery = Session.Delivery.PENDING, + ), + // A sticker draws bare, no bubble, so this catches the case where + // it falls through to the text path and renders as an empty one. + Session.Line( + "", + mine = false, + at = 1_756_300_500_000, + sticker = Session.Sticker("flamenet.core", "wave"), + ), + ), + ) /** One contact, the kit's way: cover, name plate, floating action rail. */ - @Test fun contactDetail() = shootFullScreen("screen-contact") { model, phase -> - androidx.compose.foundation.layout.Column { - ContactDetail(model, phase, phase.buddies[1]) {} + @Test fun contactDetail() = + shootFullScreen("screen-contact") { model, phase -> + androidx.compose.foundation.layout.Column { + ContactDetail(model, phase, phase.buddies[1]) {} + } } - } /** A call in progress, renderable because the look takes plain values. */ - @Test fun callActive() = shoot("screen-call-active") { - CallScreenBody( - peerName = "Hoang Nguyen", - status = "On a call", - actions = listOf( - CallAction("mute", KitIcon.Mic, "Mute") {}, - CallAction("end", KitIcon.CallEnd, "End", isPrimary = true, - tint = androidx.compose.material3.MaterialTheme.colorScheme.error, - iconColor = androidx.compose.ui.graphics.Color.White) {}, - ), - ) - } + @Test fun callActive() = + shoot("screen-call-active") { + CallScreenBody( + peerName = "Hoang Nguyen", + status = "On a call", + actions = + listOf( + CallAction("mute", KitIcon.Mic, "Mute") {}, + CallAction( + "end", + KitIcon.CallEnd, + "End", + isPrimary = true, + tint = androidx.compose.material3.MaterialTheme.colorScheme.error, + iconColor = androidx.compose.ui.graphics.Color.White, + ) {}, + ), + ) + } - @Test fun callRinging() = shoot("screen-call-ringing") { - CallScreenBody( - peerName = "Hoang Nguyen", - status = "Incoming call", - actions = listOf( - CallAction("decline", KitIcon.CallEnd, "Decline", - tint = androidx.compose.material3.MaterialTheme.colorScheme.error, - iconColor = androidx.compose.ui.graphics.Color.White) {}, - CallAction("accept", KitIcon.Call, "Answer", isPrimary = true, - tint = Palette.Teal, - iconColor = androidx.compose.ui.graphics.Color.White) {}, - ), - warning = "This server has no call relay, so the call only connects " - + "if a direct path exists between the two networks.", - ) - } + @Test fun callRinging() = + shoot("screen-call-ringing") { + CallScreenBody( + peerName = "Hoang Nguyen", + status = "Incoming call", + actions = + listOf( + CallAction( + "decline", + KitIcon.CallEnd, + "Decline", + tint = androidx.compose.material3.MaterialTheme.colorScheme.error, + iconColor = androidx.compose.ui.graphics.Color.White, + ) {}, + CallAction( + "accept", + KitIcon.Call, + "Answer", + isPrimary = true, + tint = Palette.Teal, + iconColor = androidx.compose.ui.graphics.Color.White, + ) {}, + ), + warning = + "This server has no call relay, so the call only connects " + + "if a direct path exists between the two networks.", + ) + } /** The kit's buttons, so a change to one is visible without hunting. */ - @Test fun buttons() = shoot("buttons") { - Column { - KitPrimaryButton(text = "Create group", onClick = {}) - KitSecondaryButton(text = "Accept new keys", onClick = {}, danger = true) - KitChip(text = "online", selected = true, onClick = {}) - KitChip(text = "away", selected = false, onClick = {}) + @Test fun buttons() = + shoot("buttons") { + Column { + KitPrimaryButton(text = "Create group", onClick = {}) + KitSecondaryButton(text = "Accept new keys", onClick = {}, danger = true) + KitChip(text = "online", selected = true, onClick = {}) + KitChip(text = "away", selected = false, onClick = {}) + } } - } /** * Render one composable inside the app's theme and write it out. - * - * `Surface` with the theme background, not a bare composable: without a - * background the PNG has a transparent ground and every colour judgement - * made from it is wrong. */ + /** A phase with enough in it to exercise every row shape on a screen. */ - private fun samplePhase() = MessengerViewModel.Phase.SignedIn( - handle = "fnreviewalpha", - userId = 31, - deviceId = "74d869d7-4667-4d97-854b-80ee684aaacc", - relay = "relay.flamenet.io", - fnid = "fnid:kx7q2m4n8p3r5t7v9wxyz0123456789abcdefghjkmn", - status = "Signed in on this device.", - buddies = listOf( - MessengerApi.Buddy(userId = 32, handle = "fnreviewbeta", - displayName = "fnreviewbeta", presence = "online"), - MessengerApi.Buddy(userId = 33, handle = "hoang", - displayName = "Hoang Nguyen", presence = "away"), - ), - buddiesLoaded = true, - groupsLoaded = true, - // The row's right-hand column and second line only exist when there is - // a thread behind them, so the render has to have one or it photographs - // the empty state and looks like the change never landed. - previews = mapOf( - 32L to Session.Preview( - text = "Sure! Please give me the campaign details soon", - at = System.currentTimeMillis() - 25 * 60_000L, mine = false), - 33L to Session.Preview( - text = "okay let me see", - at = System.currentTimeMillis() - 26 * 3600_000L, mine = true), - ), - unread = setOf(io.flamenet.e2e.Vault.threadForUser(32)), - stories = listOf( - io.flamenet.e2e.Vault.Story( - id = "s1", authorId = 33, mine = false, - ref = io.flamenet.e2e.Attachment.Ref( - kind = io.flamenet.e2e.Attachment.STORY, - id = "blob1", key = ByteArray(32), digest = ByteArray(32), - bytes = 1024, mime = "image/jpeg", width = 1200, height = 900, - expires = System.currentTimeMillis() / 1000.0 + 7 * 3600), - caption = "The view from the workshop this morning.", - postedAt = System.currentTimeMillis(), seen = false), - io.flamenet.e2e.Vault.Story( - id = "s2", authorId = 32, mine = false, - ref = io.flamenet.e2e.Attachment.Ref( - kind = io.flamenet.e2e.Attachment.STORY, - id = "blob2", key = ByteArray(32), digest = ByteArray(32), - bytes = 1024, mime = "image/jpeg", width = 1200, height = 900, - expires = System.currentTimeMillis() / 1000.0 + 1800), - caption = "", postedAt = System.currentTimeMillis(), seen = true), - ), - calls = listOf( - Session.CallEntry(peerId = 32, incoming = true, connected = false, - at = System.currentTimeMillis() - 40 * 60_000L), - Session.CallEntry(peerId = 33, incoming = false, connected = true, - at = System.currentTimeMillis() - 5 * 3600_000L), - Session.CallEntry(peerId = 32, incoming = true, connected = true, - at = System.currentTimeMillis() - 30 * 3600_000L), - ), - notifications = listOf( - MessengerApi.Notification(1, "contact request", - "Hoang Nguyen wants to add you", read = false), - MessengerApi.Notification(2, "missed call", - "You missed a call from fnreviewbeta", read = true), - ), - ) + private fun samplePhase() = + MessengerViewModel.Phase.SignedIn( + handle = "fnreviewalpha", + userId = 31, + deviceId = "74d869d7-4667-4d97-854b-80ee684aaacc", + relay = "relay.flamenet.io", + fnid = "fnid:kx7q2m4n8p3r5t7v9wxyz0123456789abcdefghjkmn", + status = "Signed in on this device.", + buddies = + listOf( + MessengerApi.Buddy( + userId = 32, + handle = "fnreviewbeta", + displayName = "fnreviewbeta", + presence = "online", + ), + MessengerApi.Buddy( + userId = 33, + handle = "hoang", + displayName = "Hoang Nguyen", + presence = "away", + ), + ), + buddiesLoaded = true, + groupsLoaded = true, + // The row's right-hand column and second line only exist when there is + // a thread behind them, so the render has to have one or it photographs + // the empty state and looks like the change never landed. + previews = + mapOf( + 32L to + Session.Preview( + text = "Sure! Please give me the campaign details soon", + at = System.currentTimeMillis() - 25 * 60_000L, + mine = false, + ), + 33L to + Session.Preview( + text = "okay let me see", + at = System.currentTimeMillis() - 26 * 3600_000L, + mine = true, + ), + ), + unread = + setOf( + io.flamenet.e2e.Vault + .threadForUser(32), + ), + stories = + listOf( + io.flamenet.e2e.Vault.Story( + id = "s1", + authorId = 33, + mine = false, + ref = + io.flamenet.e2e.Attachment.Ref( + kind = io.flamenet.e2e.Attachment.STORY, + id = "blob1", + key = ByteArray(32), + digest = ByteArray(32), + bytes = 1024, + mime = "image/jpeg", + width = 1200, + height = 900, + expires = System.currentTimeMillis() / 1000.0 + 7 * 3600, + ), + caption = "The view from the workshop this morning.", + postedAt = System.currentTimeMillis(), + seen = false, + ), + io.flamenet.e2e.Vault.Story( + id = "s2", + authorId = 32, + mine = false, + ref = + io.flamenet.e2e.Attachment.Ref( + kind = io.flamenet.e2e.Attachment.STORY, + id = "blob2", + key = ByteArray(32), + digest = ByteArray(32), + bytes = 1024, + mime = "image/jpeg", + width = 1200, + height = 900, + expires = System.currentTimeMillis() / 1000.0 + 1800, + ), + caption = "", + postedAt = System.currentTimeMillis(), + seen = true, + ), + ), + calls = + listOf( + Session.CallEntry( + peerId = 32, + incoming = true, + connected = false, + at = System.currentTimeMillis() - 40 * 60_000L, + ), + Session.CallEntry( + peerId = 33, + incoming = false, + connected = true, + at = System.currentTimeMillis() - 5 * 3600_000L, + ), + Session.CallEntry( + peerId = 32, + incoming = true, + connected = true, + at = System.currentTimeMillis() - 30 * 3600_000L, + ), + ), + notifications = + listOf( + MessengerApi.Notification( + 1, + "contact request", + "Hoang Nguyen wants to add you", + read = false, + ), + MessengerApi.Notification( + 2, + "missed call", + "You missed a call from fnreviewbeta", + read = true, + ), + ), + ) /** The same screen with nothing on it, the state a new account sees. */ - @Test fun alertsEmpty() = shoot("screen-alerts-empty") { - val app = androidx.test.core.app.ApplicationProvider - .getApplicationContext() - androidx.compose.foundation.layout.Column { - NotificationsScreen(MessengerViewModel(app), - samplePhase().copy(notifications = emptyList())) + @Test fun alertsEmpty() = + shoot("screen-alerts-empty") { + val app = + androidx.test.core.app.ApplicationProvider + .getApplicationContext() + androidx.compose.foundation.layout.Column { + NotificationsScreen( + MessengerViewModel(app), + samplePhase().copy(notifications = emptyList()), + ) + } } - } private fun shootFullScreen( name: String, content: @Composable (MessengerViewModel, MessengerViewModel.Phase.SignedIn) -> Unit, ) { - val app = androidx.test.core.app.ApplicationProvider - .getApplicationContext() + val app = + androidx.test.core.app.ApplicationProvider + .getApplicationContext() val model = MessengerViewModel(app) val phase = samplePhase() shoot(name) { content(model, phase) } } - private fun shoot(name: String, content: @Composable () -> Unit) { + private fun shoot( + name: String, + content: @Composable () -> Unit, + ) { compose.setContent { MessengerTheme { Surface(modifier = Modifier.fillMaxSize()) { @@ -302,20 +398,16 @@ class ScreenshotTest { } compose.waitForIdle() - // Draw the view, do not `captureToImage()`. Compose's capture - // helper goes through `PixelCopy` and waits for a frame-commit - // callback that Robolectric never delivers, so every render died as - // "Condition still not satisfied after 2000 ms", which reads as a - // hung composition and is nothing of the sort. `View.draw` into a - // software canvas produces the same pixels with no window involved. + // Draw the view, do not `captureToImage()`. var shot: Bitmap? = null compose.activityRule.scenario.onActivity { activity -> val view = activity.window.decorView - val bitmap = Bitmap.createBitmap( - view.width.coerceAtLeast(1), - view.height.coerceAtLeast(1), - Bitmap.Config.ARGB_8888, - ) + val bitmap = + Bitmap.createBitmap( + view.width.coerceAtLeast(1), + view.height.coerceAtLeast(1), + Bitmap.Config.ARGB_8888, + ) view.draw(Canvas(bitmap)) shot = bitmap } @@ -326,6 +418,8 @@ class ScreenshotTest { println("wrote ${File(out, "$name.png").absolutePath} (${bitmap.width}x${bitmap.height})") } - private fun requireNonNull(value: T?, message: String): T = - value ?: throw AssertionError(message) + private fun requireNonNull( + value: T?, + message: String, + ): T = value ?: throw AssertionError(message) } diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/SealedGroupListTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/SealedGroupListTest.kt index 207dfd1..6072f2b 100644 --- a/app/src/test/kotlin/io/flamenet/messenger/app/SealedGroupListTest.kt +++ b/app/src/test/kotlin/io/flamenet/messenger/app/SealedGroupListTest.kt @@ -8,6 +8,9 @@ import io.flamenet.e2e.Vault import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer import okhttp3.mockwebserver.RecordedRequest +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config import javax.crypto.KeyGenerator import javax.crypto.SecretKey import kotlin.test.AfterTest @@ -15,27 +18,13 @@ import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config /** * Which roster the group list believes, when the relay offers a different one. - * - * The relay must not win. A sealed group's members come out of a blob - * the owner signed. `GET /e2e/groups` carries no signature at all, so if the - * plaintext answer were preferred, or merely allowed to shadow the sealed one - * for the same gid, the server would be back in charge of deciding who is in - * a group, which is the whole thing sealing exists to stop. - * - * Robolectric for the same reason as the other `:app` tests: this is an - * Android module, so its unit-test classpath has the stubbed `org.json` that - * returns null from everything, and the vault stores JSON. */ @RunWith(RobolectricTestRunner::class) @Config(sdk = [34]) class SealedGroupListTest { - private lateinit var server: MockWebServer /** The blob the relay hands back for the sealed fetch. */ @@ -52,60 +41,95 @@ class SealedGroupListTest { override fun dispatch(request: RecordedRequest): MockResponse { val path = request.path.orEmpty() return when { - "/e2e/groups/fetch" in path -> json( - """{"groups":[{"gid":"g1","owner_key":"","roster_blob":"$sealedBlob",""" + - """"roster_version":1}]}""") - "/e2e/groups" in path -> json( - """{"groups":[{"gid":"g1","name":"Group","owner":1,"members":[""" + - plaintextRoster.joinToString(",") { """{"user_id":$it}""" } + "]}]}") - else -> json("{}") + "/e2e/groups/fetch" in path -> { + json( + """{"groups":[{"gid":"g1","owner_key":"","roster_blob":"$sealedBlob",""" + + """"roster_version":1}]}""", + ) + } + + "/e2e/groups" in path -> { + json( + """{"groups":[{"gid":"g1","name":"Group","owner":1,"members":[""" + + plaintextRoster.joinToString(",") { """{"user_id":$it}""" } + "]}]}", + ) + } + + else -> { + json("{}") + } } } } @BeforeTest fun start() { - server = MockWebServer().also { it.dispatcher = Relay(); it.start() } + server = + MockWebServer().also { + it.dispatcher = Relay() + it.start() + } + } + + @AfterTest fun stop() { + server.shutdown() } - @AfterTest fun stop() { server.shutdown() } private class MapStorage : Vault.Storage { private val records = mutableMapOf() + override fun read(name: String) = records[name] - override fun write(name: String, bytes: ByteArray) { records[name] = bytes } - override fun delete(name: String) { records.remove(name) } + + override fun write( + name: String, + bytes: ByteArray, + ) { + records[name] = bytes + } + + override fun delete(name: String) { + records.remove(name) + } + override fun names() = records.keys.toList() } - private fun key(): SecretKey = - KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() + private fun key(): SecretKey = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() private fun session(): Pair { val identity = DeviceIdentity.create() val vault = Vault(MapStorage(), key()).also { it.saveIdentity(identity) } val base = server.url("/").toString() - val s = Session( - handle = "alice", - userId = 1, - identity = identity, - vault = vault, - relay = RelayClient(base, "alice", "app pass word"), - api = MessengerApi(base, "alice", "app pass word"), - ) + val s = + Session( + handle = "alice", + userId = 1, + identity = identity, + vault = vault, + relay = RelayClient(base, "alice", "app pass word"), + api = MessengerApi(base, "alice", "app pass word"), + ) return s to vault } - private fun json(body: String) = MockResponse() - .setResponseCode(200) - .setHeader("Content-Type", "application/json") - .setBody(body) + private fun json(body: String) = + MockResponse() + .setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody(body) /** Hold the tag and key for `g1`, sealing the roster the test names. */ - private fun holdSealed(vault: Vault, members: List, name: String) { + private fun holdSealed( + vault: Vault, + members: List, + name: String, + ) { val groupKey = SealedRoster.newGroupKey() val tags = members.associate { it.toString() to SealedRoster.newTag() } sealedBlob = SealedRoster.seal(SealedRoster.Contents(members, tags, name), groupKey) - vault.saveGroupSecrets("g1", Vault.GroupSecrets( - tag = tags["1"] ?: "", key = groupKey, ownerPrivate = null, version = 1)) + vault.saveGroupSecrets( + "g1", + Vault.GroupSecrets(tag = tags["1"] ?: "", key = groupKey, ownerPrivate = null, version = 1), + ) } @Test @@ -123,7 +147,7 @@ class SealedGroupListTest { /** * The relay names a third account as a member of a group whose roster is - * sealed. The blob says otherwise, and the blob is what the owner signed. + * sealed. */ @Test fun `the relay cannot add a member to a sealed group`() { @@ -133,8 +157,11 @@ class SealedGroupListTest { plaintextRoster = listOf(1L, 2L, 99L) val g1 = session.namedGroups().first { it.id == "g1" } - assertEquals(listOf(1L, 2L), g1.memberIds.sorted(), - "the relay's roster replaced the one out of the blob") + assertEquals( + listOf(1L, 2L), + g1.memberIds.sorted(), + "the relay's roster replaced the one out of the blob", + ) assertTrue(99L !in g1.memberIds, "a relay-supplied member entered a sealed group") } @@ -145,14 +172,15 @@ class SealedGroupListTest { holdSealed(vault, listOf(1L, 2L), "Book club") plaintextRoster = listOf(1L, 2L) - assertEquals(1, session.namedGroups().count { it.id == "g1" }, - "the group is listed twice, sealed and plaintext") + assertEquals( + 1, + session.namedGroups().count { it.id == "g1" }, + "the group is listed twice, sealed and plaintext", + ) } /** - * A group we hold no secrets for is still the relay's to describe. That - * is the unsealed model, unchanged, dropping those would make somebody's - * older groups vanish the moment sealing shipped. + * A group we hold no secrets for is still the relay's to describe. */ @Test fun `an unsealed group still comes from the relay`() { diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/SiteConfigIdentityTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/SiteConfigIdentityTest.kt index 90b8bc9..56dba64 100644 --- a/app/src/test/kotlin/io/flamenet/messenger/app/SiteConfigIdentityTest.kt +++ b/app/src/test/kotlin/io/flamenet/messenger/app/SiteConfigIdentityTest.kt @@ -9,23 +9,16 @@ import org.junit.Test /** * The relay is asked what it is; the answer has to survive the trip to the UI. - * - * These guard a bug that shipped silently: `discovered` was a `var` in the - * class body, so it was excluded from the data class `equals()`. Writing the - * discovered config back into a `MutableStateFlow`, which conflates on - * equality, was a no-op. The relay answered correctly and the app ignored it: - * the sign-in screen sat on "Checking what this server uses…" forever, and the - * account mode stayed LOCAL for a relay that had just said SHARED. */ class SiteConfigIdentityTest { - - private val shared = RelayDiscovery.Descriptor( - audience = "relay.flamenet.io", - accounts = RelayDiscovery.Accounts.SHARED, - accountsUrl = "https://accounts.flamenet.io", - federation = "closed", - protocolVersions = listOf(1, 2, 3), - ) + private val shared = + RelayDiscovery.Descriptor( + audience = "relay.flamenet.io", + accounts = RelayDiscovery.Accounts.SHARED, + accountsUrl = "https://accounts.flamenet.io", + federation = "closed", + protocolVersions = listOf(1, 2, 3), + ) @Test fun `a discovered config is not equal to the same relay undiscovered`() { diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/StoryReportTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/StoryReportTest.kt index 7328445..378408f 100644 --- a/app/src/test/kotlin/io/flamenet/messenger/app/StoryReportTest.kt +++ b/app/src/test/kotlin/io/flamenet/messenger/app/StoryReportTest.kt @@ -7,24 +7,28 @@ import kotlin.test.assertTrue /** * Reporting a story (spec §6.25, ios#56). - * - * A report is the one thing this app sends that the server can read. - * Everything else is sealed. The relay cannot open a story, so a report about - * one is only actionable if the reporter hands over what they saw, which makes - * the consent, and the exact set of things that travel, the feature. */ class StoryReportTest { - /** - * The wire values must match what the relay accepts. A reason the relay - * rejects is a report somebody believed they had filed. + * The wire values must match what the relay accepts. */ @Test fun `every reason matches the relays enumeration`() { - val accepted = setOf("abuse", "harassment", "spam", - "impersonation", "csam", "other") - assertEquals(accepted, - MessengerViewModel.ReportReason.entries.map { it.wire }.toSet()) + val accepted = + setOf( + "abuse", + "harassment", + "spam", + "impersonation", + "csam", + "other", + ) + assertEquals( + accepted, + MessengerViewModel.ReportReason.entries + .map { it.wire } + .toSet(), + ) } /** A raw wire value in a picker is the wire format leaking into the UI. */ @@ -38,22 +42,26 @@ class StoryReportTest { /** * CSAM must be reportable as itself rather than folded into abuse: it - * carries a different obligation, and an operator cannot triage what - * nobody can name. + * carries a different obligation, and an operator cannot triage what nobody + * can name. */ @Test fun `csam is its own reason`() { - assertTrue(MessengerViewModel.ReportReason.entries - .any { it == MessengerViewModel.ReportReason.CSAM }) + assertTrue( + MessengerViewModel.ReportReason.entries + .any { it == MessengerViewModel.ReportReason.CSAM }, + ) } /** - * Without one, the picker forces a person to mis-categorise to - * report at all. + * Without one, the picker forces a person to mis-categorise to report at + * all. */ @Test fun `there is a reason for anything else`() { - assertTrue(MessengerViewModel.ReportReason.entries - .any { it == MessengerViewModel.ReportReason.OTHER }) + assertTrue( + MessengerViewModel.ReportReason.entries + .any { it == MessengerViewModel.ReportReason.OTHER }, + ) } } diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/WalletDisplayTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/WalletDisplayTest.kt index ed827f8..ce17455 100644 --- a/app/src/test/kotlin/io/flamenet/messenger/app/WalletDisplayTest.kt +++ b/app/src/test/kotlin/io/flamenet/messenger/app/WalletDisplayTest.kt @@ -6,15 +6,9 @@ import kotlin.test.assertTrue /** * How the wallet screen states a number. - * - * The rule this suite exists for: an unread balance is NOT zero. A screen - * that draws 0.00 because a node did not answer has told somebody their money is - * gone, on every flaky connection. The screen renders null as an em dash, and - * these cover the arithmetic underneath it. */ class WalletDisplayTest { - - // ---- Lamports as SOL ------------------------------------------------- + // ---- Lamports as SOL -------------------------------------------------. @Test fun `a whole number of SOL reads without a decimal tail`() { @@ -30,8 +24,7 @@ class WalletDisplayTest { } /** - * Zero is a real balance and reads as "0", not as an empty string. The - * trimming that removes a decimal tail must not eat the whole number. + * Zero is a real balance and reads as "0", not as an empty string. */ @Test fun `an empty account reads as zero, not as nothing`() { @@ -39,20 +32,17 @@ class WalletDisplayTest { } /** - * Five SOL already exceeds Int.MAX_VALUE in lamports. Anything holding a - * balance in an Int would overflow long before the number got interesting. + * Five SOL already exceeds Int.MAX_VALUE in lamports. */ @Test fun `a large balance is not truncated`() { assertEquals("9223372036.854775807", WalletChain.sol(Long.MAX_VALUE)) } - // ---- What can actually be sent --------------------------------------- + // ---- What can actually be sent ---------------------------------------. /** - * An account below the rent-exempt minimum is RECLAIMED by the - * network. Emptying a wallet to the last lamport does not leave it empty, - * it deletes it, so "send everything" has to stop short of everything. + * An account below the rent-exempt minimum is RECLAIMED by the network. */ @Test fun `spendable leaves the fee and the rent-exempt minimum behind`() { @@ -78,12 +68,10 @@ class WalletDisplayTest { assertEquals(1L, WalletChain.spendable(floor + 1)) } - // ---- Which node ------------------------------------------------------ + // ---- Which node ------------------------------------------------------. /** - * http is ignored rather than honoured. A balance read over plain - * http is one anybody on the path can rewrite, and a wallet showing a number - * an attacker chose is worse than a wallet showing nothing. + * http is ignored rather than honoured. */ @Test fun `the default endpoint is https`() { diff --git a/client/src/main/kotlin/io/flamenet/client/AccountIdentity.kt b/client/src/main/kotlin/io/flamenet/client/AccountIdentity.kt index bbbab57..2106017 100644 --- a/client/src/main/kotlin/io/flamenet/client/AccountIdentity.kt +++ b/client/src/main/kotlin/io/flamenet/client/AccountIdentity.kt @@ -4,52 +4,23 @@ import io.flamenet.e2e.Ed25519 import io.flamenet.e2e.FNID /** - * Turning a passphrase into an address, and telling relays where to deliver. - * - * ## Why this exists at all - * - * A relay's `user_id` is a row number it assigned itself. Register at one relay - * and everything about you, your name there, your devices, your mailbox, is - * that relay's to keep or to lose. This makes the *identity* the user's - * instead: an Ed25519 key stretched from the passphrase, whose public half is - * the address. No server issues it, so no server can take it away. - * - * The practical consequence is the one worth stating: sign in with the same - * passphrase at a relay that has never heard of you and you get the same - * address. Contacts who accepted you before still see the same person. A - * relay shutting down costs a mailbox, not an identity. - * - * ## Why it is here and not in the engine - * - * The engine holds no network. Deriving the key and building the bytes to sign - * are engine work and live in [FNID] and `AccountKdf`; presenting the result to - * a relay is client work and lives here, so the engine stays testable without - * one and this stays swappable without touching crypto. + * Turning a passphrase into an address, and telling relays where to deliver. ## + * Why this exists at all A relay's `user_id` is a row number it assigned + * itself. */ object AccountIdentity { - /** The address for an account seed. Derived, never fetched. */ fun address(seed: ByteArray): String = FNID.address(Ed25519.fromSeed(seed).publicKey) /** * Claim the address at [relay], learning which audience to bind from the * relay itself. - * - * The audience is not the caller's to guess. It is the string the - * relay checks the signature against, so a caller that inferred it from a - * URL would produce a claim that verifies nowhere, silently, because at - * the point of failure a wrong audience and a wrong key look identical. - * Reading it from the descriptor means the only way to get it wrong is for - * the relay to lie about its own name, which costs it the claim rather than - * us the identity. - * - * Self-certifying: the signature is made with the very key being - * registered, so the relay confirms possession and forms no opinion about - * whose key it is, the property that lets a hostile relay be tolerated. - * - * Returns the address the relay recorded. */ - fun claim(relay: RelayClient, seed: ByteArray, userId: Long): String { + fun claim( + relay: RelayClient, + seed: ByteArray, + userId: Long, + ): String { val key = Ed25519.fromSeed(seed) val pub = b64(key.publicKey) val audience = relay.relayDescriptor().getString("audience") @@ -59,26 +30,12 @@ object AccountIdentity { /** * Publish where this account keeps mailboxes, to every relay in the list. - * - * Every listed relay is told, not just the one being added: a home that - * does not know it is one will still accept mail, but a *sender* asking it - * for the record would be handed a stale list and would skip the others. - * Delivering the record everywhere is what keeps them from disagreeing. - * - * Takes a client per home, and that is the shape of the feature rather - * than an implementation detail: a relay will not accept a record from - * somebody with no account there, and a token minted by one relay is - * rejected by every other because the audience is bound. So homing - * somewhere means registering there first, and a map makes that a - * compile-time fact instead of a runtime surprise. - * - * Returns the relays that refused, with why. Reported rather than thrown on - * the first failure: one home being unreachable while the rest accept is a - * partial success the user can act on, and collapsing it into a single - * error would hide which. */ fun publishHomes( - seed: ByteArray, fnid: String, serial: Int, homes: Map + seed: ByteArray, + fnid: String, + serial: Int, + homes: Map, ): Map { val relays = homes.keys.toList() val sig = b64(Ed25519.sign(seed, FNID.homesCanonical(fnid, serial, relays))) @@ -93,12 +50,11 @@ object AccountIdentity { /** * The next serial to publish, read from what is already out there rather * than from local state. - * - * A device that was offline while another added a relay would otherwise - * reuse a serial and have its record refused as a rollback, by the very - * check that exists to stop an attacker doing the same thing. */ - fun nextSerial(fnid: String, clients: Collection): Int { + fun nextSerial( + fnid: String, + clients: Collection, + ): Int { var highest = 0 for (c in clients) { val record = runCatching { c.homes(fnid) }.getOrNull() ?: continue @@ -109,12 +65,13 @@ object AccountIdentity { /** * Check a home record against the address it claims to be for. - * - * Needs nothing but the record, because the address *is* the public key, - * which is the point. A sender must not have to ask a relay whether that - * relay is telling the truth about where to deliver. */ - fun verifyHomes(fnid: String, serial: Int, relays: List, signatureB64: String): Boolean { + fun verifyHomes( + fnid: String, + serial: Int, + relays: List, + signatureB64: String, + ): Boolean { val pub = FNID.decode(fnid) ?: return false val sig = runCatching { unb64(signatureB64) }.getOrNull() ?: return false return Ed25519.verify(pub, sig, FNID.homesCanonical(fnid, serial, relays)) @@ -123,27 +80,6 @@ object AccountIdentity { /** * Relays named by a record, but only once it verifies AND is not older than * one already seen. - * - * A signature alone does not stop a rollback, and that is the whole - * reason the serial exists. An older record is *signed*: it was - * published by the account holder, before they added a second home. A relay - * that wants to be somebody's only mailbox does not have to forge anything - * it serves the record it saw first, that record verifies perfectly, and - * the sender delivers to one relay believing it is delivering to all of - * them. - * - * That is the attack multi-homing exists to survive, and until this both - * clients checked only the signature. The relay refuses a lower serial on - * WRITE; nothing refused one on READ. - * - * [floor] is the highest serial this device has already accepted for - * this address, or null on first sight. Equal is fine, the same record - * re-read is the ordinary case. Only strictly lower is a rollback. - * - * A refusal here is not proof of an attack, and the caller must not - * present it as one. A relay that has fallen behind serves an old - * record for honest reasons; what the client can say is that it has seen a - * newer one, and that it will keep using it. */ fun verifiedRelays( record: org.json.JSONObject, @@ -171,9 +107,8 @@ object AccountIdentity { } // The module's own helper, so this encodes identically to every other blob - // the transport sends. `android.util.Base64` is deliberately not reachable - // here: this module is plain JVM precisely so its tests run against a real - // relay with no emulator. + // the transport sends. private fun b64(b: ByteArray): String = Wire.b64(b) + private fun unb64(s: String): ByteArray = Wire.unb64(s) } diff --git a/client/src/main/kotlin/io/flamenet/client/AccountsClient.kt b/client/src/main/kotlin/io/flamenet/client/AccountsClient.kt index 61bea65..3a73907 100644 --- a/client/src/main/kotlin/io/flamenet/client/AccountsClient.kt +++ b/client/src/main/kotlin/io/flamenet/client/AccountsClient.kt @@ -12,50 +12,30 @@ import java.security.SecureRandom /** * The shared FlameNet account service, one credential across Messenger and * Files. - * - * The passphrase never leaves the device. It is stretched here and only - * the derived secret is sent, which is why sign-in is two round trips rather - * than one: the salt and iteration count are per account, and the service - * cannot derive the secret itself. That is the point, and collapsing it - * to one call would mean sending the passphrase. - * - * Used only for relays whose accounts live in the shared service. A self-hosted - * relay authenticates against its own table and never touches this, see - * [SiteConfig], which decides from the hostname rather than from anything the - * relay says about itself. */ class AccountsClient( baseUrl: String = "https://accounts.flamenet.io", private val http: OkHttpClient = RelayClient.defaultHttpClient(), ) { - private val base = baseUrl.trimEnd('/').toHttpUrl() /** * The product a session is scoped to. - * - * Sessions are per product, and that is the privacy property. The - * token carries a pseudonym derived for this product, so Messenger and - * Files receive different opaque ids for the same person and cannot - * correlate their users with each other. */ private val defaultProduct = "messenger" /** * What the service advertises for a handle. - * - * An unknown handle gets a deterministic decoy salt, not an error, - * so this route cannot be used to test whether somebody has an account. - * Treat a success as "here are parameters", never as "this handle exists", - * a UI that says "no such account" here undoes the whole design. */ - data class Params(val salt: ByteArray, val kdfId: Int, val iterations: Int) + data class Params( + val salt: ByteArray, + val kdfId: Int, + val iterations: Int, + ) /** * @property accountKeySeed the 32-byte Ed25519 seed whose public half is - * this account's address. Derived from the same stretch as the auth - * secret and never sent anywhere, see [AccountKdf.signInSecrets] for - * why it comes back here rather than being asked for separately. + * this account's address. */ data class Session( val token: String, @@ -64,54 +44,64 @@ class AccountsClient( val accountKeySeed: ByteArray? = null, ) - sealed class Failure(message: String) : Exception(message) { + sealed class Failure( + message: String, + ) : Exception(message) { /** Wrong handle and wrong passphrase are the same answer, on purpose. */ object BadCredentials : Failure("that handle or passphrase isn't right") /** * The session is no longer accepted, expired, or revoked elsewhere. - * - * Not the same as a wrong passphrase, and saying so matters. - * Every 401 and 403 used to become [BadCredentials], so a session that - * simply aged out told the user "that handle or passphrase isn't - * right", in the middle of sending a message, hours after they signed - * in successfully. The natural reading is that their password stopped - * working or was changed by someone else, and the natural response is - * to change a password that was never wrong. */ object SessionExpired : Failure("your session has expired, sign in again") + object HandleTaken : Failure("that handle is already taken") /** * A 409 is not always a taken handle, and saying so was wrong. - * The relay answers 409 for a taken handle, a registered email, AND a - * handle too close to an existing one, three different problems with - * three different fixes. Collapsing them told somebody whose EMAIL was - * already registered to pick a different handle, which cannot work and - * gives them no way to discover why. - * - * The status is the transport's opinion; the `code` in the body is the - * relay's, and it is the one that means something. */ object EmailTaken : Failure("an account with that email already exists") - /** The handle folds onto an existing one, see the relay's - * `User.confusableSkeleton`. Changing a digit will not help. */ + /** + * The handle folds onto an existing one, see the relay's. + */ object HandleConfusable : Failure("that handle is too easily mistaken for another") object DisposableEmail : Failure("that email provider isn't accepted") + object BadHandle : Failure("that handle isn't allowed") + object BadEmail : Failure("that doesn't look like an email address") + object WeakPassword : Failure("that passphrase is too short") + object RateLimited : Failure("too many attempts, wait a few minutes") - class Service(val status: Int) : Failure("the account service returned $status") - class Transport(cause: String) : Failure("couldn't reach the account service: $cause") + + class Service( + val status: Int, + ) : Failure("the account service returned $status") + + class Transport( + cause: String, + ) : Failure("couldn't reach the account service: $cause") } fun parameters(handle: String): Params { - val url = base.newBuilder().addPathSegment("accounts").addPathSegment(handle) - .addPathSegment("params").build() - val json = send(Request.Builder().url(url).get().build()) + val url = + base + .newBuilder() + .addPathSegment("accounts") + .addPathSegment(handle) + .addPathSegment("params") + .build() + val json = + send( + Request + .Builder() + .url(url) + .get() + .build(), + ) return Params( salt = Wire.unb64(json.getString("kdfSalt")), kdfId = json.getInt("kdfID"), @@ -119,26 +109,30 @@ class AccountsClient( ) } - fun signIn(handle: String, passphrase: String, product: String = defaultProduct): Session { + fun signIn( + handle: String, + passphrase: String, + product: String = defaultProduct, + ): Session { val params = parameters(handle) - // The ADVERTISED iteration count wins over the local constant. An - // account created under an older count must still be derivable, and the - // service is the one that knows which, hardcoding it here silently - // locks out every older account. - val effective = AccountKdf.Params( - id = params.kdfId, - iterations = params.iterations, - saltBytes = params.salt.size, - ) - // Both secrets from ONE stretch. Asking for the account key separately - // would re-run 650,000 iterations for a value already in hand, several - // seconds on a phone, spent twice, to sign in once. + // The ADVERTISED iteration count wins over the local constant. + val effective = + AccountKdf.Params( + id = params.kdfId, + iterations = params.iterations, + saltBytes = params.salt.size, + ) + // Both secrets from ONE stretch. val (secret, accountKeySeed) = AccountKdf.signInSecrets(passphrase, params.salt, effective) - val json = post("sessions", JSONObject() - .put("handle", handle) - .put("authSecret", Wire.b64(secret)) - .put("product", product)) + val json = + post( + "sessions", + JSONObject() + .put("handle", handle) + .put("authSecret", Wire.b64(secret)) + .put("product", product), + ) return Session( token = json.getString("token"), pseudonym = json.optString("pseudonym", ""), @@ -148,89 +142,117 @@ class AccountsClient( } /** Create an account. The salt is generated here, not by the service. */ - fun register(handle: String, passphrase: String) { + fun register( + handle: String, + passphrase: String, + ) { val params = AccountKdf.Params.PBKDF2_V1 val salt = ByteArray(params.saltBytes).also { SecureRandom().nextBytes(it) } val secret = AccountKdf.authSecret(passphrase, salt, params) - post("accounts", JSONObject() - .put("handle", handle) - .put("authSecret", Wire.b64(secret)) - .put("kdfSalt", Wire.b64(salt)) - .put("kdfIterations", params.iterations)) + post( + "accounts", + JSONObject() + .put("handle", handle) + .put("authSecret", Wire.b64(secret)) + .put("kdfSalt", Wire.b64(salt)) + .put("kdfIterations", params.iterations), + ) } - private fun post(path: String, body: JSONObject): JSONObject = send( - Request.Builder() - .url(base.newBuilder().addPathSegments(path).build()) - .post(body.toString().toRequestBody(JSON)) - .header("Accept", "application/json") - .build(), - // Only these two actually present a passphrase, so only these two can - // meaningfully report one as wrong. - carriesCredentials = path == "sessions" || path == "accounts", - ) + private fun post( + path: String, + body: JSONObject, + ): JSONObject = + send( + Request + .Builder() + .url(base.newBuilder().addPathSegments(path).build()) + .post(body.toString().toRequestBody(JSON)) + .header("Accept", "application/json") + .build(), + // Only these two actually present a passphrase, so only these two can + // meaningfully report one as wrong. + carriesCredentials = path == "sessions" || path == "accounts", + ) - private fun send(request: Request, carriesCredentials: Boolean = false): JSONObject { - val response = try { - http.newCall(request).execute() - } catch (e: Exception) { - throw Failure.Transport(e.message ?: e.toString()) - } + private fun send( + request: Request, + carriesCredentials: Boolean = false, + ): JSONObject { + val response = + try { + http.newCall(request).execute() + } catch (e: Exception) { + throw Failure.Transport(e.message ?: e.toString()) + } response.use { val text = it.body?.string().orEmpty() when (it.code) { - in 200..299 -> return if (text.isBlank()) JSONObject() else try { - JSONObject(text) - } catch (_: Exception) { - throw Failure.Transport("unreadable response") + in 200..299 -> { + return if (text.isBlank()) { + JSONObject() + } else { + try { + JSONObject(text) + } catch (_: Exception) { + throw Failure.Transport("unreadable response") + } + } + } + + // The SAME status means different things depending on what was + // sent. + 401, 403 -> { + throw if (carriesCredentials) { + Failure.BadCredentials + } else { + Failure.SessionExpired + } + } + + // Read the relay's own code, not just the status. + 409, 400 -> { + throw failureFor(code(text), status = it.code) + } + + 429 -> { + throw Failure.RateLimited + } + + else -> { + throw Failure.Service(it.code) } - // The SAME status means different things depending on what - // was sent. Rejecting a passphrase is BadCredentials; refusing - // a token the user cannot see is an expired session. - 401, 403 -> - throw if (carriesCredentials) Failure.BadCredentials - else Failure.SessionExpired - // Read the relay's own code, not just the status. A - // 400 became a bare "the account service returned 400" and a - // 409 became "that handle is already taken" whatever the - // actual reason, so a weak passphrase, an unusable email and - // an already-registered address were all reported as something - // the person could not act on, or as the wrong problem - // entirely. The relay has always sent a specific code; nothing - // here ever read it. - 409, 400 -> throw failureFor(code(text), status = it.code) - 429 -> throw Failure.RateLimited - else -> throw Failure.Service(it.code) } } } /** * The relay's error code, or null when the body is not its error shape. - * - * Never throws. A body that cannot be parsed is a transport problem, not - * a reason to lose the status we already have. */ - private fun code(body: String): String? = runCatching { - JSONObject(body).optString("code").takeIf { it.isNotEmpty() } - }.getOrNull() + private fun code(body: String): String? = + runCatching { + JSONObject(body).optString("code").takeIf { it.isNotEmpty() } + }.getOrNull() /** * Falls back to the STATUS when the code is unknown, so a refusal this * build has never heard of still reports as a refusal rather than as a - * success or a crash. A relay newer than the app is the normal case, not - * the exception. + * success or a crash. */ - private fun failureFor(code: String?, status: Int): Failure = when (code) { - "username_taken" -> Failure.HandleTaken - "email_taken" -> Failure.EmailTaken - "username_confusable" -> Failure.HandleConfusable - "disposable_email" -> Failure.DisposableEmail - "bad_username" -> Failure.BadHandle - "bad_email" -> Failure.BadEmail - "weak_password" -> Failure.WeakPassword - else -> if (status == 409) Failure.HandleTaken else Failure.Service(status) - } + private fun failureFor( + code: String?, + status: Int, + ): Failure = + when (code) { + "username_taken" -> Failure.HandleTaken + "email_taken" -> Failure.EmailTaken + "username_confusable" -> Failure.HandleConfusable + "disposable_email" -> Failure.DisposableEmail + "bad_username" -> Failure.BadHandle + "bad_email" -> Failure.BadEmail + "weak_password" -> Failure.WeakPassword + else -> if (status == 409) Failure.HandleTaken else Failure.Service(status) + } private companion object { val JSON = "application/json".toMediaType() diff --git a/client/src/main/kotlin/io/flamenet/client/ChainRpc.kt b/client/src/main/kotlin/io/flamenet/client/ChainRpc.kt index f157964..a44bf30 100644 --- a/client/src/main/kotlin/io/flamenet/client/ChainRpc.kt +++ b/client/src/main/kotlin/io/flamenet/client/ChainRpc.kt @@ -9,63 +9,63 @@ import org.json.JSONObject /** * Talking to a chain node, for the wallet. - * - * The node is somebody else's server and it is not trusted with - * anything. It is asked what a public address holds and handed transactions - * that are already signed; it never sees a key, and it cannot move money. What - * it CAN do is lie about a balance or withhold a history, so nothing here - * treats an answer as authoritative beyond "this is what that node said". - * - * Which node somebody points at is a privacy decision, and the app has to - * say so. Asking a public RPC endpoint for the balance of an address ties - * that address to this device's IP, on somebody else's logs. Running your own - * node is the only answer that removes it; the default exists so the wallet - * works at all, not because it is private. */ class ChainRpc( val endpoint: String, private val http: OkHttpClient = OkHttpClient(), ) { - - sealed class Failure(message: String) : Exception(message) { + sealed class Failure( + message: String, + ) : Exception(message) { /** The transport refused before any JSON-RPC happened. */ - class Http(val code: Int) : Failure("The node answered with $code.") + class Http( + val code: Int, + ) : Failure("The node answered with $code.") /** - * The node's own words. It knows why it refused and we do not, so its - * message is carried rather than replaced with a guess. + * The node's own words. */ - class Node(val detail: String) : Failure(detail) + class Node( + val detail: String, + ) : Failure(detail) - class Malformed(val what: String) : - Failure("That node's answer wasn't something this app understands.") + class Malformed( + val what: String, + ) : Failure("That node's answer wasn't something this app understands.") } - // ---- Wire ------------------------------------------------------------ + // ---- Wire ------------------------------------------------------------. - private fun call(method: String, params: JSONArray): Any { - val body = JSONObject() - .put("jsonrpc", "2.0") - .put("id", 1) - .put("method", method) - .put("params", params) - .toString() - .toRequestBody("application/json".toMediaType()) + private fun call( + method: String, + params: JSONArray, + ): Any { + val body = + JSONObject() + .put("jsonrpc", "2.0") + .put("id", 1) + .put("method", method) + .put("params", params) + .toString() + .toRequestBody("application/json".toMediaType()) - val request = Request.Builder().url(endpoint).post(body).build() - val text = http.newCall(request).execute().use { response -> - if (!response.isSuccessful) throw Failure.Http(response.code) - response.body?.string().orEmpty() - } + val request = + Request + .Builder() + .url(endpoint) + .post(body) + .build() + val text = + http.newCall(request).execute().use { response -> + if (!response.isSuccessful) throw Failure.Http(response.code) + response.body?.string().orEmpty() + } - val json = runCatching { JSONObject(text) }.getOrNull() - ?: throw Failure.Malformed("not an object") + val json = + runCatching { JSONObject(text) }.getOrNull() + ?: throw Failure.Malformed("not an object") - // A JSON-RPC error arrives with HTTP 200. Reading only the status - // code takes a refusal for an answer, and then something fails further - // down with the node's own explanation already thrown away, which is - // the difference between "insufficient funds for rent" on screen and a - // parse error nobody can act on. + // A JSON-RPC error arrives with HTTP 200. json.optJSONObject("error")?.let { error -> throw Failure.Node(error.optString("message").ifEmpty { "The node refused." }) } @@ -73,14 +73,10 @@ class ChainRpc( return json.get("result") } - // ---- Reading --------------------------------------------------------- + // ---- Reading ---------------------------------------------------------. /** * The balance in lamports, the chain's smallest unit. - * - * Lamports, not SOL, all the way to the edge of the UI. Converting early - * means carrying a fractional value through code that compares and - * subtracts, and a rounding error in a balance is money. */ fun balance(address: String): Long { val result = call("getBalance", JSONArray().put(address)) @@ -90,45 +86,48 @@ class ChainRpc( } /** What has happened to an address, newest first. */ - fun history(address: String, limit: Int = 25): List { - val result = call( - "getSignaturesForAddress", - JSONArray().put(address).put(JSONObject().put("limit", limit)), - ) + fun history( + address: String, + limit: Int = 25, + ): List { + val result = + call( + "getSignaturesForAddress", + JSONArray().put(address).put(JSONObject().put("limit", limit)), + ) val rows = result as? JSONArray ?: throw Failure.Malformed("history") return (0 until rows.length()).mapNotNull { Entry.from(rows.optJSONObject(it)) } } /** * One line of an address's history. - * - * A FAILED transaction still appears in an address's history. Not - * showing that it failed would be the worst kind of wrong: somebody would - * believe a payment went through. */ data class Entry( val signature: String, val slot: Long, /** * Absent while a transaction is very new, the node has it but has not - * dated it yet. Null means "just now", never 1970. + * dated it yet. */ val blockTimeSeconds: Long?, val failed: Boolean, ) { companion object { fun from(json: JSONObject?): Entry? { - val signature = json?.optString("signature")?.takeIf { it.isNotEmpty() } - ?: return null + val signature = + json?.optString("signature")?.takeIf { it.isNotEmpty() } + ?: return null return Entry( signature = signature, slot = json.optLong("slot", 0), - blockTimeSeconds = if (json.isNull("blockTime")) null - else json.optLong("blockTime"), - // `err` is an object or an array depending on the - // failure, and `null` when there was none. Testing for - // "not null" rather than for a shape is what makes both - // kinds of failure visible. + blockTimeSeconds = + if (json.isNull("blockTime")) { + null + } else { + json.optLong("blockTime") + }, + // `err` is an object or an array depending on the failure, + // and `null` when there was none. failed = !json.isNull("err"), ) } diff --git a/client/src/main/kotlin/io/flamenet/client/Conversations.kt b/client/src/main/kotlin/io/flamenet/client/Conversations.kt index 4047690..a8a2061 100644 --- a/client/src/main/kotlin/io/flamenet/client/Conversations.kt +++ b/client/src/main/kotlin/io/flamenet/client/Conversations.kt @@ -12,17 +12,6 @@ import io.flamenet.e2e.Vault /** * Sending and receiving, on top of sessions the vault keeps. - * - * A person is not a device. Everyone may be signed in on a phone, a - * tablet and the web at once, and a message encrypted to one of them is - * invisible on the other two. So a send is a fan-out: one envelope per - * recipient device, each under its own ratchet. A client that encrypts to - * "the" device works perfectly in testing, where everybody has one. - * - * Every send and every receive moves ratchet state, and unsaved ratchet - * state is a broken conversation. Each operation persists before it returns. - * That costs a write per message and buys the difference between an app that - * survives being killed and one that has to be reinstalled. */ private const val STORY_LIFETIME_SECONDS = 24.0 * 60 * 60 @@ -32,65 +21,42 @@ class Conversations( private val relay: RelayClient, /** * Identity pinning and key-log enforcement (§19). - * - * Constructed here rather than passed in by every call site, and never - * null: a send path with an optional safety check is a send path that ends - * up shipped with it optional-ly absent. */ val trust: Trust = Trust(identity, vault, relay), ) { - - /** Set when an avatar control message was consumed; the UI reads and - * clears it to know the avatar map needs re-reading. */ + /** + * Set when an avatar control message was consumed; the UI reads and. + */ @Volatile var avatarsUpdated: Boolean = false /** * Group names contacts have announced, awaiting a membership check. - * - * Claims, not facts. Anyone who can reach us can send one of these, - * naming any gid they like, and a name adopted on the sender's word alone - * would put whatever a stranger wants into somebody's chat list, a place - * to write a lie where the reader has no reason to doubt it. The roster - * lives a layer up, so the check does too; see `Session.drainGroupNames`. */ private val claimedGroupNames = mutableListOf>() /** * A cap, because the sender chooses how many of these to send. - * Anyone who can reach us can put one claim in this list per envelope, and - * the list is only drained by a layer that may not run, a poll that never - * reaches `Session.receive`, a code path added later that forgets. An - * unbounded list whose growth a stranger controls is a slow memory leak - * with somebody else's hand on the tap. - * - * Well above any real group count: a claim is only ever ACTED on for a - * group we are actually in, and the cap exists to bound the queue, not to - * ration legitimate traffic. Oldest goes first, since a newer claim is the - * one more likely to still be true. */ private val maxClaimedGroupNames = 256 /** - * Test seam, and the reason it exists is specific. The roster check - * that turns one of these claims into a stored name lives in `Session`, a - * module up, so the test that proves a non-member is refused cannot reach - * `consumeControl`, it is `internal` here, deliberately, because it is the - * seam where a control envelope becomes a stored fact. - * - * Driving it through a real ratchet instead would mean standing up two - * parties and then making one of them claim a membership it does not have, - * which a real sender cannot be made to do. So the refusal is exercised by - * handing this layer the shape an attacker would produce, exactly as the - * iOS side does with its own receive path. + * Test seam, and the reason it exists is specific. */ - fun consumeControlForTesting(fromUser: Long, content: Content) = - consumeControl(fromUser, content) + fun consumeControlForTesting( + fromUser: Long, + content: Content, + ) = consumeControl(fromUser, content) /** A sealed-group invitation: the tag and key that open one group. */ - data class GroupInvitation(val tag: String, val key: ByteArray, val name: String) { + data class GroupInvitation( + val tag: String, + val key: ByteArray, + val name: String, + ) { override fun equals(other: Any?): Boolean = other is GroupInvitation && tag == other.tag && key.contentEquals(other.key) && name == other.name + override fun hashCode(): Int = tag.hashCode() * 31 + key.contentHashCode() } @@ -120,43 +86,18 @@ class Conversations( val content: Content, /** * Whether this arrived sealed (§14). - * - * For a sealed message [fromUser] and [fromDevice] come from inside - * the blob, and are trustworthy only because the inner envelope - * decrypted under a session those identities selected. Never surface - * a sealed message's sender on a path that skips that. */ val sealed: Boolean = false, /** * Whether the ratchet tied this sender to an identity we already hold. - * - * False means [fromUser] is a CLAIM. The device's binding says - * it belongs to somebody else, so anything keyed on the sender, - * "is this person a member of that group", "is this their key", is - * asking about the person they are pretending to be. Control traffic is - * refused outright at this layer; callers that act on the sender for - * any other reason have to check this themselves. */ val senderProven: Boolean = true, /** * When the relay accepted this envelope, epoch millis, 0 = unknown. - * Carried for call signalling, see [RelayClient.Incoming.createdAt]. */ val at: Long = 0, /** * `fnid@relay` when this came from another relay, null when it did not. - * - * This, not [fromUser], is who wrote a federated message. Our - * relay records no sender for an envelope a peer deposited, and the - * `from_user` sealed inside it is a row number on a machine we have - * never heard of, user 12 there and user 12 here are unrelated people. - * Filing one of these by user id would drop every federated message - * into thread 0, or worse, into a local contact's thread. - * - * Bound to the sending device first-use-wins, exactly as [fromUser] is: - * the ratchet proves the device, the address is a claim from inside the - * seal, so a device that later claims a different address keeps the one - * it was first seen with. */ val fromAddress: String? = null, ) { @@ -168,20 +109,11 @@ class Conversations( val delivered: List, val failed: Map, /** - * Set when this send carried a blob. Returned so the sender's own - * thread can show the image immediately, it already has the key, and - * fetching its own attachment back from the relay to display it would - * be absurd. + * Set when this send carried a blob. */ val attachment: Attachment.Ref? = null, /** * Whether the relay was told who sent this (§14). - * - * Reported rather than assumed: sealing needs the recipient's delivery - * key, which arrives inside an E2E message, so the first exchange with - * a new contact is necessarily unsealed. A UI that implied anonymity - * for that message would be lying about the one thing this feature is - * for. */ val sealed: Boolean = false, ) { @@ -190,51 +122,42 @@ class Conversations( /** * Send to every device the recipient has. - * - * Returns partial success rather than throwing. One of someone's three - * devices being unreachable, a drained prekey pool, a stale bundle, must - * not stop the message reaching the other two. Throwing on the first - * failure would turn "your tablet did not get it" into "the message was - * not sent", which is both wrong and unrecoverable. */ - fun send(toUserId: Long, text: String): Sent = sendPlaintext(toUserId, text) + fun send( + toUserId: Long, + text: String, + ): Sent = sendPlaintext(toUserId, text) /** * Send to a group. - * - * The relay does no fan-out. A group is a membership list it keeps - * so clients agree who is in it; the encryption is still pairwise, one - * ratchet per recipient device. That is the honest cost of not letting the - * relay read anything: an n-person group is n sends, and the client is what - * knows that. - * - * Our own devices are excluded, so a group message does not arrive back - * at the sender as an incoming one. The sender's own copy is written - * locally by the caller. */ + /** * Whether this device refuses to send when it cannot seal (ios#16). - * - * Injected rather than read from preferences here: this module has no - * Android dependency. An unset value must read as FALSE, degrade and say - * so, rather than silently refusing to send on a fresh install. */ var requireSealed: () -> Boolean = { false } - fun sendToGroup(group: RelayClient.Group, text: String, myUserId: Long, - mentions: List = emptyList(), - /** - * Minted by the caller, not here, because the caller - * also writes the sender's own copy and queues the retries. - * Three constructions of the same message that disagreed - * about its id would be three different messages, and - * anything said about one of them would land on nothing. - */ - messageId: String? = null): Sent { + fun sendToGroup( + group: RelayClient.Group, + text: String, + myUserId: Long, + mentions: List = emptyList(), + /** + * Minted by the caller, not here, because the caller also + * writes the sender's own copy and queues the retries. + */ + messageId: String? = null, + ): Sent { val delivered = mutableListOf() val failed = LinkedHashMap() - val body = Content.groupText(text, group.id, groupName = vault.groupName(group.id), - mentions = mentions, messageId = messageId) + val body = + Content.groupText( + text, + group.id, + groupName = vault.groupName(group.id), + mentions = mentions, + messageId = messageId, + ) for (member in group.memberIds) { if (member == myUserId) continue @@ -250,15 +173,6 @@ class Conversations( /** * Upload an encrypted blob and send the reference through the ratchet. - * - * The key never touches the relay. It is minted here, the blob is - * uploaded already encrypted, and the key travels inside the message, so - * the relay stores bytes it cannot read and hands out an id that is a - * download capability and nothing more. - * - * Uploaded ONCE and referenced by every recipient device. Uploading per - * device would multiply the bytes by the number of phones someone owns and - * gain nothing: they all decrypt the same blob with the same key. */ fun sendAttachment( toUserId: Long, @@ -274,38 +188,40 @@ class Conversations( filename: String = "", /** * File only: the label it was filed under. - * - * A MARKING, not a permission. The content key rides in the same - * envelope, so whoever can read the message can read the file. This - * travels so the recipient can file it the same way, it must never be - * described, here or on screen, as something that stops them opening it. */ classification: Int = 0, compartments: List = emptyList(), ): Sent { val sealed = Attachment.seal(data) val id = relay.uploadAttachment(sealed.blob) - val ref = Attachment.Ref( - kind = kind, id = id, key = sealed.key, digest = sealed.digest, - bytes = sealed.blob.size, mime = mime, - width = width, height = height, duration = duration, - filename = filename, classification = classification, - compartments = compartments.sorted(), - ) + val ref = + Attachment.Ref( + kind = kind, + id = id, + key = sealed.key, + digest = sealed.digest, + bytes = sealed.blob.size, + mime = mime, + width = width, + height = height, + duration = duration, + filename = filename, + classification = classification, + compartments = compartments.sorted(), + ) return sendPlaintext( toUserId, - Content.attachment(ref, caption, groupId, - groupName = groupId?.let(vault::groupName)), + Content.attachment( + ref, + caption, + groupId, + groupName = groupId?.let(vault::groupName), + ), ).copy(attachment = ref) } /** * Send an image to a group. - * - * Sealed and uploaded ONCE, then fanned out, same reasoning as a - * story. One blob and one key, referenced by every member's envelope; - * uploading per member would multiply the bytes by the group size and give - * the relay a set of blobs it could correlate. */ fun sendGroupAttachment( group: RelayClient.Group, @@ -323,14 +239,27 @@ class Conversations( ): Sent { val sealed = Attachment.seal(data) val id = relay.uploadAttachment(sealed.blob) - val ref = Attachment.Ref( - kind = kind, id = id, key = sealed.key, digest = sealed.digest, - bytes = sealed.blob.size, mime = mime, width = width, height = height, - filename = filename, classification = classification, - compartments = compartments.sorted(), - ) - val body = Content.attachment(ref, caption, group.id, - groupName = vault.groupName(group.id)) + val ref = + Attachment.Ref( + kind = kind, + id = id, + key = sealed.key, + digest = sealed.digest, + bytes = sealed.blob.size, + mime = mime, + width = width, + height = height, + filename = filename, + classification = classification, + compartments = compartments.sorted(), + ) + val body = + Content.attachment( + ref, + caption, + group.id, + groupName = vault.groupName(group.id), + ) val delivered = mutableListOf() val failed = LinkedHashMap() @@ -345,17 +274,6 @@ class Conversations( /** * Post a story to several people at once (§6.6). - * - * Sealed and uploaded ONCE, then fanned out. One blob, one key, one - * upload, and an ordinary story envelope down each recipient's existing - * ratchet. Uploading per recipient would multiply the bytes by the size of - * the contact list for no gain, and would hand the relay a set of distinct - * blobs it could correlate by size and timing. - * - * Partial delivery is normal and reported rather than thrown. A contact - * with no registered device cannot receive anything, story or not, and - * failing the whole post because one person is unreachable would make - * posting impossible for anybody with a stale contact. */ fun postStory( data: ByteArray, @@ -368,30 +286,28 @@ class Conversations( duration: Double = 0.0, /** * A live stream this story embeds (§6.13). - * - * When set, [data] is the POSTER, a still the viewer sees before - * deciding whether to connect, not the stream. A stream cannot be - * sealed: it is continuous and hosted elsewhere, so watching it means - * the viewer's device contacts that host directly. */ streamUrl: String? = null, /** * The group this story is addressed to (§6.24). - * - * Names the AUDIENCE, not a destination thread. It rides on the - * ordinary story envelope; the recipient decides whether to believe - * the claim by checking its own roster. */ groupId: String? = null, ): StoryPosted { val sealed = Attachment.seal(data) val id = relay.uploadAttachment(sealed.blob) - val ref = Attachment.Ref( - kind = Attachment.STORY, id = id, key = sealed.key, digest = sealed.digest, - bytes = sealed.blob.size, mime = mime, width = width, height = height, - duration = duration, - expires = System.currentTimeMillis() / 1000.0 + lifetimeSeconds, - ) + val ref = + Attachment.Ref( + kind = Attachment.STORY, + id = id, + key = sealed.key, + digest = sealed.digest, + bytes = sealed.blob.size, + mime = mime, + width = width, + height = height, + duration = duration, + expires = System.currentTimeMillis() / 1000.0 + lifetimeSeconds, + ) val body = Content.attachment(ref, caption, groupId, streamUrl = streamUrl) var reached = 0 @@ -402,15 +318,14 @@ class Conversations( return StoryPosted(ref, reached, missed) } - data class StoryPosted(val ref: Attachment.Ref, val reached: Int, val missed: Int) + data class StoryPosted( + val ref: Attachment.Ref, + val reached: Int, + val missed: Int, + ) /** * Post a story that is only words. - * - * No blob and no upload, the whole story is inside the ratchet, so the - * relay holds nothing at all for it. The lifetime is the sender's choice - * and rides in the sealed envelope rather than being something the server - * is told. */ fun postTextStory( text: String, @@ -418,10 +333,11 @@ class Conversations( lifetimeSeconds: Double = STORY_LIFETIME_SECONDS, ): TextStoryPosted { val expires = System.currentTimeMillis() / 1000.0 + lifetimeSeconds - // Identifies the story across the sender's devices. A picture story - // is identified by its blob token and this has none, so without it the - // same story appears once per phone the sender posted from. - val key = java.util.UUID.randomUUID().toString() + // Identifies the story across the sender's devices. + val key = + java.util.UUID + .randomUUID() + .toString() val body = Content.textStory(text, expires, key) var reached = 0 @@ -433,49 +349,46 @@ class Conversations( } data class TextStoryPosted( - val key: String, val expires: Double, val reached: Int, val missed: Int, + val key: String, + val expires: Double, + val reached: Int, + val missed: Int, ) /** - * React to somebody's story. Goes to the author alone, a story is sent to - * each viewer rather than hosted, so there is nobody else to tell, and no - * other viewer can learn who reacted. + * React to somebody's story. */ - fun reactToStory(emoji: String, storyKey: String, author: Long) { + fun reactToStory( + emoji: String, + storyKey: String, + author: Long, + ) { sendPlaintext(author, Content.storyReaction(emoji, storyKey, author)) } /** * Reply to somebody's story, privately, in the ordinary conversation. - * - * [preview] travels with it because stories expire: by the time this is - * read the story may be gone, and "replied to your story" with no idea - * which one cannot be understood. */ - fun replyToStory(body: String, storyKey: String, author: Long, preview: String): String { - val messageId = java.util.UUID.randomUUID().toString() - sendPlaintext(author, - Content.storyReply(body, storyKey, author, preview, messageId)) + fun replyToStory( + body: String, + storyKey: String, + author: Long, + preview: String, + ): String { + val messageId = + java.util.UUID + .randomUUID() + .toString() + sendPlaintext( + author, + Content.storyReply(body, storyKey, author, preview, messageId), + ) return messageId } /** * Publish a profile picture: upload it encrypted, then tell each contact * where it is and how to open it. - * - * The relay never sees the face. It stores the same opaque blob it - * stores for a photo message; the key travels inside the ratchet, exactly - * as the delivery key already does. The obvious alternative, a JPEG the - * server serves per user, would hand it everybody's face on a product - * whose claim is that it cannot see what you send. - * - * Per contact, and that is the trade. Somebody who has never - * messaged you cannot see your picture, and changing it means telling - * every contact again. Sealed sender already makes this bargain; making a - * different one for avatars would mean two answers to the same question. - * - * Saved locally BEFORE the fan-out, so a picture that reached nobody is - * still yours and is still re-sent to the next contact you talk to. */ fun setAvatar( data: ByteArray, @@ -486,26 +399,35 @@ class Conversations( ): AvatarPosted { val sealed = Attachment.seal(data) val id = relay.uploadAttachment(sealed.blob) - val ref = Attachment.Ref( - kind = Attachment.IMAGE, id = id, key = sealed.key, digest = sealed.digest, - bytes = sealed.blob.size, mime = mime, width = width, height = height, - ) + val ref = + Attachment.Ref( + kind = Attachment.IMAGE, + id = id, + key = sealed.key, + digest = sealed.digest, + bytes = sealed.blob.size, + mime = mime, + width = width, + height = height, + ) vault.saveMyAvatar(ref) val body = Content.avatar(ref) var reached = 0 var missed = 0 for (userId in toUserIds) { - // One failing contact must not stop the rest. A key that - // changed under one person is a real refusal and it is theirs, not - // a reason nobody else gets the picture. + // One failing contact must not stop the rest. val ok = runCatching { sendPlaintext(userId, body).reachedSomeone }.getOrDefault(false) if (ok) reached++ else missed++ } return AvatarPosted(ref, reached, missed) } - data class AvatarPosted(val ref: Attachment.Ref, val reached: Int, val missed: Int) + data class AvatarPosted( + val ref: Attachment.Ref, + val reached: Int, + val missed: Int, + ) /** Our own picture, or null. */ fun myAvatar(): Attachment.Ref? = vault.myAvatar() @@ -516,22 +438,16 @@ class Conversations( /** * Send our picture to one contact, if we have one and they have not been * told about this version. - * - * Called on first contact rather than on every message: the reference is - * a few hundred bytes, but sending it with every line would put a control - * envelope in front of every word anybody says. */ fun shareAvatarIfNeeded(toUserId: Long) { val ref = vault.myAvatar() ?: return - // The marker records WHICH DEVICES were told, not just which - // picture. A bare ref.id marker outlived the peer's reinstall: their - // new install held no avatar, this side said "already shared", and - // they saw initials for ever, the same staleness class as the - // delivery key. The directory endpoint is free (no prekey popped). + // The marker records WHICH DEVICES were told, not just which picture. val devices = runCatching { relay.listDevices(toUserId) }.getOrNull() ?: return - val ids = (0 until devices.length()) - .map { devices.getJSONObject(it).optString("device_id") } - .sorted().joinToString(",") + val ids = + (0 until devices.length()) + .map { devices.getJSONObject(it).optString("device_id") } + .sorted() + .joinToString(",") if (ids.isEmpty()) return val stamp = "${ref.id}|$ids" if (vault.avatarSharedWith(toUserId) == stamp) return @@ -541,31 +457,29 @@ class Conversations( /** * Send one call signal (§8.10). - * - * Down the ratchet like any message, so the SDP, which names codecs - * and every candidate address, is end-to-end encrypted. A relay that - * could read signalling would learn both parties' addresses even on a call - * it never carries. */ - /** A sticker to one person, or to a group when `groupId` is set. */ - fun sendSticker(toUserId: Long, pack: String, id: String, - groupId: String? = null, messageId: String? = null): Sent = - sendPlaintext(toUserId, Content.sticker(pack, id, groupId, messageId)) - fun sendCallSignal(toUserId: Long, signal: Content.CallSignal): Sent = - sendPlaintext(toUserId, Content.callSignal(signal)) + /** A sticker to one person, or to a group when `groupId` is set. */ + fun sendSticker( + toUserId: Long, + pack: String, + id: String, + groupId: String? = null, + messageId: String? = null, + ): Sent = sendPlaintext(toUserId, Content.sticker(pack, id, groupId, messageId)) + + fun sendCallSignal( + toUserId: Long, + signal: Content.CallSignal, + ): Sent = sendPlaintext(toUserId, Content.callSignal(signal)) /** Download and open a blob a message referred to. */ + /** * 64 lowercase hex, and nothing else, the shape the relay mints and the * only shape it will serve (audit LOW). - * - * The id arrives from a PEER and is interpolated into a URL path. The - * relay validates it before it reaches a filesystem; the client did not, - * so a crafted id could reshape the victim's own authenticated GET. */ - private fun isValidAttachmentToken(id: String): Boolean = - id.length == 64 && id.all { it in '0'..'9' || it in 'a'..'f' } + private fun isValidAttachmentToken(id: String): Boolean = id.length == 64 && id.all { it in '0'..'9' || it in 'a'..'f' } fun fetchAttachment(ref: Attachment.Ref): ByteArray = // The size came through the ratchet, so the relay cannot inflate it, @@ -575,34 +489,21 @@ class Conversations( ref.id.also { require(isValidAttachmentToken(it)) { "malformed attachment id" } }, - maxBytes = if (ref.bytes > 0) minOf(ref.bytes + 4096L, - RelayClient.HARD_ATTACHMENT_CAP) - else RelayClient.HARD_ATTACHMENT_CAP), - ref) + maxBytes = + if (ref.bytes > 0) { + minOf( + ref.bytes + 4096L, + RelayClient.HARD_ATTACHMENT_CAP, + ) + } else { + RelayClient.HARD_ATTACHMENT_CAP + }, + ), + ref, + ) /** * Refuse to encrypt to keys we have reason to distrust. - * - * Before the first envelope, not after. Checking afterwards means - * the ciphertext is already on the relay addressed to whatever key it - * offered, and the check becomes a notification that it is too late. - * - * Two failures, and they are different in kind: - * - * - [IdentityChangedException], a key changed under someone we already - * know. Only a human can say whether that was a new phone or an - * interception, so this stops and asks rather than guessing. Accepting - * is [Trust.acceptIdentityChange]. - * - [KeyNotLoggedException] / [KeyLogForkException], the relay cannot - * prove the key it served is in the log it publishes. Raised on any - * relay that keeps a log, with no setting to find first; a relay that - * keeps none is skipped unless `trust.requireKeyLog` says otherwise. - * - * A fork already detected by gossip fails the send outright, whatever - * this particular bundle looks like. A relay caught serving two logs has - * disqualified itself; continuing to talk through it because the current - * request happens to verify is the mistake this whole section exists to - * avoid. */ private fun guardIdentity( toUserId: Long, @@ -612,12 +513,7 @@ class Conversations( val theirs = bundles.filter { it.deviceId != identity.deviceId } if (theirs.isEmpty()) return - // The address compared has to be the one just SERVED. This - // read the pin and passed it straight back into the comparison, which - // compares it against itself, so the "a different address is a - // different person" rule could never fire on a send. It reads as - // working code and tests as working code; only a relay actually - // swapping an address would have shown it. + // The address compared has to be the one just SERVED. val pinned = trust.pinnedFnid(toUserId) if (trust.record(toUserId, theirs.map { it.identitySigning }, servedFnid ?: pinned) == IdentityTrust.CHANGED @@ -635,110 +531,87 @@ class Conversations( } /** - * Public only so `Session` can send a sealed-group invitation, which is - * an ordinary ratchet plaintext this layer has nothing to say about, it + * Public only so `Session` can send a sealed-group invitation, which is an + * ordinary ratchet plaintext this layer has nothing to say about, it * carries a tag and a group key that only the app layer knows how to mint. */ - fun sendPlaintextForGroupInvite(toUserId: Long, text: String): Sent = - sendPlaintext(toUserId, text) + fun sendPlaintextForGroupInvite( + toUserId: Long, + text: String, + ): Sent = sendPlaintext(toUserId, text) /** * Whether an envelope is worth interrupting somebody for. - * - * Everything this app sends was. The relay holds ciphertext and - * cannot tell a call answer from a birthday message, so it announced every - * envelope as "New message", and call answers, call ends, delivery keys, - * key-log gossip, reactions and avatars are all envelopes. One call - * produced a burst of banners on the far end for things nobody sent. - * - * A plain string is a message: text from an older build carries no - * envelope at all, and the safe reading of something unrecognised is that a - * person meant to say it. */ private fun warrantsAlert(body: String): Boolean { if (!body.startsWith("""{"fnc":""")) return true val content = Content.parse(body) - // Only the OFFER announces itself. An answer and a hang-up are the far - // end's half of a conversation already on screen. + // Only the OFFER announces itself. content.call?.let { return it.action == Content.CallSignal.OFFER } return content.isRenderable } - private fun sendPlaintext(toUserId: Long, text: String): Sent { - // The DIRECTORY, not the key route. `/e2e/keys` pops a one-time - // prekey per device and is metered per contact pair, calling it on - // EVERY send drained both, and an hour of ordinary chatting ended in - // "Too many attempts". The directory costs nothing; full bundles are - // fetched only when some device has no session yet, which is the one - // moment X3DH actually needs them. + private fun sendPlaintext( + toUserId: Long, + text: String, + ): Sent { + // The DIRECTORY, not the key route. val served = relay.listDirectory(toUserId) val directory = served.devices - val stubs = (0 until directory.length()).map { i -> - val o = directory.getJSONObject(i) - PeerBundle( - deviceId = o.optString("device_id"), - identityDH = runCatching { Wire.unb64(o.optString("ik_dh")) } - .getOrDefault(ByteArray(0)), - identitySigning = runCatching { Wire.unb64(o.optString("ik_sig")) } - .getOrDefault(ByteArray(0)), - signedPreKeyId = 0, - signedPreKey = ByteArray(0), - signedPreKeySignature = ByteArray(0), - oneTimePreKeyId = 0, - oneTimePreKey = null, - proto = o.optInt("proto", 1), - ) - } - // A stub cannot open a session, so the two lists must be merged, - // not chosen between. `listDevices` returns identity keys only, no - // signed prekey, no signature, which is all an EXISTING session needs. - // A device with no session needs a full bundle, and `E2ESession.initiate` - // verifies the signed prekey's signature before anything else: handed a - // stub, it fails on empty bytes and that device silently drops out of - // the fan-out. Because the OTHER device still succeeded, `delivered` was - // non-empty and the partial-delivery banner never fired, so a contact - // who added a second device simply stopped receiving on it, with - // nothing anywhere saying so. - // - // Fetching full bundles for everyone on every send is what the stub - // path exists to avoid (it pops a one-time prekey per device and is - // metered per pair). So: fetch full bundles only when something needs - // establishing, then use the full bundle for any device that has one - // and the stub for the rest. - val needsEstablish = stubs.any { - it.deviceId != identity.deviceId && existingSession(it.deviceId) == null - } - // The directory serves the address too, and it has to. A key - // log leaf is written under `fnid:` once an account has claimed an - // address; a verifier that does not know the address computes - // `local:/`, matches no leaf, and refuses the send as an - // unlogged key, reported as "something went wrong reaching that - // server". Bundles are fetched only when a session needs establishing, - // so every later send saw the directory alone; falling back to the pin - // failed outright for a contact pinned before addresses existed. + val stubs = + (0 until directory.length()).map { i -> + val o = directory.getJSONObject(i) + PeerBundle( + deviceId = o.optString("device_id"), + identityDH = + runCatching { Wire.unb64(o.optString("ik_dh")) } + .getOrDefault(ByteArray(0)), + identitySigning = + runCatching { Wire.unb64(o.optString("ik_sig")) } + .getOrDefault(ByteArray(0)), + signedPreKeyId = 0, + signedPreKey = ByteArray(0), + signedPreKeySignature = ByteArray(0), + oneTimePreKeyId = 0, + oneTimePreKey = null, + proto = o.optInt("proto", 1), + ) + } + // A stub cannot open a session, so the two lists must be merged, not + // chosen between. + val needsEstablish = + stubs.any { + it.deviceId != identity.deviceId && existingSession(it.deviceId) == null + } + // The directory serves the address too, and it has to. var servedFnid: String? = served.fnid - val bundles = if (!needsEstablish) stubs else { - val keys = relay.fetchKeys(toUserId) - servedFnid = keys.fnid ?: servedFnid - keys.fnid?.let { trust.rememberFnid(toUserId, it) } - val full = keys.bundles.associateBy { it.deviceId } - // Union: every device the directory knows, carrying a full bundle - // where the key route returned one. - stubs.map { full[it.deviceId] ?: it } - } - // The send path had no logging at all, and that is why a message - // that never left the phone was indistinguishable from one that did. - // The relay records refusals, so an absent POST looks identical to a - // successful one from the server side; only this side knows. - System.err.println("fnmsg-send: to=$toUserId stubs=${stubs.size} " + - "bundles=${bundles.size} mine=${identity.deviceId}") + val bundles = + if (!needsEstablish) { + stubs + } else { + val keys = relay.fetchKeys(toUserId) + servedFnid = keys.fnid ?: servedFnid + keys.fnid?.let { trust.rememberFnid(toUserId, it) } + val full = keys.bundles.associateBy { it.deviceId } + // Union: every device the directory knows, carrying a full bundle + // where the key route returned one. + stubs.map { full[it.deviceId] ?: it } + } + // The send path had no logging at all, and that is why a message that + // never left the phone was indistinguishable from one that did. + System.err.println( + "fnmsg-send: to=$toUserId stubs=${stubs.size} " + + "bundles=${bundles.size} mine=${identity.deviceId}", + ) try { guardIdentity(toUserId, bundles, servedFnid) } catch (e: Exception) { - System.err.println("fnmsg-send: REFUSED by guardIdentity " + - "(${e.javaClass.simpleName}: ${e.message}), nothing will be sent. " + - "This fires when the peer's identity key changed, e.g. after they " + - "reinstalled or the account was recreated.") + System.err.println( + "fnmsg-send: REFUSED by guardIdentity " + + "(${e.javaClass.simpleName}: ${e.message}), nothing will be sent. " + + "This fires when the peer's identity key changed, e.g. after they " + + "reinstalled or the account was recreated.", + ) throw e } val delivered = mutableListOf() @@ -746,80 +619,56 @@ class Conversations( val outgoing = mutableListOf() // Nobody to send to is a FAILURE, and it used to report success. - // A message is addressed per device. With no devices the loop below ran - // zero times, `outgoing` stayed empty, no request was made, and `Sent` - // came back with an empty `failed`, which every caller reads as "it - // went". The message was written into our own thread and simply never - // existed anywhere else. - // - // That is the whole bug behind "I wasn't signed in on the other end and - // the message got lost": signing out deregisters the device, so the - // recipient has none, and this is the one path that has to - // notice. iOS already threw here; this side never did. - // - // Recorded in `failed` rather than thrown, because the group senders - // above call this once per member with no try around it, throwing - // would abandon everyone after the first person who is signed out. if (bundles.none { it.deviceId != identity.deviceId }) { - System.err.println("fnmsg-send: NOT SENDING, the recipient has no " + - "device other than ours (bundles=${bundles.map { it.deviceId }})") + System.err.println( + "fnmsg-send: NOT SENDING, the recipient has no " + + "device other than ours (bundles=${bundles.map { it.deviceId }})", + ) failed["user:$toUserId"] = "not signed in on any device" return Sent(delivered, failed, sealed = false) } - // Seal if we hold either authorisation. §21.4's capability is preferred: - // it is revocable for this contact alone, and the relay sees a fresh - // value per message rather than one constant per recipient. A silent - // fall back from sealed to unsealed is the failure §14.5 warns about, - // so the outcome is still returned rather than assumed. + // Seal if we hold either authorisation. §21.4's capability is + // preferred: it is revocable for this contact alone, and the relay sees + // a fresh value per message rather than one constant per recipient. val peerKey = deliveryKeyFor(toUserId) val peerToken = vault.takePeerToken(toUserId) // Hand this contact OUR key on first contact so they can reply sealed. - // Rides the ratchet as an ordinary content envelope, so the relay - // cannot tell it from a photo (§14.4). val sealed = peerToken != null || peerKey != null - // ios#16, refuse rather than degrade, for the people who would rather - // a message did not go than go attributable. - // - // Nothing is spent by refusing here: `sealed` is false exactly when - // neither a capability nor a key was found, so `takePeerToken` above - // returned null and there is no token to hand back. + // ios#16, refuse rather than degrade, for the people who would rather a + // message did not go than go attributable. if (!sealed && requireSealed()) throw RelayException.SealingRequired() - val preludes = listOfNotNull( - ourDeliveryKeyEnvelopeFor(toUserId), - ourKeyLogHeadEnvelopeFor(toUserId), - ) + val preludes = + listOfNotNull( + ourDeliveryKeyEnvelopeFor(toUserId), + ourKeyLogHeadEnvelopeFor(toUserId), + ) for (bundle in bundles) { - if (bundle.deviceId == identity.deviceId) continue // never talk to ourselves + if (bundle.deviceId == identity.deviceId) continue // never talk to ourselves try { val session = sessionFor(bundle) for (prelude in preludes) { val pre = session.encrypt(prelude) - outgoing += RelayClient.ToDevice( - bundle.deviceId, wrap(pre, bundle, sealed)) + outgoing += + RelayClient.ToDevice(bundle.deviceId, wrap(pre, bundle, sealed)) } val envelope = session.encrypt(text) // Protocol shape only, version and whether the post-quantum - // half of the ratchet step is on the wire. A v3 peer refuses a - // step without it, and nothing on this side reported what we - // actually emitted. + // half of the ratchet step is on the wire. System.err.println( "fnmsg-send: envelope v${envelope.version} ${envelope.type} " + "n=${envelope.header.n} pqdh=${envelope.header.pqdh != null} " + "pqct=${envelope.header.pqct != null} " + - "sessionVersion=${session.version}") - // Saved BEFORE the network call. If the relay accepts the - // envelope and we then crash, the peer has a message our - // ratchet has not advanced past, and nothing either side can - // do recovers it. The reverse, saved, not sent, costs one - // unused message key and nothing else. + "sessionVersion=${session.version}", + ) + // Saved BEFORE the network call. vault.saveSession(bundle.deviceId, session) - outgoing += RelayClient.ToDevice( - bundle.deviceId, wrap(envelope, bundle, sealed)) + outgoing += + RelayClient.ToDevice(bundle.deviceId, wrap(envelope, bundle, sealed)) delivered += bundle.deviceId } catch (e: Exception) { failed[bundle.deviceId] = e.message ?: e::class.simpleName.orEmpty() @@ -827,65 +676,52 @@ class Conversations( } if (outgoing.isEmpty()) { - System.err.println("fnmsg-send: NOT SENDING, every device failed to " + - "encrypt: $failed") + System.err.println( + "fnmsg-send: NOT SENDING, every device failed to " + + "encrypt: $failed", + ) } if (outgoing.isNotEmpty()) { - System.err.println("fnmsg-send: POSTing ${outgoing.size} envelope(s) to " + - "/e2e/messages for user $toUserId sealed=$sealed " + - "toDevices=${outgoing.map { it.deviceId }.distinct()}") - // The OUTCOME, not just the attempt. "POSTing" told us the code - // reached the call; it did not tell us whether the request left the - // phone. A DNS blip or a dropped connection throws here, and every - // caller above treats a thrown send as a failed message without - // ever saying what failed. + System.err.println( + "fnmsg-send: POSTing ${outgoing.size} envelope(s) to " + + "/e2e/messages for user $toUserId sealed=$sealed " + + "toDevices=${outgoing.map { it.deviceId }.distinct()}", + ) + // The OUTCOME, not just the attempt. try { relay.sendEnvelopes( - toUserId, outgoing, identity.deviceId, + toUserId, + outgoing, + identity.deviceId, silent = !warrantsAlert(text), - // The key only when there is no capability. Sending both - // would let the relay accept on the key when the capability - // had been revoked, so it would never learn it was refused - // and that is the whole lever §21 adds. + // The key only when there is no capability. deliveryKey = if (peerToken == null) peerKey?.let(Wire::b64) else null, deliveryToken = peerToken, ) System.err.println("fnmsg-send: relay ACCEPTED ${outgoing.size} envelope(s)") } catch (e: Exception) { if (e !is RelayException.Unauthorized) { - System.err.println("fnmsg-send: SEND FAILED " + - "(${e.javaClass.simpleName}: ${e.message}), the message did " + - "not leave this phone") + System.err.println( + "fnmsg-send: SEND FAILED " + + "(${e.javaClass.simpleName}: ${e.message}), the message did " + + "not leave this phone", + ) throw e } - @Suppress("NAME_SHADOWING") val e = e + @Suppress("NAME_SHADOWING") + val e = e // A refused capability means the batch was revoked. - // Retrying with the rest of it for ever is how a conversation - // quietly stops working, so the batch goes and the SAME - // envelopes are re-sent on the shared key, the sealed blobs - // are reusable here because they are addressed to the device, - // not to the authorisation. - // - // Not a way around revocation: revoking moves a sender back - // onto §14.4's key, it does not silence them. A complete - // cut-off is revoking the batch AND rotating the key, see - // `revokeDeliveryTokens`. if (peerToken != null && peerKey != null) { vault.savePeerTokens(toUserId, emptyList()) relay.sendEnvelopes( - toUserId, outgoing, identity.deviceId, + toUserId, + outgoing, + identity.deviceId, deliveryKey = peerKey.let(Wire::b64), ) return Sent(delivered, failed, sealed = true) } // "Delivery key not accepted", the cached key is stale. - // A contact who reinstalls rotates their delivery key, and a - // send sealed with the old copy is refused with 401 for ever: - // nothing evicted it and there was no fallback, so the thread - // just died with an auth error. Evict, re-encrypt UNSEALED - // (sealed blobs are addressed inside and cannot be reused), - // and send once more. Their next message carries the new key - // and sealing resumes on its own. if (peerKey == null) throw e vault.loadDelivery()?.let { d -> vault.saveDelivery(d.copy(peers = d.peers - toUserId)) @@ -896,13 +732,13 @@ class Conversations( runCatching { val session = sessionFor(bundle) for (prelude in preludes) { - plain += RelayClient.ToDevice( - bundle.deviceId, wrap(session.encrypt(prelude), bundle, false)) + plain += + RelayClient.ToDevice(bundle.deviceId, wrap(session.encrypt(prelude), bundle, false)) } val envelope = session.encrypt(text) vault.saveSession(bundle.deviceId, session) - plain += RelayClient.ToDevice( - bundle.deviceId, wrap(envelope, bundle, false)) + plain += + RelayClient.ToDevice(bundle.deviceId, wrap(envelope, bundle, false)) }.onFailure { failed[bundle.deviceId] = it.message ?: "retry failed" } } if (plain.isNotEmpty()) { @@ -914,85 +750,79 @@ class Conversations( return Sent(delivered, failed, sealed = sealed) } - // -- Federation (§20.5) -------------------------------------------------- + // -- Federation (§20.5) --------------------------------------------------. /** * Send to somebody on another relay. - * - * A parallel path, and [sendPlaintext] is untouched. The local one - * is keyed on a row number this relay assigned; a remote peer has an - * address and a home. Folding the two together would mean editing the code - * where a mistake loses a message silently, on the path that already works. - * - * Always sealed, and here that is not a preference. A relay that - * accepts a federated envelope stores it with no sender, it was not told - * and must not invent one, so an unsealed envelope arrives unattributable - * and is dropped on the floor. Locally, sealing is an improvement; across a - * boundary it is the only thing that makes the message deliverable at all. - * - * Our own address is required, because the receiving relay records - * none. Without it the message decrypts on their device, appears, and has - * nowhere to reply to, which reads to the recipient as the app being - * broken. Refused up front rather than sent into that. */ - fun sendToAddress(card: ContactCard, text: String): Sent { - val mine = myAddress - ?: return Sent(emptyList(), mapOf(card.address to - "this device has no address of its own yet, so a reply could not reach it")) + fun sendToAddress( + card: ContactCard, + text: String, + ): Sent { + val mine = + myAddress + ?: return Sent( + emptyList(), + mapOf( + card.address to + "this device has no address of its own yet, so a reply could not reach it", + ), + ) - // The directory, not the bundle route, this consumes nothing on the - // far side, and the far side's prekey pool belongs to somebody who - // cannot see why it is draining. + // The directory, not the bundle route, this consumes nothing on the far + // side, and the far side's prekey pool belongs to somebody who cannot + // see why it is draining. val directory = relay.remoteDevices(card.fnid, card.relay) - val stubs = (0 until directory.length()).map { - val o = directory.getJSONObject(it) - PeerBundle( - deviceId = o.optString("device_id"), - identityDH = runCatching { Wire.unb64(o.optString("ik_dh")) } - .getOrDefault(ByteArray(0)), - identitySigning = runCatching { Wire.unb64(o.optString("ik_sig")) } - .getOrDefault(ByteArray(0)), - signedPreKeyId = 0, - signedPreKey = ByteArray(0), - signedPreKeySignature = ByteArray(0), - oneTimePreKeyId = 0, - oneTimePreKey = null, - proto = o.optInt("proto", 1), - ) - } + val stubs = + (0 until directory.length()).map { + val o = directory.getJSONObject(it) + PeerBundle( + deviceId = o.optString("device_id"), + identityDH = + runCatching { Wire.unb64(o.optString("ik_dh")) } + .getOrDefault(ByteArray(0)), + identitySigning = + runCatching { Wire.unb64(o.optString("ik_sig")) } + .getOrDefault(ByteArray(0)), + signedPreKeyId = 0, + signedPreKey = ByteArray(0), + signedPreKeySignature = ByteArray(0), + oneTimePreKeyId = 0, + oneTimePreKey = null, + proto = o.optInt("proto", 1), + ) + } if (stubs.isEmpty()) { return Sent(emptyList(), mapOf(card.address to "that address has no devices")) } // The same first-use gate as the local path, in the address keyspace. - // Trust here cannot borrow from the local one: a user id on their relay - // means nothing on ours, so a pin has to hang off the address. if (!recordRemoteTrust(card.address, stubs.map { it.identitySigning })) { throw IdentityChangedException(0L) } - // Full bundles only when something needs establishing. Same - // stub/full union as the local path and for the same reason: a stub - // cannot open a session, and a device silently dropping out of the - // fan-out is how somebody stops receiving on their tablet with nothing - // anywhere saying so. + // Full bundles only when something needs establishing. val needsEstablish = stubs.any { existingSession(it.deviceId) == null } - val bundles = if (!needsEstablish) stubs else { - // Fetched ONCE into a local. This route pops a one-time prekey - // per device on the far relay, so calling it inside the loop would - // drain a stranger's pool at one request per device per send. - val served = relay.remoteBundles(card.fnid, card.relay) - val full = (0 until served.length()) - .mapNotNull { runCatching { PeerBundle.fromWire(served.getJSONObject(it)) } - .getOrNull() } - .associateBy { it.deviceId } - stubs.map { full[it.deviceId] ?: it } - } + val bundles = + if (!needsEstablish) { + stubs + } else { + // Fetched ONCE into a local. + val served = relay.remoteBundles(card.fnid, card.relay) + val full = + (0 until served.length()) + .mapNotNull { + runCatching { PeerBundle.fromWire(served.getJSONObject(it)) } + .getOrNull() + }.associateBy { it.deviceId } + stubs.map { full[it.deviceId] ?: it } + } - // One id per MESSAGE, not per device and not per mailbox. A - // recipient polling several homes collapses the copies by it; a fresh - // id per copy would show them the same message once per mailbox. - val envelopeId = java.util.UUID.randomUUID().toString() + // One id per MESSAGE, not per device and not per mailbox. + val envelopeId = + java.util.UUID + .randomUUID() + .toString() val messages = mutableListOf() val delivered = mutableListOf() val failed = LinkedHashMap() @@ -1003,19 +833,19 @@ class Conversations( val envelope = session.encrypt(text) // Saved before the request, as locally: a relay that accepts an // envelope our ratchet has not advanced past leaves the peer - // holding a message neither side can recover. The reverse costs - // one unused message key. + // holding a message neither side can recover. vault.saveSession(bundle.deviceId, session) - val blob = SealedSender.seal( - envelope = envelope, - fromUser = myUserId ?: 0L, - fromDevice = identity.deviceId, - toDevice = bundle.deviceId, - recipientIKdh = bundle.identityDH, - fromAddress = mine, - ) - messages += RelayClient.RemoteMessage( - bundle.deviceId, blob.toPayload(), envelopeId) + val blob = + SealedSender.seal( + envelope = envelope, + fromUser = myUserId ?: 0L, + fromDevice = identity.deviceId, + toDevice = bundle.deviceId, + recipientIKdh = bundle.identityDH, + fromAddress = mine, + ) + messages += + RelayClient.RemoteMessage(bundle.deviceId, blob.toPayload(), envelopeId) delivered += bundle.deviceId } catch (e: Exception) { failed[bundle.deviceId] = e.message ?: e::class.simpleName.orEmpty() @@ -1024,19 +854,16 @@ class Conversations( if (messages.isEmpty()) return Sent(delivered, failed, sealed = false) val result = relay.sendRemote(card.fnid, card.relay, messages) - // `accepted` first. Checking only the failure fields reported a - // send as successful when the far relay had taken nothing at all: the - // app said sent, the peer's inbox stayed empty, and no error anywhere - // explained it. Zero accepted is a failed send whatever else the reply - // says. + // `accepted` first. val accepted = result.optInt("accepted", 0) if (accepted <= 0) { val unknown = result.optJSONArray("unknown")?.length() ?: 0 - failed[card.address] = if (unknown > 0) { - "${card.relay} does not know that address" - } else { - "${card.relay} accepted the request but took no copies" - } + failed[card.address] = + if (unknown > 0) { + "${card.relay} does not know that address" + } else { + "${card.relay} accepted the request but took no copies" + } return Sent(emptyList(), failed, sealed = true) } // Partial delivery is a real outcome once an account has several @@ -1055,14 +882,14 @@ class Conversations( /** * This device's own `fnid@relay`, sealed into every federated envelope as * the return address. - * - * Set by the app once the account key has been derived, because the fnid - * comes off the passphrase and this layer deliberately never sees one. */ @Volatile var myAddress: String? = null /** Trust on first use, in the address keyspace. */ - private fun recordRemoteTrust(address: String, offered: List): Boolean { + private fun recordRemoteTrust( + address: String, + offered: List, + ): Boolean { val usable = offered.filter { it.isNotEmpty() } val pinned = vault.pinnedAddressIdentities(address) if (pinned.isEmpty()) { @@ -1070,52 +897,33 @@ class Conversations( vault.pinAddressIdentities(address, usable) return true } - // Every offered key must already be pinned. A key never seen is either - // a new device of theirs or one their relay minted, and nothing on the - // wire tells those apart. + // Every offered key must already be pinned. return usable.isNotEmpty() && usable.all { key -> pinned.any { it.contentEquals(key) } } } /** Addresses this device has exchanged federated messages with. */ fun remoteAddresses(): List = vault.remoteAddresses() - // -- Disappearing messages (§6.11) --------------------------------------- + // -- Disappearing messages (§6.11) ---------------------------------------. /** * Set this conversation's timer, and tell the other side. - * - * Symmetric by design. Either party may set it and both delete, so - * the announcement rides the ratchet as ordinary control traffic, the - * relay never learns that a conversation is timed, let alone for how long. - * A timer only one side honoured would be worse than none: the person who - * turned it on would believe their words were gone from a device still - * holding them. - * - * Stored locally first. If the send fails, this device still expires - * its own copy, a half-applied timer that deletes too much is recoverable - * by asking again; one that deletes too little is a broken promise. */ - fun setDisappearing(toUserId: Long, seconds: Int) { + fun setDisappearing( + toUserId: Long, + seconds: Int, + ) { vault.saveDisappearSeconds(Vault.threadForUser(toUserId), seconds) sendPlaintext(toUserId, Content.disappearing(seconds)) } /** * Offer a contact the bridge this phone uses (§6.19). - * - * Nothing is stored locally, unlike [setDisappearing]. This changes - * nothing here, it hands somebody a line and lets them decide. The - * asymmetry is the point: a timer is a property of a conversation both - * ends must share, and a bridge is a route one person chooses for - * themselves. - * - * Rides the ratchet like any other message, so the relay sees a - * conversation happening and not a bridge being handed out. That is most - * of why this exists: a bridge is only useful while whoever wants to block - * it does not have the line, and every other way of distributing one, - * a web page, an email, a channel, is a place to go and collect them. */ - fun shareBridge(toUserId: Long, line: String) { + fun shareBridge( + toUserId: Long, + line: String, + ) { sendPlaintext(toUserId, Content.bridgeOffer(line)) } @@ -1126,40 +934,17 @@ class Conversations( fun dismissBridgeOffer(fromUser: Long) = vault.removeBridgeOffer(fromUser) /** This conversation's timer in seconds, 0 when off. */ - fun disappearSeconds(toUserId: Long): Int = - vault.disappearSeconds(Vault.threadForUser(toUserId)) + fun disappearSeconds(toUserId: Long): Int = vault.disappearSeconds(Vault.threadForUser(toUserId)) /** * Read the inbox, decrypt what we can, and advance the watermark. - * - * The watermark advances per message, after it is decrypted and its - * session is stored, not once for the batch. Advancing for the batch up - * front means a crash halfway loses every message after the crash, and the - * relay will never offer those ids again. - * - * A message that will not decrypt still advances the watermark. It is - * never going to decrypt, the ratchet has moved on, or it was addressed to - * a device identity we replaced, and leaving it in place would make the - * client re-fetch and re-fail it on every poll, for ever. */ fun poll(limit: Int = 100): List { - // S4: keep auditing the log we depend on. Self-throttled (one head an - // hour) and best-effort, it only acts on a proven fork. + // S4: keep auditing the log we depend on. trust.verifyLogConsistencyIfDue() val out = mutableListOf() // Two properties that pull against each other, and both hold. - // - // A failed poll must not be reported as an empty inbox, "no new - // messages" and "could not reach the server" are different things and - // the app says different words for them. But a home relay that is down - // must not take every OTHER mailbox down with it, which is precisely - // the situation several mailboxes exist to survive. - // - // So every mailbox is read, and the failure is only raised if NOTHING - // could be read. A home relay that is down while a backup answers is a - // partial success the caller can act on: its mail is returned, not - // thrown away with the error. val reached = mutableListOf() var firstFailure: Throwable? = null @@ -1167,234 +952,213 @@ class Conversations( .onSuccess { reached += true } .onFailure { firstFailure = it } - // After the signed-in relay and never instead of it: a backup home - // that is slow or down must not delay the mailbox nearly all mail - // actually arrives at. + // After the signed-in relay and never instead of it: a backup home that + // is slow or down must not delay the mailbox nearly all mail actually + // arrives at. for (home in extraHomes()) { runCatching { readInbox(home.second, home.first, limit, out) } .onSuccess { reached += true } .onFailure { if (firstFailure == null) firstFailure = it } } - // `reached`, not `out`. An inbox that answered with nothing in it is - // a successful poll, and treating an empty result as a failure would - // report every quiet minute as an outage. + // `reached`, not `out`. if (reached.isEmpty()) firstFailure?.let { throw it } return out } /** * Clients for the other mailboxes, rebuilt from the vault each poll. - * - * Rebuilt rather than cached because the list changes when a mailbox is - * added or dropped, and a cached one would go on polling a mailbox the user - * has just removed, or miss the one they have just added until relaunch. */ private fun extraHomes(): List> = vault.extraHomes().map { home -> - home.relay to RelayClient( - home.baseUrl, home.username, home.appPassword - ).also { it.useRelayAuth(identity) } + home.relay to + RelayClient( + home.baseUrl, + home.username, + home.appPassword, + ).also { it.useRelayAuth(identity) } } /** - * Read one mailbox. [home] is null for the relay this app signed in to. - * - * The watermark advances only for envelopes that were delivered or are - * permanently undeliverable, exactly as the single-mailbox path always did. - * A message whose session has not been established yet holds the cursor, so - * the next poll retries it rather than skipping it forever. + * Read one mailbox. */ private fun readInbox( - client: RelayClient, home: String?, limit: Int, into: MutableList, + client: RelayClient, + home: String?, + limit: Int, + into: MutableList, ) { val watermark = if (home == null) vault.inboxWatermark else vault.watermarkFor(home) val incoming = client.pollInbox(identity.deviceId, watermark, limit) val out = into for (message in incoming) { - // The same message arrives at every mailbox. Collapsed by the - // id the sender stamped once per MESSAGE, the copies are byte - // identical, so without this the second one reaches a ratchet that - // has already deleted its message key and is dropped, which reads - // in the log as a lost message rather than as a duplicate. - // - // Marked BEFORE decrypting, not after: a mark written only on - // success would let two homes read in the same cycle both get past - // this line. + // The same message arrives at every mailbox. if (message.envelopeId.isNotEmpty()) { if (vault.hasSeenEnvelope(message.envelopeId)) { - if (home == null) vault.inboxWatermark = message.id - else vault.setWatermarkFor(home, message.id) + if (home == null) { + vault.inboxWatermark = message.id + } else { + vault.setWatermarkFor(home, message.id) + } continue } vault.rememberEnvelope(message.envelopeId) } try { - // A sealed message arrives with NO sender the relay could - // vouch for, the transport's from_user/from_device are absent - // or meaningless. The blob names a sender, and anyone holding - // this device's published IK_dh can write whatever they like - // there. So the claim is used ONLY to pick a session, and it is - // the ratchet opening that decides who this actually was. + // A sealed message arrives with NO sender the relay could vouch + // for, the transport's from_user/from_device are absent or + // meaningless. val sealed = SealedSender.isSealed(message.payload) - val opened = if (sealed) { - SealedSender.open( - SealedSender.blobFromPayload(message.payload), - identity.identityDH.private, - identity.deviceId, - ) - } else null + val opened = + if (sealed) { + SealedSender.open( + SealedSender.blobFromPayload(message.payload), + identity.identityDH.private, + identity.deviceId, + ) + } else { + null + } val envelope = opened?.envelope ?: Envelope.fromPayload(message.payload) val fromDevice = opened?.claimedFromDevice ?: message.fromDevice - // Three chains to try, not one. This used to be - // `existingSession(fromDevice) ?: E2ESession.respond(...)`, - // which reads as a fallback and is not one: when a stored - // session existed it was the ONLY thing tried, `decrypt` threw - // for a peer on a different chain, and the catch below swallowed - // it. `respond` was never reached. - // - // That happens whenever both sides run X3DH before either has - // processed the other's prekey, two people opening the same - // chat at once, which is ordinary behaviour. Each adopts the - // other's chain, and from then on every message arrives on a - // chain the receiver has thrown away. + // Three chains to try, not one. val existing = existingSession(fromDevice) val retired = runCatching { vault.loadRetiredSession(fromDevice) }.getOrNull() - // Which slot the winner belongs in is not the same question - // as which chain opened it, and conflating the two swaps them. - // Each branch writes back deliberately. + // Which slot the winner belongs in is not the same question as + // which chain opened it, and conflating the two swaps them. val fromCurrent = openWith(existing, envelope) val fromRetired = if (fromCurrent == null) openWith(retired, envelope) else null - // The receive-side identity gate (audit C1). Building a - // session from a prekey envelope means accepting whatever - // identity key it offers, so it must pass the SAME pin check - // the send path applies. Without it, anyone could hand us a - // handshake with their own key while claiming to be a contact - // we have already pinned, and every later message from that - // session would render as that contact. iOS has always gated - // this branch; this is the missing half. + // The receive-side identity gate (audit C1). val fromFresh = if (fromCurrent == null && fromRetired == null) { - val offeredIK: ByteArray? = runCatching { - Wire.unb64(envelope.handshake!!.getString("ik_sig")) - }.getOrNull() + val offeredIK: ByteArray? = + runCatching { + Wire.unb64(envelope.handshake!!.getString("ik_sig")) + }.getOrNull() val claimant = opened?.claimedFromUser ?: message.fromUser - // The gate applies in whichever keyspace the - // sender is in. A first handshake from another relay - // carries a user id that means nothing here, so - // checking it against the local pins would either - // compare it to an unrelated contact's keys or fall - // through the `claimant <= 0` guard and refuse every - // federated first contact. The address keyspace has its - // own pin, set on our first send to them and on their - // first message to us, and it is the one that applies. - val addressClaim = opened?.claimedFromAddress - ?.let { ContactCard.canonical(it) } - val refused = when { - offeredIK == null -> true - addressClaim != null -> - !recordRemoteTrust( - vault.bindRemoteAddress(addressClaim, fromDevice), - listOf(offeredIK)) - claimant <= 0L -> true - else -> trust.record( - claimant, listOf(offeredIK), trust.pinnedFnid(claimant) - ) == IdentityTrust.CHANGED - } + // The gate applies in whichever keyspace the sender is + // in. + val addressClaim = + opened + ?.claimedFromAddress + ?.let { ContactCard.canonical(it) } + val refused = + when { + offeredIK == null -> { + true + } + + addressClaim != null -> { + !recordRemoteTrust( + vault.bindRemoteAddress(addressClaim, fromDevice), + listOf(offeredIK), + ) + } + + claimant <= 0L -> { + true + } + + else -> { + trust.record( + claimant, + listOf(offeredIK), + trust.pinnedFnid(claimant), + ) == IdentityTrust.CHANGED + } + } if (refused) { - // Refusing costs this message; establishing a session - // on a planted key costs the whole conversation. + // Refusing costs this message; establishing a + // session on a planted key costs the whole + // conversation. throw IdentityChangedException(claimant) } openWith(E2ESession.respond(identity, envelope), envelope) - } else null + } else { + null + } System.err.println( "fnmsg-recv: envelope ${message.id} opened by " + - (if (fromCurrent != null) "current" else if (fromRetired != null) "retired" - else if (fromFresh != null) "FRESH handshake" else "nothing") + - " (sealed=$sealed, handshake=${envelope.handshake != null})") - val plaintext = when { - fromCurrent != null -> { - vault.saveSession(fromDevice, fromCurrent.session) - fromCurrent.plaintext - } - fromRetired != null -> { - // The peer is still on the chain we retired. Advance the - // RETIRED slot and leave the current one alone, - // promoting it here would swap the two and lose the - // chain we are actually sending on. - vault.saveRetiredSession(fromDevice, fromRetired.session) - fromRetired.plaintext - } - fromFresh != null -> { - // Reload before retiring. `existing` was handed to - // a decrypt that FAILED, and a failed decrypt leaves the - // object part-advanced, archiving that instance stores a - // chain that can no longer open anything. The copy in the - // vault is untouched, so re-read it. - if (existing != null) { - existingSession(fromDevice)?.let { - vault.saveRetiredSession(fromDevice, it) + ( + if (fromCurrent != null) { + "current" + } else if (fromRetired != null) { + "retired" + } else if (fromFresh != null) { + "FRESH handshake" + } else { + "nothing" } + ) + + " (sealed=$sealed, handshake=${envelope.handshake != null})", + ) + val plaintext = + when { + fromCurrent != null -> { + vault.saveSession(fromDevice, fromCurrent.session) + fromCurrent.plaintext + } + + fromRetired != null -> { + // The peer is still on the chain we retired. + vault.saveRetiredSession(fromDevice, fromRetired.session) + fromRetired.plaintext + } + + fromFresh != null -> { + // Reload before retiring. + if (existing != null) { + existingSession(fromDevice)?.let { + vault.saveRetiredSession(fromDevice, it) + } + } + vault.saveSession(fromDevice, fromFresh.session) + fromFresh.plaintext + } + + else -> { + throw IllegalStateException("no session could open this envelope") } - vault.saveSession(fromDevice, fromFresh.session) - fromFresh.plaintext } - else -> throw IllegalStateException("no session could open this envelope") - } // The claim is checked against the binding (audit C2). - // The ratchet proved the DEVICE. Who that device belongs to was - // decided the first time it was seen and is not up for - // renegotiation, a device claiming a different user now is - // lying, and its message files under the user it is bound to. val claimed = opened?.claimedFromUser ?: message.fromUser - // A federated sender is resolved in the ADDRESS keyspace, - // never the local one. Their `from_user` is a row number on - // their relay; binding a device to it here would either file - // the message under a local contact who happens to share the - // number, or, where the relay recorded no sender at all, - // under user 0. Both are silent, and both put somebody else's - // words in a thread with the wrong name on it. - val fromAddress = opened?.claimedFromAddress - ?.let { ContactCard.canonical(it) } - ?.let { vault.bindRemoteAddress(it, fromDevice) } - val fromUser = if (fromAddress != null) 0L - else vault.bindDeviceOwner(claimed, fromDevice) + // A federated sender is resolved in the ADDRESS keyspace, never + // the local one. + val fromAddress = + opened + ?.claimedFromAddress + ?.let { ContactCard.canonical(it) } + ?.let { vault.bindRemoteAddress(it, fromDevice) } + val fromUser = + if (fromAddress != null) { + 0L + } else { + vault.bindDeviceOwner(claimed, fromDevice) + } // Control traffic needs a sender the ratchet tied to an - // identity we already hold. A remote address is bound to a - // device and pinned on first use, which is the same standard, - // so a proven remote sender may write control envelopes into - // its own thread and nowhere else. + // identity we already hold. val senderProven = if (fromAddress != null) true else fromUser == claimed val content = Content.parse(plaintext) // Control traffic (delivery key, avatar) only from a proven // sender: poisoning another user's delivery key forces our next // send to them UNSEALED, which hands the relay the sender that // sealing exists to hide. - // Local control writes are keyed by user id, so they are - // skipped entirely for a federated sender: `consumeControl(0L, - // ...)` would poison user 0's delivery key and avatar, which is - // every unattributed message on this device. if (senderProven && fromAddress == null) consumeControl(fromUser, content) // §21.4, every message from a contact spent one of the // capabilities we issued them, so this is how the issuer knows - // when to mint more without anybody asking. Counted for control - // traffic too: a control message they sent cost one as well. - if (senderProven && fromAddress == null - && vault.issuedTokens(fromUser).isNotEmpty()) { + // when to mint more without anybody asking. + if (senderProven && fromAddress == null && + vault.issuedTokens(fromUser).isNotEmpty() + ) { vault.noteReceivedSinceIssue(fromUser) } - // An UNSEALED message from a contact we have told our - // delivery key means their copy is gone, a reinstall wiped - // it. Sealing to us requires that key, and a peer who holds - // it seals everything. Un-mark them so the next send re-tells - // it; until then the relay sees their sender on every message - // they write us. Worst case: one redundant control envelope. + // An UNSEALED message from a contact we have told our delivery + // key means their copy is gone, a reinstall wiped it. if (!sealed && fromUser != 0L && fromAddress == null) { vault.loadDelivery()?.let { d -> if (fromUser in d.toldPeers) { @@ -1403,54 +1167,46 @@ class Conversations( } } if (fromAddress != null) vault.rememberRemoteAddress(fromAddress) - out += Received(message.id, fromUser, fromDevice, content, - sealed = sealed, senderProven = senderProven, - at = message.createdAt, - fromAddress = fromAddress) + out += + Received( + message.id, + fromUser, + fromDevice, + content, + sealed = sealed, + senderProven = senderProven, + at = message.createdAt, + fromAddress = fromAddress, + ) } catch (failure: Exception) { - // Skipped, but never again in silence. Swallowing this - // whole is what made Android's half of every delivery - // investigation unreadable: the relay says delivered, the app - // shows nothing, and the one place that knew why threw the - // reason away. The message is still skipped, that part was - // right, but it now says which envelope, from which device, - // and what actually failed. - // - // The exception type and its message only. Neither carries - // plaintext or key material; `IdentityChangedException` carries - // a user id and the rest are fixed strings. + // Skipped, but never again in silence. System.err.println( "fnmsg-recv: DROPPED envelope ${message.id} from " + "${message.fromDevice.take(8)}, " + - "${failure.javaClass.simpleName}: ${failure.message}") - // Deliberately swallowed and skipped. One undecryptable message - // is not a reason to stop reading the inbox. - // - // The watermark still advances below, on purpose: an - // envelope that none of the three chains could open will not - // open on the next poll either, and holding the cursor on it - // would wedge the inbox behind one bad message. What made this - // lossy before was not the watermark, it was that only ONE - // chain was ever tried. + "${failure.javaClass.simpleName}: ${failure.message}", + ) + // Deliberately swallowed and skipped. + } + if (home == null) { + vault.inboxWatermark = message.id + } else { + vault.setWatermarkFor(home, message.id) } - if (home == null) vault.inboxWatermark = message.id - else vault.setWatermarkFor(home, message.id) } } /** How many prekeys we have left with the relay, and a top-up when it is low. */ - fun replenishPreKeysIfLow(threshold: Int = 5, batch: Int = 20): Int { - val mine = relay.fetchKeys(myUserId ?: return 0).bundles - .firstOrNull { it.deviceId == identity.deviceId } ?: return 0 + fun replenishPreKeysIfLow( + threshold: Int = 5, + batch: Int = 20, + ): Int { + val mine = + relay + .fetchKeys(myUserId ?: return 0) + .bundles + .firstOrNull { it.deviceId == identity.deviceId } ?: return 0 // The relay pops one per fetch, so seeing none here means the pool was // empty before this call, not that this call drained it. - // Either pool being empty triggers a refill of BOTH. The - // post-quantum pool drains at exactly the same rate, one key per new - // session, so a refill that topped up only the classical one would let - // the PQ pool run dry and push every new session onto the last-resort - // key. That is a real weakening (they then all share one KEM key) and - // it is completely silent: sessions still establish, still encrypt, and - // nothing anywhere reports a downgrade. if (mine.oneTimePreKey != null && mine.pqOneTimePreKey != null) return 0 val fresh = identity.generateOneTimePreKeys(batch) val pqFresh = identity.generatePQOneTimePreKeys(batch) @@ -1462,21 +1218,28 @@ class Conversations( /** Set once known; only [replenishPreKeysIfLow] needs it. */ var myUserId: Long? = null - // -- Sealed sender (§14) ------------------------------------------------- + // -- Sealed sender (§14) -------------------------------------------------. /** Wrap an envelope for one device, sealed when we can reach them that way. */ - private fun wrap(envelope: Envelope, bundle: PeerBundle, sealed: Boolean): String = - if (!sealed) envelope.toPayload() - else SealedSender.seal( - envelope = envelope, - fromUser = myUserId ?: 0L, - fromDevice = identity.deviceId, - toDevice = bundle.deviceId, - recipientIKdh = bundle.identityDH, - ).toPayload() + private fun wrap( + envelope: Envelope, + bundle: PeerBundle, + sealed: Boolean, + ): String = + if (!sealed) { + envelope.toPayload() + } else { + SealedSender + .seal( + envelope = envelope, + fromUser = myUserId ?: 0L, + fromDevice = identity.deviceId, + toDevice = bundle.deviceId, + recipientIKdh = bundle.identityDH, + ).toPayload() + } - private fun deliveryKeyFor(userId: Long): ByteArray? = - vault.loadDelivery()?.peers?.get(userId) + private fun deliveryKeyFor(userId: Long): ByteArray? = vault.loadDelivery()?.peers?.get(userId) /** Contacts we have gossiped our log head to this session (§19.4). */ private val headSentTo = mutableSetOf() @@ -1484,29 +1247,18 @@ class Conversations( /** * Our view of the key log as a control envelope, once per contact per * session (§19.4). - * - * Without this the log is checked but never *compared*. Each client only - * learns the relay was consistent with itself; a relay serving two branches - * the one attack transparency exists to catch, stays invisible. The - * receiving half has always been wired, and [Trust.headPayload] was written - * and never called, on this app and on iOS both. - * - * In memory rather than in the vault, matching the JS reference: once per - * launch is enough to make a fork visible, and persisting it would mean a - * device that pinned a new head after a restart never mentioned it. */ private fun ourKeyLogHeadEnvelopeFor(userId: Long): String? { if (!headSentTo.add(userId)) return null - return trust.headPayload() ?: run { headSentTo.remove(userId); null } + return trust.headPayload() ?: run { + headSentTo.remove(userId) + null + } } /** * Our delivery key as a control envelope, the first time we write to * someone, and null every time after. - * - * Marked as told BEFORE the send rather than after: a resend that repeats - * it costs a wasted envelope, while a crash between sending and marking - * would repeat it on every message for ever. */ private fun ourDeliveryKeyEnvelopeFor(userId: Long): String? { val delivery = vault.loadDelivery() ?: return null @@ -1516,54 +1268,38 @@ class Conversations( if (!needsKey && !needsTokens) return null // §21.4, a fresh batch of single-use capabilities rides the same - // envelope as the key. Best-effort: a relay that refuses the upload - // leaves this contact on the shared key, which still works, and failing - // the send would turn an anti-abuse improvement into an outage. - // - // The key travels even when capabilities do. It is the fallback for - // a sender holding none, and dropping it would cut off every contact - // whose client predates this. - val batch = if (needsTokens) { - runCatching { issueDeliveryTokens(userId) }.getOrDefault(emptyList()) - } else emptyList() + // envelope as the key. + val batch = + if (needsTokens) { + runCatching { issueDeliveryTokens(userId) }.getOrDefault(emptyList()) + } else { + emptyList() + } vault.saveDelivery(delivery.copy(toldPeers = delivery.toldPeers + userId)) return SealedSender.deliveryKeyEnvelope(delivery.key, batch) } - // -- §21.4 single-use delivery capabilities ------------------------------- + // -- §21.4 single-use delivery capabilities + // -------------------------------. /** * How many capabilities go to a contact in one batch, and the point at * which the issuer mints afresh. - * - * Re-issued at HALF rather than at the last one. The issuer counts what - * it has received to know what the holder has spent, and that count is a - * lower bound, sends that failed, or arrived while this device was - * offline, are never seen, so it always runs somewhat behind. Half a batch - * of slack costs nothing, and running dry anyway is safe: the sender falls - * back to §14.4's shared key. */ private val deliveryTokenBatch = 64 /** * Mint a batch for one contact, publish the hashes, and return the * capabilities to hand over inside the ratchet. - * - * Only hashes reach the relay, and no row names a sender. The map - * from contact to batch stays in the vault because this device is the only - * party that should hold it, a relay that knew which batch belonged to - * whom would have exactly the per-sender pseudonym §21.2 rejects. - * - * Replaces the previous batch rather than adding to it, revoking the old - * hashes in the same call. Otherwise a contact accumulates capabilities for - * ever and cutting them off means remembering every batch ever issued. */ fun issueDeliveryTokens(toUserId: Long): List { val random = java.security.SecureRandom() - val fresh = (0 until deliveryTokenBatch).map { - Wire.b64(ByteArray(32).also(random::nextBytes)) - } + val fresh = + (0 until deliveryTokenBatch).map { + Wire.b64(ByteArray(32).also(random::nextBytes)) + } + fun digest(list: List) = list.map { Wire.b64(Wire.sha256(Wire.unb64(it))) } relay.setDeliveryTokens(digest(fresh), digest(vault.issuedTokens(toUserId))) @@ -1574,16 +1310,6 @@ class Conversations( /** * Cut one contact off, and nobody else. - * - * This is what §21 exists for. With the shared key of §14.4 the same - * act means rotating for everybody and redistributing to the rest, which is - * why in practice it never happens. - * - * This moves them back onto the shared key; it does not silence - * them. A complete cut-off is this AND [rotateDeliveryKey], every other - * contact recovers from that on its own now, because the new key rides the - * same prelude a batch does. Two doors, and closing one does not close the - * other. */ fun revokeDeliveryTokens(forUserId: Long): Int { val held = vault.issuedTokens(forUserId) @@ -1595,10 +1321,6 @@ class Conversations( /** * Whether this contact is due a fresh batch. - * - * Counted rather than asked for: the holder cannot ask the relay how many - * it has left, because the relay does not know whose are whose, but every - * message we receive from somebody spent one of theirs. */ private fun needsDeliveryTokens(userId: Long): Boolean { val issued = vault.issuedTokens(userId) @@ -1608,66 +1330,49 @@ class Conversations( /** * Tell these members what a group is called, over their pairwise ratchets. - * - * The relay has no copy to fall back on. It used to store group - * names in the clear, in a column it never read; it stopped, so the members - * are now the only record and somebody who is never told stays looking at a - * generic placeholder. - * - * Rides the `delivery_key` control kind rather than a new one, a new kind - * would arrive on every phone running v5 as a bubble full of raw JSON. See - * [SealedSender.deliveryKeyEnvelope]. - * - * Best-effort per member: one unreachable device must not stop the rest - * of the group learning what the group is called. Every group envelope - * repeats the name anyway, so a missed announcement heals on first traffic. - * - * Our own other devices are not told, because fan-out excludes our - * own account. A second phone belonging to whoever named the group shows - * the placeholder until any OTHER member sends something, which does reach - * it, since fan-out is per device. Closing it properly needs a - * copy-to-own-devices path this protocol does not have. */ - fun announceGroupName(groupId: String, memberIds: List) { + fun announceGroupName( + groupId: String, + memberIds: List, + ) { val name = vault.groupName(groupId) ?: return val key = vault.loadDelivery()?.key ?: return - val payload = SealedSender.deliveryKeyEnvelope( - key, groupId = groupId, groupName = name) - // Our own account is included. Fan-out - // excludes us everywhere else, so a second phone we own would be told - // by nobody: the relay has no name to serve it and the members only - // send to each other. The fan-out loop already skips our own device - // ("never talk to ourselves"), so what goes out is addressed to our - // OTHER devices and to nothing else. + val payload = + SealedSender.deliveryKeyEnvelope(key, groupId = groupId, groupName = name) + // Our own account is included. for (member in memberIds + listOfNotNull(myUserId)) { runCatching { sendPlaintext(member, payload) } } } /** Take in the control messages this layer owns, rather than showing them. */ + /** * `internal`, not private: this is the seam where a control envelope * becomes a stored fact, and it is where the wiring is worth asserting * directly rather than through a full ratchet round trip. */ - internal fun consumeControl(fromUser: Long, content: Content) { + internal fun consumeControl( + fromUser: Long, + content: Content, + ) { when (content.kind) { Content.Kind.DELIVERY_KEY -> { - // A member telling us what a group is called (§6.7). Recorded - // as a CLAIM, not adopted: this layer does not know the group - // rosters, and a name is only trustworthy from someone who is - // actually in the group. `Session` drains this and checks. + // A member telling us what a group is called (§6.7). val gid = content.raw?.optString("gid")?.takeIf { it.isNotEmpty() } val gname = content.raw?.optString("gname")?.takeIf { it.isNotEmpty() } // §8.9b, an invitation carries the tag and key as well. val gtag = content.raw?.optString("gtag")?.takeIf { it.isNotEmpty() } - val gkey = content.raw?.optString("gkey")?.takeIf { it.isNotEmpty() } - ?.let { runCatching { Wire.unb64(it) }.getOrNull() } - ?.takeIf { it.size == 32 } + val gkey = + content.raw + ?.optString("gkey") + ?.takeIf { it.isNotEmpty() } + ?.let { runCatching { Wire.unb64(it) }.getOrNull() } + ?.takeIf { it.size == 32 } if (gid != null && gtag != null && gkey != null) { synchronized(groupInvitations) { - groupInvitations += Triple( - fromUser, gid, GroupInvitation(gtag, gkey, gname ?: "")) + groupInvitations += + Triple(fromUser, gid, GroupInvitation(gtag, gkey, gname ?: "")) while (groupInvitations.size > maxClaimedGroupNames) { groupInvitations.removeAt(0) } @@ -1681,73 +1386,54 @@ class Conversations( } } } - // The batch is stored first, and independently. Peer - // delivery keys live inside our OWN delivery record, so the - // original `loadDelivery() ?: return` dropped everything in - // this envelope on a device that had not published a key of its - // own yet, which is every device before `ensureDeliveryKey` - // has run. Capabilities have their own store and no such - // dependency, so they must not inherit that early return. - // - // Replaces rather than appends: the issuer revoked the - // previous batch's hashes as it minted these, so anything still - // held from before is already dead and spending it is refused. + // The batch is stored first, and independently. content.raw?.optJSONArray("tokens")?.let { array -> - val fresh = (0 until array.length()).mapNotNull { i -> - array.optString(i).takeIf { - runCatching { Wire.unb64(it).size }.getOrNull() == 32 + val fresh = + (0 until array.length()).mapNotNull { i -> + array.optString(i).takeIf { + runCatching { Wire.unb64(it).size }.getOrNull() == 32 + } } - } vault.savePeerTokens(fromUser, fresh) } val key = content.raw?.optString("key")?.takeIf { it.isNotEmpty() } ?: return val delivery = vault.loadDelivery() ?: return - vault.saveDelivery( - delivery.copy(peers = delivery.peers + (fromUser to Wire.unb64(key)))) + vault.saveDelivery(delivery.copy(peers = delivery.peers + (fromUser to Wire.unb64(key)))) } + Content.Kind.AVATAR -> { - // Consumed, never rendered. Without this branch an avatar - // would fall through as an ordinary image and appear in the - // conversation as though they had sent you their face as a - // photo message. + // Consumed, never rendered. vault.saveAvatar(fromUser, content.attachment) // The UI refreshes its avatar map only when told to, control - // messages touch no thread, so without this flag a picture - // that arrived mid-session stayed invisible until re-sign-in. + // messages touch no thread, so without this flag a picture that + // arrived mid-session stayed invisible until re-sign-in. avatarsUpdated = true } + Content.Kind.KEY_LOG_HEAD -> { val raw = content.raw ?: return trust.gossip(raw.optInt("size"), raw.optString("root")) } - // The other side set a timer (§6.11). Adopted rather than offered: - // a timer both parties have to agree to separately is a timer that - // is off whenever one of them has not noticed the prompt. - // A contact offering the bridge they use (§6.19). - // - // Stored as an OFFER and never applied. Turning this on - // because it arrived would let a contact, or whoever has taken - // their account, move this phone's traffic onto a host they - // chose. They could not read it, but they would learn who is - // online and when, and could cut them off. The person it was sent - // to is the only thing that turns it on. + + // The other side set a timer (§6.11). Content.Kind.BRIDGE -> { val line = Content.bridgeLine(content) ?: return vault.saveBridgeOffer(fromUser, line, at = System.currentTimeMillis() / 1000) } + Content.Kind.DISAPPEAR -> { val seconds = Content.disappearSeconds(content) ?: return vault.saveDisappearSeconds(Vault.threadForUser(fromUser), seconds) } - else -> Unit + + else -> { + Unit + } } } /** * Make sure this account has a delivery key and the relay has its digest. - * - * Publishing the DIGEST, never the key. Sending the key itself would let - * the relay seal to its own users and would make rotation meaningless. */ fun ensureDeliveryKey() { val existing = vault.loadDelivery() @@ -1759,12 +1445,6 @@ class Conversations( /** * Replace the delivery key, cutting off anyone who has the old one. - * - * This is the only way to stop a sealed spammer, and it is blunt. - * Every contact holding the old key loses the ability to reach us sealed - * until they are told the new one, which happens the next time we write to - * them. That is the trade §14.4 makes: anonymity for senders costs the - * recipient a per-contact redistribution. */ fun rotateDeliveryKey() { val fresh = Vault.Delivery(SealedSender.newDeliveryKey(), published = false) @@ -1773,33 +1453,27 @@ class Conversations( vault.saveDelivery(fresh.copy(published = true)) } - private fun existingSession(peerDeviceId: String): E2ESession? = - runCatching { vault.loadSession(peerDeviceId) }.getOrNull() + private fun existingSession(peerDeviceId: String): E2ESession? = runCatching { vault.loadSession(peerDeviceId) }.getOrNull() /** A session that opened an envelope, and what came out of it. */ - private class Opened(val session: E2ESession, val plaintext: String) + private class Opened( + val session: E2ESession, + val plaintext: String, + ) /** * Try one chain, and report failure as null rather than as an exception. - * - * Decrypting mutates the ratchet, so each candidate has to be a - * session object the caller is willing to keep, never the same instance - * tried twice. A failed attempt leaves that object in an indeterminate - * state and it is dropped, which is why the caller reloads rather than - * reusing. */ - private fun openWith(session: E2ESession?, envelope: Envelope): Opened? { + private fun openWith( + session: E2ESession?, + envelope: Envelope, + ): Opened? { if (session == null) return null return runCatching { Opened(session, session.decrypt(envelope)) }.getOrNull() } /** * The session for a peer device: the stored one, or a new handshake. - * - * Starting a new session when a stored one exists is not a fresh start, - * it is a broken conversation, the peer keeps ratcheting the old one and - * nothing either side sends will open. */ - private fun sessionFor(bundle: PeerBundle): E2ESession = - existingSession(bundle.deviceId) ?: E2ESession.initiate(identity, bundle) + private fun sessionFor(bundle: PeerBundle): E2ESession = existingSession(bundle.deviceId) ?: E2ESession.initiate(identity, bundle) } diff --git a/client/src/main/kotlin/io/flamenet/client/DeliveryDiagnostics.kt b/client/src/main/kotlin/io/flamenet/client/DeliveryDiagnostics.kt index 2cc0b91..6139348 100644 --- a/client/src/main/kotlin/io/flamenet/client/DeliveryDiagnostics.kt +++ b/client/src/main/kotlin/io/flamenet/client/DeliveryDiagnostics.kt @@ -1,25 +1,10 @@ package io.flamenet.client /** - * What the client already knows about delivery, kept where a person can read - * it (ios#21, ios#23, the Android half). - * - * The client has always known this; it only ever told the log. Every - * refusal went to `System.err` and nowhere else, so a send blocked by the - * identity guard reached the person sending it as "Something went wrong - * reaching that server", which was neither true nor actionable. - * - * Shape only, never content. Counts, reasons and timestamps. No message - * text, no key material, no payloads, and no identifiers, see - * [normalisedReason]. A diagnostic reading that leaks what it is diagnosing is - * worse than none, and this one is meant to be safe to screenshot. - * - * In memory, deliberately. It describes the session you are in; persisting - * it would be a growing record of who you talked to and when, which is the - * metadata this project spends its effort not keeping. + * What the client already knows about delivery, kept where a person can read it + * (ios#21, ios#23, the Android half). */ object DeliveryDiagnostics { - data class Peer( var envelopesSeen: Int = 0, var delivered: Int = 0, @@ -33,9 +18,7 @@ object DeliveryDiagnostics { val dropTotal: Int get() = drops.values.sum() /** - * A client that repeatedly repairs is a symptom, not a cure. One is - * ordinary, a contact reinstalled. Three against the same peer means - * something is not settling, and that is worth saying out loud. + * A client that repeatedly repairs is a symptom, not a cure. */ val repairsLookAbnormal: Boolean get() = repairs >= 3 } @@ -54,27 +37,39 @@ object DeliveryDiagnostics { peers.getOrPut(from) { Peer() }.delivered++ } - @Synchronized fun dropped(reason: String, from: Long) { + @Synchronized fun dropped( + reason: String, + from: Long, + ) { val p = peers.getOrPut(from) { Peer() } val slug = normalisedReason(reason) p.drops[slug] = (p.drops[slug] ?: 0) + 1 } - @Synchronized fun repaired(userId: Long, at: Long = System.currentTimeMillis()) { + @Synchronized fun repaired( + userId: Long, + at: Long = System.currentTimeMillis(), + ) { val p = peers.getOrPut(userId) { Peer() } p.repairs++ p.lastRepairAt = at } - @Synchronized fun sendFailed(reason: String, to: Long, - at: Long = System.currentTimeMillis()) { + @Synchronized fun sendFailed( + reason: String, + to: Long, + at: Long = System.currentTimeMillis(), + ) { val p = peers.getOrPut(to) { Peer() } p.lastSendFailure = reason p.lastSendFailureAt = at } @Synchronized fun clearSendFailure(to: Long) { - peers[to]?.let { it.lastSendFailure = null; it.lastSendFailureAt = 0L } + peers[to]?.let { + it.lastSendFailure = null + it.lastSendFailureAt = 0L + } } /** Signing out ends the session this describes. */ @@ -82,16 +77,6 @@ object DeliveryDiagnostics { /** * Strip anything identifying before a reason is counted. - * - * Refusal messages name the thing that was wrong, which sometimes means - * naming a device id, "device … has published keys, but none match". - * A log may carry that; a screen the user is invited to screenshot may not. - * Runs of digits and hex become "…". - * - * Short hex ids are the ones that slip through: `9f0e` is neither a long - * hex run nor an all-digit run. Hence "four or more hex characters - * containing at least one digit", which catches truncated device ids - * without eating ordinary words like `base64`. */ fun normalisedReason(reason: String): String = reason diff --git a/client/src/main/kotlin/io/flamenet/client/DeviceClock.kt b/client/src/main/kotlin/io/flamenet/client/DeviceClock.kt index 83dd148..0ca5574 100644 --- a/client/src/main/kotlin/io/flamenet/client/DeviceClock.kt +++ b/client/src/main/kotlin/io/flamenet/client/DeviceClock.kt @@ -9,46 +9,11 @@ import kotlin.math.roundToInt /** * What the time is, when the device's own answer cannot be trusted (ios#45). - * - * A wrong device clock breaks a promise made to somebody else. Almost - * everything time-dependent here is a commitment: a story disappears after an - * hour, a message clears itself after a day. Those are promises the SENDER - * made, and they are enforced on the READER's device, so a reader whose clock - * is running behind keeps a message the sender was told would be gone, and - * nobody involved can tell. - * - * That asymmetry decides the whole design. Being wrong in one direction costs - * the reader something they were going to lose anyway; being wrong in the other - * direction breaks somebody else's expectation silently. So: - * - * For expiry, take the LATEST plausible time. Never the device's alone, - * never the server's alone, whichever is further ahead. A device clock set back - * cannot extend a message's life, and neither can a relay that lies about the - * time, because the other source overrules it. The cost is that a device clock - * set FORWARD expires things early, which is the failure worth having. - * - * The relay is a hint, not an authority. It is told the time by nobody we - * trust, and a hostile one would like to move time backwards so that - * disappearing messages linger. Taking the later of the two is exactly what - * stops that being useful to it. - * - * Process-wide and synchronised, not a singleton object with plain vars. - * The Swift version can be `nonisolated(unsafe) static var` because the places - * that write it are on one actor; here the observation is written from whatever - * OkHttp thread finished a call and read from the UI thread, so the fields are - * `@Volatile` and the pair is updated under a lock. A torn read of "skew from - * now, timestamp from an hour ago" would age out an observation that had just - * been made, or trust one that had not. */ object DeviceClock { - /** * Difference between the last server time seen and this device's clock, in - * milliseconds. Positive means the device is BEHIND. - * - * Not persisted. A skew learned yesterday says nothing about today, the - * clock may have been corrected, or the phone rebooted with a fresh answer - * from the network. + * milliseconds. */ @Volatile private var skewMillis: Long? = null @@ -61,10 +26,6 @@ object DeviceClock { /** * How far out the clock has to be before it is worth saying anything. - * - * Five minutes. Ordinary drift is seconds; a phone whose clock is out by - * more than this will have visible symptoms, stories that vanish, messages - * that will not send, and being told why is better than guessing. */ const val SUSPECT_THRESHOLD_MS = 300_000L @@ -73,11 +34,11 @@ object DeviceClock { /** * Learn the skew from a response's `Date` header. - * - * Free: every HTTP response already carries one, so this costs no extra - * request and no extra round trip. */ - fun observe(serverMillis: Long, deviceNow: Long = System.currentTimeMillis()) { + fun observe( + serverMillis: Long, + deviceNow: Long = System.currentTimeMillis(), + ) { synchronized(lock) { skewMillis = serverMillis - deviceNow observedAt = deviceNow @@ -85,7 +46,10 @@ object DeviceClock { } /** Parse and record a `Date` header, if there is a usable one. */ - fun observe(header: String?, deviceNow: Long = System.currentTimeMillis()): Boolean { + fun observe( + header: String?, + deviceNow: Long = System.currentTimeMillis(), + ): Boolean { val parsed = header?.let { parseHttpDate(it) } ?: return false observe(parsed, deviceNow) return true @@ -100,12 +64,13 @@ object DeviceClock { } /** The skew, if it is recent enough to mean anything. */ - fun usableSkew(now: Long = System.currentTimeMillis()): Long? = synchronized(lock) { - val skew = skewMillis ?: return null - val at = observedAt ?: return null - if (now - at >= OBSERVATION_LIFETIME_MS) return null - skew - } + fun usableSkew(now: Long = System.currentTimeMillis()): Long? = + synchronized(lock) { + val skew = skewMillis ?: return null + val at = observedAt ?: return null + if (now - at >= OBSERVATION_LIFETIME_MS) return null + skew + } /** Whether the clock is far enough out to be worth telling somebody about. */ fun isSuspect(now: Long = System.currentTimeMillis()): Boolean { @@ -115,10 +80,6 @@ object DeviceClock { /** * What to say about it, or null when there is nothing to say. - * - * Names the symptom before the cause. Somebody whose stories keep - * vanishing does not think "my clock is wrong"; they think the app is - * broken. */ fun warning(now: Long = System.currentTimeMillis()): String? { val skew = usableSkew(now) ?: return null @@ -133,14 +94,6 @@ object DeviceClock { /** * The time to judge an expiry against, in milliseconds. - * - * The later of the device's clock and the server's. Neither alone: a - * device set back would keep a disappearing message past the moment its - * sender was told it would go, and a relay that lied about the time could - * arrange the same thing. Taking whichever is further ahead means both have - * to be wrong in the same direction to extend anything, and the remaining - * failure, expiring early, costs the reader something they were about to - * lose anyway. */ fun nowForExpiry(deviceNow: Long = System.currentTimeMillis()): Long { val skew = usableSkew(deviceNow) ?: return deviceNow @@ -149,21 +102,11 @@ object DeviceClock { /** * The same in seconds, which is how the vault stores expiries. - * - * A Double of seconds, matching `Vault.Story.expires`, converting to a - * Long of seconds here would floor away sub-second precision on a boundary - * that decides whether a story is still on screen. */ - fun expirySeconds(deviceNow: Long = System.currentTimeMillis()): Double = - nowForExpiry(deviceNow) / 1000.0 + fun expirySeconds(deviceNow: Long = System.currentTimeMillis()): Double = nowForExpiry(deviceNow) / 1000.0 /** * RFC 7231 `Date`, which is what an HTTP header carries. - * - * Fixed locale and zone. A formatter that picked those up from the device - * parses nothing on a phone set to most of the world, and a `SimpleDateFormat` - * is not thread-safe, so one is built per call rather than shared. This runs - * once per HTTP response, which is nowhere near hot enough to care. */ private fun parseHttpDate(header: String): Long? { val formatter = SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz", Locale.US) diff --git a/client/src/main/kotlin/io/flamenet/client/LinkPreviewFetcher.kt b/client/src/main/kotlin/io/flamenet/client/LinkPreviewFetcher.kt index d820b3b..803b3d6 100644 --- a/client/src/main/kotlin/io/flamenet/client/LinkPreviewFetcher.kt +++ b/client/src/main/kotlin/io/flamenet/client/LinkPreviewFetcher.kt @@ -1,33 +1,22 @@ package io.flamenet.client import io.flamenet.e2e.Content -import java.util.concurrent.TimeUnit import okhttp3.OkHttpClient import okhttp3.Request +import java.util.concurrent.TimeUnit /** * Fetches a preview for a link the user is about to send (spec §6.12). - * - * This runs on the SENDER's device, and the result travels inside the - * ciphertext. The obvious implementation, each recipient fetching the URL to - * draw a card, is the one that must not be built: it tells the site the - * reader's IP address, that they received a link, and roughly when they opened - * it, once per member on a group message. A messenger that hides the message - * and then has every reader announce its contents to a third party has given - * away what it was protecting. - * - * Sender-side is a smaller leak, not no leak: the site learns somebody is - * about to share that URL, from this address, before the message is sent. That - * is what the setting is for. */ object LinkPreviewFetcher { - /** A short timeout: a preview is a decoration and must never delay a send. */ - private val http = OkHttpClient.Builder() - .connectTimeout(4, TimeUnit.SECONDS) - .readTimeout(4, TimeUnit.SECONDS) - .callTimeout(6, TimeUnit.SECONDS) - .build() + private val http = + OkHttpClient + .Builder() + .connectTimeout(4, TimeUnit.SECONDS) + .readTimeout(4, TimeUnit.SECONDS) + .callTimeout(6, TimeUnit.SECONDS) + .build() /** 64 KiB is plenty for a ``, and a cap is what bounds a hostile page. */ private const val MAX_BYTES = 64 * 1024 @@ -38,59 +27,78 @@ object LinkPreviewFetcher { fun firstUrl(text: String): String? = URL_RE.find(text)?.value?.trimEnd('.', ',', ')') /** - * Fetch and parse Open Graph tags. Null for anything unusual, no title, a - * refusal, a timeout, each of which leaves a message with a plain URL in - * it, which is a perfectly good thing to be. + * Fetch and parse Open Graph tags. */ - fun fetch(url: String): Content.LinkPreview? = runCatching { - val request = Request.Builder() - .url(url) - .header("Accept", "text/html") - // Identifies as a link preview rather than impersonating a browser: - // a site that wants to refuse these should be able to. - .header("User-Agent", "FlamenetLinkPreview/1.0") - .build() - http.newCall(request).execute().use { response -> - if (!response.isSuccessful) return null - val head = response.body?.source()?.let { source -> - source.request(MAX_BYTES.toLong()) - source.buffer.snapshot(minOf(MAX_BYTES.toLong(), source.buffer.size).toInt()) - .utf8() - } ?: return null + fun fetch(url: String): Content.LinkPreview? = + runCatching { + val request = + Request + .Builder() + .url(url) + .header("Accept", "text/html") + // Identifies as a link preview rather than impersonating a browser. + // a site that wants to refuse these should be able to. + .header("User-Agent", "FlamenetLinkPreview/1.0") + .build() + http.newCall(request).execute().use { response -> + if (!response.isSuccessful) return null + val head = + response.body?.source()?.let { source -> + source.request(MAX_BYTES.toLong()) + source.buffer + .snapshot(minOf(MAX_BYTES.toLong(), source.buffer.size).toInt()) + .utf8() + } ?: return null - val title = meta(head, "og:title") ?: titleTag(head) ?: return null - val desc = meta(head, "og:description") ?: metaName(head, "description") ?: "" - Content.LinkPreview.of(url, title, desc) - } - }.getOrNull() + val title = meta(head, "og:title") ?: titleTag(head) ?: return null + val desc = meta(head, "og:description") ?: metaName(head, "description") ?: "" + Content.LinkPreview.of(url, title, desc) + } + }.getOrNull() - // Regex over a bounded prefix, deliberately. A full HTML parser for two - // attributes is a dependency and an attack surface; this reads what it - // recognises and gives up otherwise, which is the correct failure here. + // Regex over a bounded prefix, deliberately. - private fun meta(html: String, property: String): String? = - Regex("""]+property=["']${Regex.escape(property)}["'][^>]+content=["']([^"']*)["']""", - RegexOption.IGNORE_CASE).find(html)?.groupValues?.get(1)?.let(::clean) - ?: Regex("""]+content=["']([^"']*)["'][^>]+property=["']${Regex.escape(property)}["']""", - RegexOption.IGNORE_CASE).find(html)?.groupValues?.get(1)?.let(::clean) + private fun meta( + html: String, + property: String, + ): String? = + Regex( + """]+property=["']${Regex.escape(property)}["'][^>]+content=["']([^"']*)["']""", + RegexOption.IGNORE_CASE, + ).find(html)?.groupValues?.get(1)?.let(::clean) + ?: Regex( + """]+content=["']([^"']*)["'][^>]+property=["']${Regex.escape(property)}["']""", + RegexOption.IGNORE_CASE, + ).find(html)?.groupValues?.get(1)?.let(::clean) - private fun metaName(html: String, name: String): String? = - Regex("""]+name=["']${Regex.escape(name)}["'][^>]+content=["']([^"']*)["']""", - RegexOption.IGNORE_CASE).find(html)?.groupValues?.get(1)?.let(::clean) + private fun metaName( + html: String, + name: String, + ): String? = + Regex( + """]+name=["']${Regex.escape(name)}["'][^>]+content=["']([^"']*)["']""", + RegexOption.IGNORE_CASE, + ).find(html)?.groupValues?.get(1)?.let(::clean) private fun titleTag(html: String): String? = Regex("""]*>([^<]*)""", RegexOption.IGNORE_CASE) - .find(html)?.groupValues?.get(1)?.let(::clean) + .find(html) + ?.groupValues + ?.get(1) + ?.let(::clean) /** - * The handful of entities that actually appear in titles. Anything else is - * left alone, a literal `©` is a cosmetic flaw, and expanding - * arbitrary entities is how a parser grows teeth. + * The handful of entities that actually appear in titles. */ - private fun clean(s: String): String = s - .replace("&", "&").replace("<", "<").replace(">", ">") - .replace(""", "\"").replace("'", "'").replace("'", "'") - .replace(" ", " ") - .trim() - .takeIf { it.isNotEmpty() } ?: "" + private fun clean(s: String): String = + s + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace(""", "\"") + .replace("'", "'") + .replace("'", "'") + .replace(" ", " ") + .trim() + .takeIf { it.isNotEmpty() } ?: "" } diff --git a/client/src/main/kotlin/io/flamenet/client/LocalVault.kt b/client/src/main/kotlin/io/flamenet/client/LocalVault.kt index 83a6514..f2e700e 100644 --- a/client/src/main/kotlin/io/flamenet/client/LocalVault.kt +++ b/client/src/main/kotlin/io/flamenet/client/LocalVault.kt @@ -9,6 +9,8 @@ import io.flamenet.e2e.FilesKdf import io.flamenet.e2e.Hkdf import io.flamenet.e2e.Label import io.flamenet.e2e.Random +import org.json.JSONArray +import org.json.JSONObject import java.io.File import java.nio.file.Files import java.nio.file.StandardCopyOption @@ -16,42 +18,9 @@ import java.util.Base64 import java.util.UUID import java.util.concurrent.locks.ReentrantLock import kotlin.concurrent.withLock -import org.json.JSONArray -import org.json.JSONObject /** * Files that live on this phone. - * - * The server is not storage any more. It is a courier: it holds - * ciphertext for as long as it takes to reach the other device, and then it does - * not. Everything a person owns is here, in this app's own container, and a - * phone that never syncs still has all of it. - * - * What that costs is worth stating plainly, because it changed and it changed - * silently everywhere else: - * - * Read-down is no longer enforced by anything that can refuse. When the - * rows lived in Postgres, `session_dominates(classification, compartments)` was - * an RLS predicate, the database itself would not return a row above your - * clearance, whatever the client asked for. There is no database here. A label - * still gates *decryption*, absolutely: without the label key the bytes are - * bytes. But nothing stops a sender transferring a file above a recipient's - * level, and the clearance shown in the interface now means "the keys this - * device holds", not "what a server will show you". That is a deliberate trade, - * and the interface must not keep using the old words for the new thing. - * - * The index file is device-local and is NOT an interchange format. The - * node *fields* match iOS's exactly, deliberately, so that a courier transfer is - * a copy rather than a translation. The JSON around them is not: iOS encodes - * dates as Swift's reference-date doubles, which is an accident of `JSONEncoder` - * rather than a decision, and pinning Kotlin to it would make an accident into a - * format. Anybody building vault migration between phones should move nodes - * through a declared wire shape, not by copying this file. - * - * Synchronous, and it touches the disk. Call it off the main thread. The - * lock below makes concurrent calls safe, not fast, a `ReentrantLock` rather - * than a coroutine `Mutex` because this module deliberately has no coroutines - * dependency, which is what lets its tests run without an emulator. */ class LocalVault( private val root: File, @@ -59,60 +28,39 @@ class LocalVault( private val identityRawPrivate: ByteArray, /** * How a referenced file is reached. - * - * Required, with no default. A no-op default would make every reference - * row read as "missing" in an app that simply forgot to wire this up, a - * vault that looks like it has lost half its contents, with nothing - * anywhere saying why. */ private val references: ReferenceResolver, ) { - /** * Where a row's bytes actually are. - * - * The two are not equally protected and the interface must not present - * them as if they were. - * - * - [VAULT]: copied in and encrypted under the label key. A stolen backup or - * container yields ciphertext, and the classification is a control. - * - [REFERENCE]: a pointer to a file that stays where it is on the phone, - * encrypted only at the moment it is sent. Nothing is duplicated, and the - * file keeps whatever protection the platform gives it, but we are not - * adding any, the label is advisory until it is sent, and the row breaks - * if the file is moved or deleted. */ - enum class Storage(val wire: String) { + enum class Storage( + val wire: String, + ) { VAULT("vault"), - REFERENCE("reference"); + REFERENCE("reference"), + ; companion object { /** * Absence means [VAULT], and that is not a nicety. iOS shipped a - * `storage` field with a Swift default and discovered that a default - * does not make a `Codable` key optional, every index written - * before referencing existed failed to decode, which presents as a - * vault that has lost everything. Reading an unknown or missing - * value as `vault` is what those rows actually are. + * `storage` field with a Swift default and discovered that a + * default does not make a `Codable` key optional, every index + * written before referencing existed failed to decode, which + * presents as a vault that has lost everything. */ - fun parse(raw: String?): Storage = - entries.firstOrNull { it.wire == raw } ?: VAULT + fun parse(raw: String?): Storage = entries.firstOrNull { it.wire == raw } ?: VAULT } } /** * How a referenced file is reached again after a relaunch. - * - * The [bookmark] is opaque here on purpose. On Android it is a Storage - * Access Framework document URI whose read permission was persisted; on iOS - * it is a security-scoped bookmark. Neither belongs in this module, which - * has no platform in it. */ interface ReferenceResolver { /** * Whether the file is still there, asked separately from reading it, - * because a row that silently does nothing when tapped is worse than one - * that says the file has moved. + * because a row that silently does nothing when tapped is worse than + * one that says the file has moved. */ fun exists(bookmark: String): Boolean @@ -148,19 +96,6 @@ class LocalVault( /** * Every field, with the byte arrays compared by CONTENT. - * - * This was `other.id == id`, written to dodge the fact that a data - * class compares `ByteArray` by identity, and wrong in a way that broke - * a whole feature. Promoting a link to the vault keeps the node's id and - * changes its `storage`, so the new row compared EQUAL to the old one: - * the state object it was in compared equal too, `StateFlow` conflated - * the update away, and Compose never saw it. On screen the file vanished - * from Linked and did not appear in the vault until the app was - * restarted, which reads as losing somebody's file. - * - * The identity-vs-content trap is real, so it is handled rather than - * avoided: `contentEquals` for the four byte arrays, ordinary equality - * for the rest. */ override fun equals(other: Any?): Boolean = other is Node && @@ -172,8 +107,10 @@ class LocalVault( other.wrappedNodeKey.contentEquals(wrappedNodeKey) && other.nameDigest.contentEquals(nameDigest) && other.wrappedName.contentEquals(wrappedName) && - (other.wrappedContentKey?.contentEquals(wrappedContentKey ?: ByteArray(0)) - ?: (wrappedContentKey == null)) && + ( + other.wrappedContentKey?.contentEquals(wrappedContentKey ?: ByteArray(0)) + ?: (wrappedContentKey == null) + ) && other.byteCount == byteCount && other.addedAtMillis == addedAtMillis && other.storage == storage && @@ -203,7 +140,6 @@ class LocalVault( val name: String?, /** * True when this is a reference whose file is no longer where it was. - * Surfaced rather than hidden. */ val isMissing: Boolean, ) { @@ -223,70 +159,57 @@ class LocalVault( val isCompletelyDone: Boolean get() = keptNonEmpty.isEmpty() } - class VaultException(message: String) : Exception(message) + class VaultException( + message: String, + ) : Exception(message) private val lock = ReentrantLock() private var nodes = mutableListOf() private val indexFile: File get() = File(root, "index.json") private val blobs: File get() = File(root, "blobs") + private fun blobFile(id: UUID) = File(blobs, id.toString()) - // ---- Keys ------------------------------------------------------------ + // ---- Keys ------------------------------------------------------------. /** * The key for a label, derived rather than fetched. - * - * Deterministic from the identity key, so every device holding the - * identity derives the same one and a vault restored from a courier transfer - * opens without a grant round-trip. Sharing an area means sending this - * value, wrapped to the recipient, the same grant the server used to - * broker, minus the broker. - * - * Binds the whole canonical label encoding, not just the - * classification. Deriving from the level alone would give one key to - * every compartment at that level, which is precisely the separation - * compartments exist for. */ - internal fun labelKey(label: Label): ByteArray = Hkdf.derive( - ikm = identityRawPrivate, - salt = label.bytes(), - info = FilesKdf.Context.LOCAL_LABEL.toByteArray(Charsets.UTF_8), - length = 32, - ) + internal fun labelKey(label: Label): ByteArray = + Hkdf.derive( + ikm = identityRawPrivate, + salt = label.bytes(), + info = FilesKdf.Context.LOCAL_LABEL.toByteArray(Charsets.UTF_8), + length = 32, + ) /** * The name, or null when this device cannot read the row. - * - * On a local vault every label the identity can derive is readable, so - * this is non-null for anything this device filed itself. It exists for the - * case that is not that: a node that arrived from somebody else, filed under - * a label whose key was never granted. Those decrypt to null and the row - * says so. */ - private fun nameOf(node: Node): String? = try { - val nodeKey = FileKeys.unwrapNodeKey(node.wrappedNodeKey, labelKey(node.label), node.label) - FileNames.decrypt(nodeKey, node.wrappedName, node.nameDigest, node.label) - } catch (e: Aead.DecryptionFailed) { - null - } catch (e: FilesEngineException) { - null - } + private fun nameOf(node: Node): String? = + try { + val nodeKey = FileKeys.unwrapNodeKey(node.wrappedNodeKey, labelKey(node.label), node.label) + FileNames.decrypt(nodeKey, node.wrappedName, node.nameDigest, node.label) + } catch (e: Aead.DecryptionFailed) { + null + } catch (e: FilesEngineException) { + null + } - // ---- Storage --------------------------------------------------------- + // ---- Storage ---------------------------------------------------------. - fun load() = lock.withLock { - blobs.mkdirs() - val text = if (indexFile.exists()) indexFile.readText() else return@withLock - val root = JSONObject(text) - val array = root.optJSONArray("nodes") ?: JSONArray() - nodes = (0 until array.length()).mapTo(mutableListOf()) { decode(array.getJSONObject(it)) } - } + fun load() = + lock.withLock { + blobs.mkdirs() + val text = if (indexFile.exists()) indexFile.readText() else return@withLock + val root = JSONObject(text) + val array = root.optJSONArray("nodes") ?: JSONArray() + nodes = (0 until array.length()).mapTo(mutableListOf()) { decode(array.getJSONObject(it)) } + } /** - * Written to a temporary file and moved into place. An index truncated by - * a crash mid-write is a vault that has lost every file at once, and the - * blobs would still be sitting there, unreferenced, taking up the space. + * Written to a temporary file and moved into place. */ private fun save() { root.mkdirs() @@ -295,59 +218,44 @@ class LocalVault( JSONObject() .put("version", 1) .put("nodes", JSONArray().also { array -> nodes.forEach { array.put(encode(it)) } }) - .toString() + .toString(), ) Files.move( - temporary.toPath(), indexFile.toPath(), - StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE, + temporary.toPath(), + indexFile.toPath(), + StandardCopyOption.REPLACE_EXISTING, + StandardCopyOption.ATOMIC_MOVE, ) } private fun b64(bytes: ByteArray) = Base64.getEncoder().encodeToString(bytes) + private fun unb64(text: String) = Base64.getDecoder().decode(text) - private fun encode(node: Node): JSONObject = JSONObject() - .put("id", node.id.toString()) - .put("parentID", node.parentId?.toString() ?: JSONObject.NULL) - .put("isFolder", node.isFolder) - .put("classification", node.classification.toInt()) - .put("compartments", JSONArray(node.compartments)) - .put("wrappedNodeKey", b64(node.wrappedNodeKey)) - .put("nameDigest", b64(node.nameDigest)) - .put("wrappedName", b64(node.wrappedName)) - .put("wrappedContentKey", node.wrappedContentKey?.let { b64(it) } ?: JSONObject.NULL) - .put("byteCount", node.byteCount) - .put("addedAt", node.addedAtMillis) - .put("storage", node.storage.wire) - .put("bookmark", node.bookmark ?: JSONObject.NULL) + private fun encode(node: Node): JSONObject = + JSONObject() + .put("id", node.id.toString()) + .put("parentID", node.parentId?.toString() ?: JSONObject.NULL) + .put("isFolder", node.isFolder) + .put("classification", node.classification.toInt()) + .put("compartments", JSONArray(node.compartments)) + .put("wrappedNodeKey", b64(node.wrappedNodeKey)) + .put("nameDigest", b64(node.nameDigest)) + .put("wrappedName", b64(node.wrappedName)) + .put("wrappedContentKey", node.wrappedContentKey?.let { b64(it) } ?: JSONObject.NULL) + .put("byteCount", node.byteCount) + .put("addedAt", node.addedAtMillis) + .put("storage", node.storage.wire) + .put("bookmark", node.bookmark ?: JSONObject.NULL) /** - * `optString` is not safe on a nullable field, and the JVM will not - * tell you. Android's bundled `org.json` coerces a JSON `null` to the - * four-character string `"null"`; the Maven `org.json` these tests run - * against returns `""`. Every nullable field below therefore passed on a - * laptop and broke on a phone: - * - * - `parentID` threw `Invalid UUID string: null`, so a vault holding any - * top-level file failed to load AT ALL. Adding the first file worked, - * because it was already in memory; reopening the app lost everything, - * with an error nobody could act on. - * - `wrappedContentKey` was worse, because it did NOT throw: `"null"` is - * valid base64, so the node came back with a plausible three-byte content - * key and simply refused to decrypt. - * - `bookmark` produced a reference pointing at `"null"`, permanently - * missing. - * - * So absence is decided by [JSONObject.isNull], never by comparing a - * decoded string to a sentinel. The literal `"null"` is rejected as well: - * it is not a valid value for any of these fields, and tolerating it costs - * nothing next to reintroducing this. - * - * Found by running the app on a device (android!52). No JVM test could have - * caught it, which is the argument for the device run, not against the - * tests. + * `optString` is not safe on a nullable field, and the JVM will not tell + * you. */ - private fun optional(o: JSONObject, key: String): String? { + private fun optional( + o: JSONObject, + key: String, + ): String? { if (o.isNull(key)) return null val value = o.optString(key, "") return value.takeIf { it.isNotEmpty() && it != "null" } @@ -372,19 +280,21 @@ class LocalVault( ) } - // ---- Reading --------------------------------------------------------- + // ---- Reading ---------------------------------------------------------. - fun list(parentId: UUID?): List = lock.withLock { - nodes.filter { it.parentId == parentId } - .map { Entry(it, nameOf(it), isMissing = isMissing(it)) } - .sortedWith( - // Folders first, then by name; unreadable rows last, where they - // are least in the way of the ones you can act on. - compareByDescending { it.isFolder } - .thenBy { it.name == null } - .thenBy(String.CASE_INSENSITIVE_ORDER) { it.name ?: "" } - ) - } + fun list(parentId: UUID?): List = + lock.withLock { + nodes + .filter { it.parentId == parentId } + .map { Entry(it, nameOf(it), isMissing = isMissing(it)) } + .sortedWith( + // Folders first, then by name; unreadable rows last, where they + // are least in the way of the ones you can act on. + compareByDescending { it.isFolder } + .thenBy { it.name == null } + .thenBy(String.CASE_INSENSITIVE_ORDER) { it.name ?: "" }, + ) + } private fun isMissing(node: Node): Boolean = node.storage == Storage.REFERENCE && @@ -396,51 +306,35 @@ class LocalVault( /** * Every id in a subtree, so a screen can offer "and everything in it". - * - * Bounded by what it has already seen, not by a step counter. A cycle - * cannot be built through this API, but the index is a file on disk and - * something else could write one, so this has to terminate. iOS bounds it - * with `guardRail < 10_000`, which terminates and then hands back ten - * thousand copies of the same two ids: a screen offering to delete a folder - * "and everything in it" would say 10,000 things. Refusing to revisit a node - * both terminates AND returns the right answer, and needs no magic number. */ - fun descendants(of: UUID): List = lock.withLock { - val found = mutableListOf() - val seen = mutableSetOf(of) - val frontier = ArrayDeque(listOf(of)) - while (frontier.isNotEmpty()) { - val next = frontier.removeLast() - for (child in nodes.filter { it.parentId == next }) { - if (!seen.add(child.id)) continue - found += child.id - frontier.addLast(child.id) + fun descendants(of: UUID): List = + lock.withLock { + val found = mutableListOf() + val seen = mutableSetOf(of) + val frontier = ArrayDeque(listOf(of)) + while (frontier.isNotEmpty()) { + val next = frontier.removeLast() + for (child in nodes.filter { it.parentId == next }) { + if (!seen.add(child.id)) continue + found += child.id + frontier.addLast(child.id) + } } + found } - found - } /** * Every file in the whole tree, for a picker that has no business showing * folders. - * - * The whole tree, not the top of it. iOS's picker lists `list(nil)`, - * so a document filed into a folder cannot be sent from a conversation at - * all, and nothing says so; the file is simply absent from the list, which - * reads as "it isn't there". Filed as ios#68. - * - * Unreadable and missing rows are dropped here rather than shown - * disabled. Everywhere else this class insists on showing a row you cannot - * open, because hiding it would leave somebody unable to see that anything - * is there. A picker is the exception: its only purpose is choosing - * something to send, and a row that can never be chosen is furniture. */ - fun allFiles(): List = lock.withLock { - nodes.filter { !it.isFolder } - .map { Entry(it, nameOf(it), isMissing = isMissing(it)) } - .filter { it.name != null && !it.isMissing } - .sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name ?: "" }) - } + fun allFiles(): List = + lock.withLock { + nodes + .filter { !it.isFolder } + .map { Entry(it, nameOf(it), isMissing = isMissing(it)) } + .filter { it.name != null && !it.isMissing } + .sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name ?: "" }) + } fun contents(of: Node): ByteArray = lock.withLock { readContents(of) } @@ -450,14 +344,14 @@ class LocalVault( if (node.storage == Storage.REFERENCE) { val bookmark = node.bookmark if (bookmark == null || !references.exists(bookmark)) { - throw VaultException( - "That file isn't where it was, it may have been moved or deleted.") + throw VaultException("That file isn't where it was, it may have been moved or deleted.") } return references.read(bookmark) } - val wrappedContentKey = node.wrappedContentKey - ?: throw VaultException("That file has no contents.") + val wrappedContentKey = + node.wrappedContentKey + ?: throw VaultException("That file has no contents.") val nodeKey = FileKeys.unwrapNodeKey(node.wrappedNodeKey, labelKey(node.label), node.label) val contentKey = FileKeys.unwrapContentKey(wrappedContentKey, nodeKey, node.label) return FileContent.decrypt(blobFile(node.id).readBytes(), contentKey, node.label) @@ -465,62 +359,73 @@ class LocalVault( /** * The bytes, encrypted for sending, whichever way they are stored. - * - * This is where a referenced file first becomes ciphertext. It is - * sealed under a fresh content key bound to the label, so the courier - * carries something it cannot read and the classification finally gates - * something. Returns the sealed blob and the key to hand the recipient. */ - data class Sealed(val blob: ByteArray, val contentKey: ByteArray) + data class Sealed( + val blob: ByteArray, + val contentKey: ByteArray, + ) - fun sealedForSending(node: Node): Sealed = lock.withLock { - val plaintext = readContents(node) - val contentKey = Random.bytes(32) - Sealed(FileContent.encrypt(plaintext, contentKey, node.label), contentKey) - } + fun sealedForSending(node: Node): Sealed = + lock.withLock { + val plaintext = readContents(node) + val contentKey = Random.bytes(32) + Sealed(FileContent.encrypt(plaintext, contentKey, node.label), contentKey) + } - // ---- Writing --------------------------------------------------------- + // ---- Writing ---------------------------------------------------------. - fun add(bytes: ByteArray, name: String, label: Label, parentId: UUID? = null): Node = + fun add( + bytes: ByteArray, + name: String, + label: Label, + parentId: UUID? = null, + ): Node = lock.withLock { val node = makeNode(name, label, parentId, isFolder = false, byteCount = bytes.size) val nodeKey = FileKeys.unwrapNodeKey(node.wrappedNodeKey, labelKey(label), label) val contentKey = Random.bytes(32) - // Bytes to disk BEFORE the index. The other order leaves an index - // entry for a file that is not there, which reads as corruption; - // this order leaves at worst an unreferenced blob, which is a - // cleanup job nobody notices. + // Bytes to disk BEFORE the index. blobs.mkdirs() writeAtomically(blobFile(node.id), FileContent.encrypt(bytes, contentKey, label)) - val stored = node.copy( - wrappedContentKey = FileKeys.wrapContentKey(contentKey, nodeKey, label)) + val stored = + node.copy(wrappedContentKey = FileKeys.wrapContentKey(contentKey, nodeKey, label)) nodes += stored save() stored } - fun createFolder(name: String, label: Label, parentId: UUID? = null): Node = lock.withLock { - val node = makeNode(name, label, parentId, isFolder = true, byteCount = 0) - nodes += node - save() - node - } + fun createFolder( + name: String, + label: Label, + parentId: UUID? = null, + ): Node = + lock.withLock { + val node = makeNode(name, label, parentId, isFolder = true, byteCount = 0) + nodes += node + save() + node + } /** * File a document that stays where it is. - * - * Nothing is copied and nothing is encrypted here, see [Storage]. The - * bytes get their content key at send time, not now, so until then this - * row's label is a statement of intent rather than a lock. */ - fun reference(bookmark: String, name: String, label: Label, parentId: UUID? = null): Node = + fun reference( + bookmark: String, + name: String, + label: Label, + parentId: UUID? = null, + ): Node = lock.withLock { - val node = makeNode( - name, label, parentId, isFolder = false, - byteCount = references.size(bookmark), - ).copy(storage = Storage.REFERENCE, bookmark = bookmark) + val node = + makeNode( + name, + label, + parentId, + isFolder = false, + byteCount = references.size(bookmark), + ).copy(storage = Storage.REFERENCE, bookmark = bookmark) nodes += node save() node @@ -528,53 +433,38 @@ class LocalVault( /** * Turn a reference into an encrypted copy, in place. - * - * The bridge between the two ways of holding a file, and the reason - * both exist. Referencing avoids duplicating anything; vaulting is what - * makes a classification a control rather than a note. Most files want the - * first and a few want the second, and nobody knows which is which until - * after they have added them, so this promotes one without re-picking it. - * - * It does NOT delete the person's original: that is their file, and - * removing it is a separate decision the interface asks about afterwards. - * Until they do, this is two copies, the honest trade for having - * the encrypted one. - * - * Returns the bookmark the original was read from, so a caller can offer - * that, or null when the row was already vaulted. */ - fun encrypt(id: UUID): String? = lock.withLock { - val position = nodes.indexOfFirst { it.id == id } - if (position < 0) throw VaultException("That file isn't here any more.") - val node = nodes[position] - if (node.storage != Storage.REFERENCE) return@withLock null - val origin = node.bookmark - if (origin == null || !references.exists(origin)) { - throw VaultException("That file isn't where it was, it may have been moved or deleted.") + fun encrypt(id: UUID): String? = + lock.withLock { + val position = nodes.indexOfFirst { it.id == id } + if (position < 0) throw VaultException("That file isn't here any more.") + val node = nodes[position] + if (node.storage != Storage.REFERENCE) return@withLock null + val origin = node.bookmark + if (origin == null || !references.exists(origin)) { + throw VaultException("That file isn't where it was, it may have been moved or deleted.") + } + + val plaintext = readContents(node) + val nodeKey = FileKeys.unwrapNodeKey(node.wrappedNodeKey, labelKey(node.label), node.label) + val contentKey = Random.bytes(32) + + // Bytes before index, as everywhere else here. + blobs.mkdirs() + writeAtomically(blobFile(node.id), FileContent.encrypt(plaintext, contentKey, node.label)) + + nodes[position] = + node.copy( + storage = Storage.VAULT, + // The bookmark goes. + bookmark = null, + wrappedContentKey = FileKeys.wrapContentKey(contentKey, nodeKey, node.label), + byteCount = plaintext.size, + ) + save() + origin } - val plaintext = readContents(node) - val nodeKey = FileKeys.unwrapNodeKey(node.wrappedNodeKey, labelKey(node.label), node.label) - val contentKey = Random.bytes(32) - - // Bytes before index, as everywhere else here. - blobs.mkdirs() - writeAtomically(blobFile(node.id), FileContent.encrypt(plaintext, contentKey, node.label)) - - nodes[position] = node.copy( - storage = Storage.VAULT, - // The bookmark goes. Keeping it would leave a vaulted row still - // able to read through to a plaintext original, so the encrypted - // copy would not be the only way in, which is the point of - // vaulting it. - bookmark = null, - wrappedContentKey = FileKeys.wrapContentKey(contentKey, nodeKey, node.label), - byteCount = plaintext.size, - ) - save() - origin - } - private fun makeNode( name: String, label: Label, @@ -590,10 +480,7 @@ class LocalVault( isFolder = isFolder, classification = label.classification, compartments = label.compartments.sorted(), - // Wrapped to the node's OWN label key, never the parent's. The - // tree allows a Restricted file inside a Confidential folder, so a - // folder-derived key would hand every folder-key holder content - // above their clearance. + // Wrapped to the node's OWN label key, never the parent's. wrappedNodeKey = FileKeys.wrapNodeKey(nodeKey, labelKey(label), label), nameDigest = sealed.digest, wrappedName = sealed.ciphertext, @@ -603,96 +490,84 @@ class LocalVault( ) } - private fun writeAtomically(target: File, bytes: ByteArray) { + private fun writeAtomically( + target: File, + bytes: ByteArray, + ) { val temporary = File(target.parentFile, target.name + ".new") temporary.writeBytes(bytes) Files.move( - temporary.toPath(), target.toPath(), - StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE, + temporary.toPath(), + target.toPath(), + StandardCopyOption.REPLACE_EXISTING, + StandardCopyOption.ATOMIC_MOVE, ) } - // ---- Deleting -------------------------------------------------------- + // ---- Deleting --------------------------------------------------------. /** * Removes a node and its bytes. - * - * Refuses a folder that still has anything in it. Deleting a folder and - * orphaning its contents would leave rows nothing can reach and bytes - * nothing will ever free. */ - fun remove(id: UUID) = lock.withLock { - val position = nodes.indexOfFirst { it.id == id } - if (position < 0) return@withLock - val node = nodes[position] - if (node.isFolder && nodes.any { it.parentId == id }) { - throw VaultException("That folder isn't empty.") + fun remove(id: UUID) = + lock.withLock { + val position = nodes.indexOfFirst { it.id == id } + if (position < 0) return@withLock + val node = nodes[position] + if (node.isFolder && nodes.any { it.parentId == id }) { + throw VaultException("That folder isn't empty.") + } + nodes.removeAt(position) + save() + // Never the person's own file, see the note in the bulk version. + if (node.storage == Storage.VAULT) blobFile(id).delete() } - nodes.removeAt(position) - save() - // Never the person's own file, see the note in the bulk version. - if (node.storage == Storage.VAULT) blobFile(id).delete() - } /** * Delete several things at once. - * - * This reports rather than returning nothing. Selecting eleven things - * and having nine disappear is a data-loss-shaped event in the other - * direction: the two that survived are the ones somebody now thinks are - * gone. The screen says which, and why. - * - * Deletes deepest-first, so selecting a folder AND everything in it - * works. A folder whose contents were NOT all selected is kept, because the - * alternative is a bulk gesture that silently takes rows the person never - * selected, including ones filed above what this device can even read, - * which they could not have reviewed before tapping. */ - fun remove(ids: List): Removal = lock.withLock { - val removed = mutableListOf() - val keptNonEmpty = mutableListOf() - val removedVaultIds = mutableListOf() - val wanted = ids.toSet() + fun remove(ids: List): Removal = + lock.withLock { + val removed = mutableListOf() + val keptNonEmpty = mutableListOf() + val removedVaultIds = mutableListOf() + val wanted = ids.toSet() - // Depth first: a child must go before the parent that contains it. - fun depth(id: UUID): Int { - var steps = 0 - var current = nodes.firstOrNull { it.id == id }?.parentId - while (current != null && steps < 64) { - steps++ - current = nodes.firstOrNull { it.id == current }?.parentId - } - return steps - } - val ordered = ids.filter { id -> nodes.any { it.id == id } } - .sortedByDescending { depth(it) } - - for (id in ordered) { - val position = nodes.indexOfFirst { it.id == id } - if (position < 0) continue - val node = nodes[position] - if (node.isFolder) { - // Anything left inside that was not itself selected? - if (nodes.any { it.parentId == id && it.id !in wanted }) { - keptNonEmpty += id - continue + // Depth first: a child must go before the parent that contains it. + fun depth(id: UUID): Int { + var steps = 0 + var current = nodes.firstOrNull { it.id == id }?.parentId + while (current != null && steps < 64) { + steps++ + current = nodes.firstOrNull { it.id == current }?.parentId } + return steps } - if (node.storage == Storage.VAULT) removedVaultIds += id - nodes.removeAt(position) - removed += id - } + val ordered = + ids + .filter { id -> nodes.any { it.id == id } } + .sortedByDescending { depth(it) } - // One save for the whole batch, after the index is consistent. A save - // per row would leave a half-deleted tree on disk if the process died - // mid-loop; the blobs go afterwards, so a crash costs orphaned bytes - // rather than rows pointing at files that are gone. - save() - // Only ever our own blobs. A referenced row's bytes are the person's - // own file, sitting where they put it, deleting the row must not delete - // their document, and `blobFile` for a reference names a file that was - // never written. - removedVaultIds.forEach { blobFile(it).delete() } - Removal(removed, keptNonEmpty) - } + for (id in ordered) { + val position = nodes.indexOfFirst { it.id == id } + if (position < 0) continue + val node = nodes[position] + if (node.isFolder) { + // Anything left inside that was not itself selected? + if (nodes.any { it.parentId == id && it.id !in wanted }) { + keptNonEmpty += id + continue + } + } + if (node.storage == Storage.VAULT) removedVaultIds += id + nodes.removeAt(position) + removed += id + } + + // One save for the whole batch, after the index is consistent. + save() + // Only ever our own blobs. + removedVaultIds.forEach { blobFile(it).delete() } + Removal(removed, keptNonEmpty) + } } diff --git a/client/src/main/kotlin/io/flamenet/client/MailboxHoming.kt b/client/src/main/kotlin/io/flamenet/client/MailboxHoming.kt index d4c40b6..c9d6b2c 100644 --- a/client/src/main/kotlin/io/flamenet/client/MailboxHoming.kt +++ b/client/src/main/kotlin/io/flamenet/client/MailboxHoming.kt @@ -4,34 +4,9 @@ import io.flamenet.e2e.DeviceIdentity import io.flamenet.e2e.Vault /** - * Keeping a mailbox at more than one relay (spec §20.4). - * - * ## What this buys, and what it does not - * - * Your address already survives a relay disappearing: it comes from your - * passphrase, so signing in elsewhere produces the same one. What does not - * survive is your mailbox, mail sitting at a relay that goes away is gone, - * and contacts holding only that relay's name have nowhere to write. - * - * A second home fixes both, and only in advance. Senders deliver a copy to - * every relay in a signed record, so losing one loses no message, and a contact - * whose delivery fails at the first home finds the second in the same record. - * Added after a relay disappears, none of that helps: there is nothing left - * to publish an updated record from and no way to tell anybody where you went. - * - * ## Why it is several requests - * - * Homing somewhere means having an account there. A relay will not take a - * mailbox record from a stranger, and a token minted by one relay is refused by - * every other because the audience is bound. So: get a credential at the new - * relay, register this device so there is a mailbox to deliver into, claim the - * same address so a sender can find it, and only then publish the record, at - * every home rather than only the new one. - * - * That last part is not tidiness. A home that does not know it is one still - * accepts mail, but a sender asking *it* for the record is handed a stale list - * and skips the others, so the redundancy quietly depends on which relay the - * sender happened to ask. + * Keeping a mailbox at more than one relay (spec §20.4). ## What this buys, and + * what it does not Your address already survives a relay disappearing: it comes + * from your passphrase, so signing in elsewhere produces the same one. */ class MailboxHoming( private val vault: Vault, @@ -40,17 +15,13 @@ class MailboxHoming( private val primaryRelay: String, private val primary: RelayClient, ) { - - class Refused(message: String) : Exception(message) + class Refused( + message: String, + ) : Exception(message) companion object { /** * Normalise what somebody typed into a hostname, or return null. - * - * Refuses a URL rather than parsing one out. A relay is a hostname, - * and accepting `https://x/y` here would let a pasted string point this - * device at a path on somebody else's host, and then send it a signed - * record naming that host as a mailbox. */ fun hostname(typed: String): String? { val host = typed.trim().lowercase() @@ -65,12 +36,7 @@ class MailboxHoming( /** * Sign in at [typed], register this device, claim the address, and publish - * the record everywhere. Returns the home list as it now stands. - * - * Nothing is written to the vault until the relay has actually accepted - * a registration. A half-added home, stored locally, unknown to the relay - * would be polled forever and would show on screen as a mailbox that - * never has anything in it. + * the record everywhere. */ fun add( typed: String, @@ -82,56 +48,41 @@ class MailboxHoming( /** * How to reach [typed], which is a separate question from what it is * called. - * - * The signed record names a hostname, it is inside the - * signature, so it can carry no scheme and no port, while this is how - * this device dials that host. `https://` is the only correct - * answer in production and the default; naming the two separately is - * what lets a self-hosted or test deployment reachable some other way - * work without the record lying about its own name. */ dialAs: String? = null, ): List { - val host = hostname(typed) ?: throw Refused( - "That does not look like a relay's address. It is a hostname, like " + - "relay.example.com, no https:// and no path.") + val host = + hostname(typed) ?: throw Refused( + "That does not look like a relay's address. It is a hostname, like " + + "relay.example.com, no https:// and no path.", + ) if (host == primaryRelay || host in existingHomes) { throw Refused("That relay already holds a mailbox for you.") } val base = dialAs ?: "https://$host" - val account = try { - RelayAccounts(base).signIn(handle, passphrase) - } catch (e: Exception) { - // Says which of the two things went wrong. "No account there" and - // "wrong passphrase" call for opposite actions, and one message - // covering both leaves somebody retyping a correct passphrase. - throw Refused( - "$host did not accept those details. Sign up there with the same " + - "passphrase first, that is what gives you the same address.") - } + val account = + try { + RelayAccounts(base).signIn(handle, passphrase) + } catch (e: Exception) { + // Says which of the two things went wrong. + throw Refused( + "$host did not accept those details. Sign up there with the same " + + "passphrase first, that is what gives you the same address.", + ) + } val client = RelayClient(base, account.username, account.appPassword) client.useRelayAuth(identity) // A mailbox to deliver into. - // - // No one-time prekeys here. A one-time prekey is only usable if - // this device kept its private half, and those live in the identity - // archive keyed to no particular relay, publishing keys at a second - // home whose privates this device would not offer back makes every - // session opened with one undecryptable, which is worse than offering - // none. Without them a sender falls back to the signed prekey: it costs - // initial forward secrecy on the first message and nothing else. try { client.registerDevice(identity, emptyList()) } catch (e: Exception) { throw Refused("$host refused the registration: ${e.message}") } - // The same address here as everywhere. A sender resolving `fnid@host` - // asks that relay, and until the claim lands the answer is "no such - // address", an unclaimed home is a mailbox nobody can address. + // The same address here as everywhere. try { AccountIdentity.claim(client, accountKeySeed, account.userId) } catch (e: Exception) { @@ -142,40 +93,31 @@ class MailboxHoming( val clients = LinkedHashMap() clients[primaryRelay] = primary for (existing in vault.extraHomes()) { - clients[existing.relay] = RelayClient( - existing.baseUrl, existing.username, existing.appPassword - ).also { it.useRelayAuth(identity) } + clients[existing.relay] = + RelayClient( + existing.baseUrl, + existing.username, + existing.appPassword, + ).also { it.useRelayAuth(identity) } } clients[host] = client val serial = AccountIdentity.nextSerial(fnid, clients.values) - val failures = AccountIdentity.publishHomes( - accountKeySeed, fnid, serial, homes.associateWith { clients[it] ?: primary }) + val failures = + AccountIdentity.publishHomes(accountKeySeed, fnid, serial, homes.associateWith { clients[it] ?: primary }) - // The NEW relay accepting is what decides. A record every old home - // took but the new one refused describes a mailbox that does not exist, - // and a sender told to deliver there gets nothing. + // The NEW relay accepting is what decides. failures[host]?.let { throw Refused("$host refused the mailbox: ${it.message}") } vault.saveExtraHomes( vault.extraHomes() + - Vault.ExtraHome(host, account.username, account.appPassword, base)) + Vault.ExtraHome(host, account.username, account.appPassword, base), + ) return homes } /** * Stop keeping a mailbox at [relay]. - * - * The shorter list is published first, and the local credential goes - * only if that worked. Forgetting locally while senders are still told to - * deliver there leaves mail arriving at a mailbox this device no longer - * reads, indistinguishable, from the sender's side, from delivery. - * - * The relay being removed is not asked to accept the new record. It - * is the one party with a reason to refuse, and it is not in the list any - * more so it would refuse on those grounds regardless. Its copy simply goes - * stale, which costs nothing: the record is self-certifying and every other - * home serves the current one. */ fun remove( relay: String, @@ -186,7 +128,8 @@ class MailboxHoming( if (relay == primaryRelay) { throw Refused( "This is the relay you are signed in to, so it cannot be removed " + - "while you are using it.") + "while you are using it.", + ) } val remaining = currentHomes.filterNot { it == relay } if (remaining.isEmpty()) throw Refused("An account needs at least one mailbox.") @@ -195,14 +138,17 @@ class MailboxHoming( clients[primaryRelay] = primary for (existing in vault.extraHomes()) { if (existing.relay == relay) continue - clients[existing.relay] = RelayClient( - existing.baseUrl, existing.username, existing.appPassword - ).also { it.useRelayAuth(identity) } + clients[existing.relay] = + RelayClient( + existing.baseUrl, + existing.username, + existing.appPassword, + ).also { it.useRelayAuth(identity) } } val serial = AccountIdentity.nextSerial(fnid, clients.values) - val failures = AccountIdentity.publishHomes( - accountKeySeed, fnid, serial, remaining.associateWith { clients[it] ?: primary }) + val failures = + AccountIdentity.publishHomes(accountKeySeed, fnid, serial, remaining.associateWith { clients[it] ?: primary }) failures[primaryRelay]?.let { throw Refused("Your own relay would not take the new list: ${it.message}") } diff --git a/client/src/main/kotlin/io/flamenet/client/MessengerApi.kt b/client/src/main/kotlin/io/flamenet/client/MessengerApi.kt index 58985d1..2813761 100644 --- a/client/src/main/kotlin/io/flamenet/client/MessengerApi.kt +++ b/client/src/main/kotlin/io/flamenet/client/MessengerApi.kt @@ -12,12 +12,6 @@ import org.json.JSONObject /** * The non-cryptographic half of the product: who your contacts are, who is * online, what you have been notified about, who you have blocked. - * - * These routes take the account credential, not the relay token. The - * `/e2e/…` routes deliberately refuse the account password because a - * credential good for every device is too much authority for key material. - * These routes are the opposite case, they are about the account, not a - * device, so Basic is correct here and a device-scoped token would be wrong. */ class MessengerApi( baseUrl: String, @@ -25,19 +19,11 @@ class MessengerApi( appPassword: String, private val http: OkHttpClient = RelayClient.defaultHttpClient(), ) { - private val base: HttpUrl = baseUrl.trimEnd('/').toHttpUrl() private val appPassword = appPassword.replace(" ", "") /** * Someone in your list. - * - * `handle` is the name they signed up with and the only one another - * person can type to reach them. It was null on this relay for a while, - * having started life as an OSCAR screen name, which meant you could not - * find your own contacts by the name you knew them by. Show it. - * - * `presence` is their chosen status, not an inference from activity. */ data class Buddy( val userId: Long, @@ -47,11 +33,6 @@ class MessengerApi( /** * Whether this person is somebody the user chose, or somebody who * turned up because they wrote first. - * - * Defaults to true, and that default is the compatibility story. - * A relay that predates this field says nothing about acceptance, and - * the only safe reading of silence is "these are your contacts", the - * alternative labels every contact on every older relay a stranger. */ val accepted: Boolean = true, ) @@ -67,8 +48,8 @@ class MessengerApi( val list = get("buddies").optJSONArray("buddies") ?: JSONArray() return (0 until list.length()).map { val b = list.getJSONObject(it) - // `id`, `display_name`, `screen_name`, `presence`, this route - // does NOT use the `user_id`/`status` shape the §8 routes use, and + // `id`, `display_name`, `screen_name`, `presence`, this route does + // NOT use the `user_id`/`status` shape the §8 routes use, and // guessing otherwise threw "No value for user_id" at the exact // moment a contact was added. Buddy( @@ -82,20 +63,17 @@ class MessengerApi( } /** - * By username, not by id. A person types a name; the relay is the only - * thing that can turn that into an id, and doing the lookup client-side - * would need a directory this product deliberately does not have. + * By username, not by id. */ + /** * @param auto true when the app is adopting somebody who messaged first, * rather than the user choosing to add them. - * - * The distinction is the whole feature. Adopting a stranger keeps their - * first message visible, which is why adoption exists, while leaving the - * decision about whether they are a contact to the person, instead of - * making it for them silently. */ - fun addBuddy(username: String, auto: Boolean = false) { + fun addBuddy( + username: String, + auto: Boolean = false, + ) { post("buddies", JSONObject().put("username", username).put("auto", auto)) } @@ -106,24 +84,17 @@ class MessengerApi( /** * Resolve a user id to a name, `GET /messenger/users/:id`. - * - * This is how a message from a stranger becomes visible. An E2E - * envelope names its sender by id only, and the buddy list is what the - * Messages screen renders, so first contact from somebody who added US - * arrived, decrypted, filed, counted as unread, and never appeared - * anywhere. The receive path uses this to name the sender and add them. - * - * Null when the relay does not know the id (or predates the route). */ - fun userInfo(userId: Long): Buddy? = runCatching { - val b = get("users/$userId") - Buddy( - userId = b.getLong("id"), - displayName = b.optString("display_name", ""), - handle = b.optString("screen_name", ""), - presence = "offline", - ) - }.getOrNull() + fun userInfo(userId: Long): Buddy? = + runCatching { + val b = get("users/$userId") + Buddy( + userId = b.getLong("id"), + displayName = b.optString("display_name", ""), + handle = b.optString("screen_name", ""), + presence = "offline", + ) + }.getOrNull() fun removeBuddy(userId: Long) { delete("buddies/$userId") @@ -136,26 +107,12 @@ class MessengerApi( /** * Set our presence, and return what the relay says it now is. - * - * Return the server's answer, never the value we asked for. The - * relay used to accept an unknown status with 200 and store nothing, so a - * caller that assumed success showed a state the server had discarded, - * "offline" on this screen while every contact still saw us online. The - * relay refuses unknown values now, but a client that reports its own - * request back to itself is wrong regardless of how the server behaves. - * - * The answer is not always the word sent: `offline` is stored as - * `invisible`, which is what "appear offline" has always meant. */ + /** * Read the status the relay currently holds for us, without changing it. - * - * An empty body is a READ. The route only writes when `status` is - * present, and answers with the stored value either way, so this needs no - * route of its own, and cannot accidentally set something. */ - fun currentStatus(): String = - post("status", JSONObject()).optString("status").ifEmpty { "online" } + fun currentStatus(): String = post("status", JSONObject()).optString("status").ifEmpty { "online" } fun setStatus(status: String): String { val reply = post("status", JSONObject().put("status", status)) @@ -168,9 +125,6 @@ class MessengerApi( return (0 until list.length()).map { val n = list.getJSONObject(it) // `type`/`message`/`is_read`, the relay's actual field names. - // This read `kind`/`body`/`read` for months, which exist nowhere, - // so every alert decoded as an empty unread row and the screen - // looked permanently blank. Notification( id = n.optLong("id"), kind = n.optString("type", ""), @@ -186,44 +140,39 @@ class MessengerApi( /** * The ids this account has blocked. - * - * `id`, not `user_id`. This route answers `{id, name, slug}`, the - * same shape as the buddy list and NOT the `user_id` shape the §8 routes - * use. Reading `user_id` here threw `No value for user_id`, and the reason - * it survived so long is the reason it was dangerous: the list is empty - * until somebody blocks their first person, so the map never ran and every - * test passed. The first block anybody performed broke it. - * - * It threw where a caller was defaulting to "nobody is blocked", which - * turned a field-name typo into a blocked person being auto-added back to - * your contacts. See `adoptStrangers`. */ fun blocked(): List { val list = get("blocks").optJSONArray("blocked") ?: JSONArray() return (0 until list.length()).map { list.getJSONObject(it).getLong("id") } } - fun setBlocked(userId: Long, blocked: Boolean) { + fun setBlocked( + userId: Long, + blocked: Boolean, + ) { if (blocked) post("blocks/$userId", JSONObject()) else delete("blocks/$userId") } - // -- The account itself -------------------------------------------------- + // -- The account itself --------------------------------------------------. - data class Me(val userId: Long, val displayName: String, val handle: String) + data class Me( + val userId: Long, + val displayName: String, + val handle: String, + ) - fun me(): Me = raw("api/flamenet/v1/messenger/me", "GET", null).let { - Me(it.getLong("id"), it.optString("name"), it.optString("slug")) - } + fun me(): Me = + raw("api/flamenet/v1/messenger/me", "GET", null).let { + Me(it.getLong("id"), it.optString("name"), it.optString("slug")) + } fun updateDisplayName(name: String) { raw("api/flamenet/v1/messenger/me", "PUT", JSONObject().put("name", name)) } /** - * Changing the password invalidates other credentials, which is the - * point of changing it, so this device has to sign in again afterwards. - * A UI that changes the password and carries on looks fine until the next - * request 401s for no visible reason. + * Changing the password invalidates other credentials, which is the point + * of changing it, so this device has to sign in again afterwards. */ fun updatePassword(password: String) { raw("api/flamenet/v1/messenger/me", "PUT", JSONObject().put("password", password)) @@ -235,38 +184,27 @@ class MessengerApi( } /** - * Exact-username lookup. Deliberately not a directory: no listing exists. - * - * The relay answers with an ARRAY, empty for a miss and one element - * for a hit, `RoutesAccount.lookup` returns `[MeResponse]` precisely so a - * miss and a hit differ only in length and neither leaks timing. This went - * through `send()`, which does `JSONObject(text)` and throws on an array, - * and the throw was swallowed by `runCatching { }.getOrNull()`. - * - * The result: every lookup reported "no account called X here" even when - * the relay had found the account and returned it. Adding a contact was - * impossible on Android, which is the first thing anyone does. Measured - * against relay.flamenet.io, the relay returned - * `[{"name":"appreview2demo","id":30,"slug":"appreview2demo"}]` while the - * app said no such account. - * - * So parse the array here, and keep `getOrNull` for TRANSPORT failure - * only, a miss is an empty array, not an exception. + * Exact-username lookup. */ fun lookup(username: String): Me? { - val text = rawText( - "api/flamenet/v1/messenger/users", query = mapOf("slug" to username) - ) ?: return null + val text = + rawText( + "api/flamenet/v1/messenger/users", + query = mapOf("slug" to username), + ) ?: return null val found = runCatching { JSONArray(text) }.getOrNull() ?: return null if (found.length() == 0) return null val o = found.getJSONObject(0) return Me(o.getLong("id"), o.optString("name"), o.optString("slug")) } - // -- Internals ----------------------------------------------------------- + // -- Internals -----------------------------------------------------------. - private fun url(path: String) = base.newBuilder() - .addPathSegments("api/flamenet/v1/messenger/$path").build() + private fun url(path: String) = + base + .newBuilder() + .addPathSegments("api/flamenet/v1/messenger/$path") + .build() /** For the routes that are not under the messenger prefix. */ private fun raw( @@ -275,8 +213,12 @@ class MessengerApi( body: JSONObject?, query: Map = emptyMap(), ): JSONObject { - val target = base.newBuilder().addPathSegments(path) - .also { b -> query.forEach { (k, v) -> b.addQueryParameter(k, v) } }.build() + val target = + base + .newBuilder() + .addPathSegments(path) + .also { b -> query.forEach { (k, v) -> b.addQueryParameter(k, v) } } + .build() val builder = Request.Builder().url(target).header("Accept", "application/json") when (method) { "GET" -> builder.get() @@ -287,33 +229,60 @@ class MessengerApi( return send(builder) } - private fun get(path: String) = send( - Request.Builder().url(url(path)).get().header("Accept", "application/json")) + private fun get(path: String) = + send( + Request + .Builder() + .url(url(path)) + .get() + .header("Accept", "application/json"), + ) - private fun post(path: String, body: JSONObject) = send( - Request.Builder().url(url(path)) + private fun post( + path: String, + body: JSONObject, + ) = send( + Request + .Builder() + .url(url(path)) .post(body.toString().toRequestBody(JSON)) - .header("Accept", "application/json")) + .header("Accept", "application/json"), + ) - private fun delete(path: String) = send( - Request.Builder().url(url(path)).delete().header("Accept", "application/json")) + private fun delete(path: String) = + send( + Request + .Builder() + .url(url(path)) + .delete() + .header("Accept", "application/json"), + ) /** * A GET whose body is returned as text rather than forced into a * `JSONObject`. - * - * Exists for the ONE route that answers with an array. Everything else - * goes through `send()`; do not widen this without checking the route, - * because losing the typed error mapping is a real cost. */ - private fun rawText(path: String, query: Map = emptyMap()): String? { - val target = base.newBuilder().addPathSegments(path) - .also { b -> query.forEach { (k, v) -> b.addQueryParameter(k, v) } }.build() - val request = Request.Builder().url(target) - .header("Accept", "application/json") - .header("Authorization", "Basic " + Wire.b64( - "$username:$appPassword".toByteArray(Charsets.UTF_8))) - .get().build() + private fun rawText( + path: String, + query: Map = emptyMap(), + ): String? { + val target = + base + .newBuilder() + .addPathSegments(path) + .also { b -> query.forEach { (k, v) -> b.addQueryParameter(k, v) } } + .build() + val request = + Request + .Builder() + .url(target) + .header("Accept", "application/json") + .header( + "Authorization", + "Basic " + + Wire.b64("$username:$appPassword".toByteArray(Charsets.UTF_8)), + ).get() + .build() return runCatching { http.newCall(request).execute().use { if (it.isSuccessful) it.body?.string() else null @@ -322,29 +291,44 @@ class MessengerApi( } private fun send(builder: Request.Builder): JSONObject { - val request = builder - .header("Authorization", "Basic " + Wire.b64( - "$username:$appPassword".toByteArray(Charsets.UTF_8))) - .build() - val response = try { - http.newCall(request).execute() - } catch (e: Exception) { - throw RelayException.Transport(e.message ?: e.toString(), e) - } + val request = + builder + .header( + "Authorization", + "Basic " + + Wire.b64("$username:$appPassword".toByteArray(Charsets.UTF_8)), + ).build() + val response = + try { + http.newCall(request).execute() + } catch (e: Exception) { + throw RelayException.Transport(e.message ?: e.toString(), e) + } response.use { val text = it.body?.string().orEmpty() if (it.isSuccessful) { - return if (text.isBlank()) JSONObject() else runCatching { JSONObject(text) } - .getOrElse { throw RelayException.Malformed("the relay's reply was not JSON") } + return if (text.isBlank()) { + JSONObject() + } else { + runCatching { JSONObject(text) } + .getOrElse { throw RelayException.Malformed("the relay's reply was not JSON") } + } } - val message = runCatching { JSONObject(text).optString("message") } - .getOrNull().orEmpty().ifBlank { text } + val message = + runCatching { JSONObject(text).optString("message") } + .getOrNull() + .orEmpty() + .ifBlank { text } throw when (it.code) { 401, 403 -> RelayException.Unauthorized(message) + 404 -> RelayException.NotFound(message.ifBlank { request.url.encodedPath }) + 429 -> RelayException.RateLimited(it.header("Retry-After")?.toIntOrNull()) - // The geo-gate, not the relay. Needs a sentence, not a number. + + // The geo-gate, not the relay. 451 -> RelayException.Unavailable(message) + else -> RelayException.Http(it.code, message) } } diff --git a/client/src/main/kotlin/io/flamenet/client/Obfs4Routing.kt b/client/src/main/kotlin/io/flamenet/client/Obfs4Routing.kt index 2e67a6e..07f4163 100644 --- a/client/src/main/kotlin/io/flamenet/client/Obfs4Routing.kt +++ b/client/src/main/kotlin/io/flamenet/client/Obfs4Routing.kt @@ -6,38 +6,13 @@ import java.util.concurrent.TimeUnit /** * Which bridge, if any, the app's HTTP clients route through. - * - * A holder rather than a constructor argument, because there are ten - * construction sites and no way to be sure of catching them all. `RelayClient`, - * `MessengerApi`, `AccountsClient`, `RelayAccounts`, `SignupProofOfWork`, - * `RelayDiscovery` and the background service each build their own client, some - * from default parameters evaluated at field-initialisation time. Threading a - * bridge through every one would work until somebody adds an eleventh, and the - * eleventh would connect directly, successfully, with nothing to show that it - * had. A missed site here does not fail; it silently does not protect. - * - * The socket factory is installed whether or not a bridge is set. A - * client is built once and the setting changes later, so the factory has to be - * able to follow it. With no bridge it delegates to the platform's own factory, - * which is exactly what OkHttp would have used. */ object Obfs4Routing { - @Volatile private var current: Obfs4Bridge? = null /** * One pool for every default client. - * - * Shared so it can be emptied. OkHttp keeps connections alive across - * requests; a connection opened before the bridge changed still runs over - * the old transport, and would go on serving requests for minutes after the - * user switched bridges, or after they turned one OFF, which is the - * dangerous direction. Eviction on change is the only thing that stops that, - * and it needs a pool we hold. - * - * The idle window is short for the same reason a bridge that goes away - * must not leave requests queued behind a dead tunnel. */ internal val pool = ConnectionPool(8, 2, TimeUnit.MINUTES) @@ -46,9 +21,6 @@ object Obfs4Routing { /** * Route through [bridge] from now on, or directly when it is null. - * - * Existing pooled connections are dropped, because they are already - * committed to the previous transport. */ fun use(bridge: Obfs4Bridge?) { if (bridge == current) return diff --git a/client/src/main/kotlin/io/flamenet/client/RelayAccounts.kt b/client/src/main/kotlin/io/flamenet/client/RelayAccounts.kt index fa881b5..6a8d023 100644 --- a/client/src/main/kotlin/io/flamenet/client/RelayAccounts.kt +++ b/client/src/main/kotlin/io/flamenet/client/RelayAccounts.kt @@ -9,21 +9,11 @@ import org.json.JSONObject /** * A relay's own account table, the identity provider a self-hosted relay is. - * - * This is what keeps self-hosting one service rather than two. A relay with - * no shared account service configured still signs people up, signs them in and - * resets their passwords, on its own. - * - * What comes back is not the password. The relay mints a long-lived - * credential, returns it once, and keeps only its hash. That credential is what - * every later request carries; the password is used at this moment and then - * should not be kept. */ class RelayAccounts( baseUrl: String, private val http: OkHttpClient = RelayClient.defaultHttpClient(), ) { - private val base = baseUrl.trimEnd('/').toHttpUrl() /** Who the relay says you are, and the credential to prove it with. */ @@ -36,80 +26,76 @@ class RelayAccounts( /** * The challenge is fetched only when the relay asks for one. - * Fetching up front would add a round trip to every sign-up on every - * server, for a feature that is off by default and off on every relay - * running today, a second wait on the one screen where somebody is - * already waiting. So the first attempt goes out plain, and only a relay - * that refuses it sends us to solve anything. - * - * The refused attempt costs one of fifty per hour from this address, which - * is the right trade for not charging everybody else a round trip. */ - fun signUp(username: String, email: String, password: String, displayName: String? = null): Account { - fun body() = JSONObject() - .put("username", username) - .put("email", email) - .put("password", password) - .put("display_name", displayName ?: username) + fun signUp( + username: String, + email: String, + password: String, + displayName: String? = null, + ): Account { + fun body() = + JSONObject() + .put("username", username) + .put("email", email) + .put("password", password) + .put("display_name", displayName ?: username) return try { account(post("account/signup", body())) } catch (asked: RelayException.ChallengeRequired) { - // Rethrown, not swallowed, when we cannot answer. A difficulty - // above the cap or a search that ran out leaves this app // unable to sign up here, and "update the app" is then the true - // sentence, quietly failing with something else would send - // somebody looking in the wrong place. + // Rethrown, not swallowed, when we cannot answer. val solved = SignupProofOfWork.satisfy(base.toString(), http) ?: throw asked - account(post("account/signup", body() - .put("challenge", solved.challenge) - .put("nonce", solved.nonce))) + account( + post( + "account/signup", + body() + .put("challenge", solved.challenge) + .put("nonce", solved.nonce), + ), + ) } } /** - * The field is "username or email" and always has been. A client - * that only sends usernames rejects half the people who typed exactly what - * they were asked for. + * The field is "username or email" and always has been. */ - fun signIn(usernameOrEmail: String, password: String): Account = - account(post("account/signin", JSONObject() - .put("username", usernameOrEmail) - .put("password", password))) + fun signIn( + usernameOrEmail: String, + password: String, + ): Account = + account( + post( + "account/signin", + JSONObject() + .put("username", usernameOrEmail) + .put("password", password), + ), + ) /** * Exchange a shared FlameNet account session for a relay session. - * - * Present only on a relay configured to trust an account service. A - * self-hosted relay answers 404 here, and that is the correct answer rather - * than a misconfiguration, it is its own identity provider. - * - * The account service's token carries a product-scoped pseudonym, not - * the handle, so the relay learns an opaque id and nothing that ties the - * person to any other FlameNet product. */ + /** - * The handle travels beside the token, not inside it. The account - * service will not put a handle in a token, so that a relay cannot - * correlate its users with another product's, the token carries a - * product-scoped pseudonym instead. The relay uses the handle only the - * first time it sees that pseudonym; after that the pseudonym decides, and - * honouring the handle would let somebody rename themselves into an - * existing account. + * The handle travels beside the token, not inside it. */ - fun sharedSession(accountToken: String, handle: String, displayName: String? = null): Account = - account(post("api/flamenet/v1/messenger/session", JSONObject() - .put("token", accountToken) - .put("handle", handle) - .also { if (!displayName.isNullOrEmpty()) it.put("display_name", displayName) })) + fun sharedSession( + accountToken: String, + handle: String, + displayName: String? = null, + ): Account = + account( + post( + "api/flamenet/v1/messenger/session", + JSONObject() + .put("token", accountToken) + .put("handle", handle) + .also { if (!displayName.isNullOrEmpty()) it.put("display_name", displayName) }, + ), + ) /** * Ask the relay to send a reset code. - * - * The relay answers identically whatever the truth is, unknown handle, - * no address on file, an account that authenticates elsewhere. So this - * cannot report whether it worked, and a UI that claims it can is lying. - * Anything thrown here is a transport or server failure, never "no such - * account". */ fun requestPasswordReset(handle: String) { post("account/reset/request", JSONObject().put("handle", handle)) @@ -117,81 +103,121 @@ class RelayAccounts( /** * Spend an email verification code. - * - * Unauthenticated, exactly like reset confirm: the code IS the proof. - * Requiring a signed-in session would mean somebody kept out *because* they - * are unverified could never verify. */ fun verifyEmail(token: String) { post("account/verify", JSONObject().put("token", token)) } - fun confirmPasswordReset(handle: String, code: String, newPassword: String) { - post("account/reset/confirm", JSONObject() - .put("handle", handle) - .put("code", code) - .put("password", newPassword)) + fun confirmPasswordReset( + handle: String, + code: String, + newPassword: String, + ) { + post( + "account/reset/confirm", + JSONObject() + .put("handle", handle) + .put("code", code) + .put("password", newPassword), + ) } - private fun account(json: JSONObject) = Account( - userId = json.getLong("user_id"), - username = json.getString("username"), - displayName = json.optString("display_name", json.getString("username")), - appPassword = json.getString("app_password"), - ) + private fun account(json: JSONObject) = + Account( + userId = json.getLong("user_id"), + username = json.getString("username"), + displayName = json.optString("display_name", json.getString("username")), + appPassword = json.getString("app_password"), + ) - private fun post(path: String, body: JSONObject): JSONObject { - val request = Request.Builder() - .url(base.newBuilder().addPathSegments(path).build()) - .post(body.toString().toRequestBody(JSON)) - .header("Accept", "application/json") - .build() - val response = try { - http.newCall(request).execute() - } catch (e: Exception) { - throw RelayException.Transport(e.message ?: e.toString(), e) - } + private fun post( + path: String, + body: JSONObject, + ): JSONObject { + val request = + Request + .Builder() + .url(base.newBuilder().addPathSegments(path).build()) + .post(body.toString().toRequestBody(JSON)) + .header("Accept", "application/json") + .build() + val response = + try { + http.newCall(request).execute() + } catch (e: Exception) { + throw RelayException.Transport(e.message ?: e.toString(), e) + } response.use { val text = it.body?.string().orEmpty() if (it.isSuccessful) { - return if (text.isBlank()) JSONObject() else try { - JSONObject(text) - } catch (_: Exception) { - throw RelayException.Malformed("the relay's reply was not a JSON object") + return if (text.isBlank()) { + JSONObject() + } else { + try { + JSONObject(text) + } catch (_: Exception) { + throw RelayException.Malformed("the relay's reply was not a JSON object") + } } } // The relay puts a human-readable reason in the body, and it is // better than anything this layer could invent from a status code. - val message = runCatching { JSONObject(text).optString("message") } - .getOrNull().orEmpty().ifBlank { text } - val code = runCatching { JSONObject(text).optString("code") } - .getOrNull().orEmpty() + val message = + runCatching { JSONObject(text).optString("message") } + .getOrNull() + .orEmpty() + .ifBlank { text } + val code = + runCatching { JSONObject(text).optString("code") } + .getOrNull() + .orEmpty() throw when { - // Decided by the relay's own `code`, never by the status. A - // 400 is also a malformed body and a bad email; telling either - // of those to go and update the app would send somebody to the - // Play Store to fix a typo. - code == "challenge_required" -> RelayException.ChallengeRequired(message) - code == "challenge_failed" -> RelayException.ChallengeFailed(message) - // A 403 is not an expired session. 401 means the token - // was refused and signing in again is the fix. 403 means a - // RULE refused this, too many devices, a retired device, an + // Decided by the relay's own `code`, never by the status. + code == "challenge_required" -> { + RelayException.ChallengeRequired(message) + } + + code == "challenge_failed" -> { + RelayException.ChallengeFailed(message) + } + + // A 403 is not an expired session. 401 means the token was + // refused and signing in again is the fix. 403 means a RULE + // refused this, too many devices, a retired device, an // unverified address, and the relay writes a sentence saying - // which. Reporting those as "your session expired, sign out and - // back in" sends somebody round a loop that cannot end: they - // sign in again, hit the same rule, and are told the same - // wrong thing. Observed tonight on a fresh install. - it.code == 401 -> RelayException.Unauthorized(message) - it.code == 403 && code == "email_unverified" -> + // which. + it.code == 401 -> { + RelayException.Unauthorized(message) + } + + it.code == 403 && code == "email_unverified" -> { RelayException.EmailUnverified(message) - it.code == 403 -> RelayException.Http(403, message) - it.code == 404 -> RelayException.NotFound(path) - it.code == 409 -> RelayException.Http(409, message) - it.code == 429 -> + } + + it.code == 403 -> { + RelayException.Http(403, message) + } + + it.code == 404 -> { + RelayException.NotFound(path) + } + + it.code == 409 -> { + RelayException.Http(409, message) + } + + it.code == 429 -> { RelayException.RateLimited(it.header("Retry-After")?.toIntOrNull()) - // The geo-gate, not the relay. Needs a sentence, not a number. - it.code == 451 -> RelayException.Unavailable(message) - else -> RelayException.Http(it.code, message) + } + + // The geo-gate, not the relay. + it.code == 451 -> { + RelayException.Unavailable(message) + } + + else -> { + RelayException.Http(it.code, message) + } } } } diff --git a/client/src/main/kotlin/io/flamenet/client/RelayAuth.kt b/client/src/main/kotlin/io/flamenet/client/RelayAuth.kt index 71f5e72..9690831 100644 --- a/client/src/main/kotlin/io/flamenet/client/RelayAuth.kt +++ b/client/src/main/kotlin/io/flamenet/client/RelayAuth.kt @@ -6,99 +6,76 @@ import java.util.UUID /** * Authentication for the relay's `/e2e/…` routes (spec §8.3). - * - * The relay refuses the account's own password on these routes, and that is a - * design decision rather than an oversight to work around. A credential good - * for every route on every one of an account's devices means a token lifted - * from one device drains another's prekey pool and reads its inbox. What the - * relay wants instead is a short-lived token scoped to one device and one - * relay, plus a per-request signature proving the sender holds the identity - * key that token names, so a stolen token, on its own, is inert. - * - * The nonce is not decoration. Without it, two inbox polls in the same - * second with the same empty body produce byte-identical signatures, and the - * relay's replay cache cannot tell an honest repeat from a captured one. It - * then refuses the honest client, and the symptom is an app that stops - * receiving mail for no visible reason. - * - * A deployment with no token endpoint answers 404 once and is then asked no - * more; the caller falls back to Basic. Discovered rather than configured, so - * one build talks to both. */ class RelayAuth( private val deviceId: String, private val identityKey: Ed25519.KeyPair, private val mint: (deviceId: String, identityPublicB64: String) -> Minted?, ) { - /** A minted token and the absolute second it stops being accepted. */ - data class Minted(val token: String, val lifetimeSeconds: Int) + data class Minted( + val token: String, + val lifetimeSeconds: Int, + ) - private class Cached(val token: String, val expiresAt: Long) + private class Cached( + val token: String, + val expiresAt: Long, + ) @Volatile private var cached: Cached? = null /** * Set once the relay answers "no such route". - * - * Without it, a deployment that will never mint a token is asked on every - * single request, a round trip per call, forever, to learn the same thing. */ @Volatile private var unavailable = false /** * A valid token, minting one when the cache is empty or close to expiry. - * Null when this deployment does not issue them. */ - /** Plain Kotlin module: no android.util.Log here. On device this lands in - * logcat as `W/System.err`, which is what matters, it is READABLE. */ + + /** + * Plain Kotlin module: no android.util.Log here. + */ private fun warn(message: String) = System.err.println("fnmsg-auth: $message") fun token(now: Long = System.currentTimeMillis() / 1000): String? { if (unavailable) return null cached?.let { if (it.expiresAt - SLACK_SECONDS > now) return it.token } - val minted = try { - mint(deviceId, Wire.b64(identityKey.publicKey)) - } catch (e: RelayException.NotFound) { - // Say it. Every failure here used to be swallowed in - // silence, and the consequence was invisible: with no token the - // client falls back to Basic, the relay refuses `/e2e/*`, which it - // does by design, and the user sees "the call failed", "could not - // finish setting up", or an inbox that 410s and wipes itself. Four - // hours of debugging produced symptoms and no cause, because the - // one line that knew what went wrong discarded it. - warn("no token: relay has no /account/token route; " + - "falling back to Basic, which /e2e/* refuses. Calls and device " + - "registration will fail until this is fixed." + " [" + e + "]") - unavailable = true - return null - } catch (e: Exception) { - warn("no token: minting failed (${e.javaClass.simpleName}: " + - "${e.message}); falling back to Basic, which /e2e/* refuses.") - return null - } ?: run { - // A non-2xx that is not 404 arrives here as null, not as a - // throw, so this branch is NOT unreachable, it was the quietest - // path of the three. - warn("no token: /account/token answered a non-success " + - "status; falling back to Basic, which /e2e/* refuses.") - return null - } + val minted = + try { + mint(deviceId, Wire.b64(identityKey.publicKey)) + } catch (e: RelayException.NotFound) { + // Say it. + warn( + "no token: relay has no /account/token route; " + + "falling back to Basic, which /e2e/* refuses. Calls and device " + + "registration will fail until this is fixed." + " [" + e + "]", + ) + unavailable = true + return null + } catch (e: Exception) { + warn( + "no token: minting failed (${e.javaClass.simpleName}: " + + "${e.message}); falling back to Basic, which /e2e/* refuses.", + ) + return null + } ?: run { + // A non-2xx that is not 404 arrives here as null, not as a throw, + // so this branch is NOT unreachable, it was the quietest path of + // the three. + warn( + "no token: /account/token answered a non-success " + + "status; falling back to Basic, which /e2e/* refuses.", + ) + return null + } cached = Cached(minted.token, now + minted.lifetimeSeconds) return minted.token } /** * Discard the cached token, so one stale token costs a single retry. - * - * Also un-latches [unavailable]. That flag is a told-once marker: it - * concludes "this deployment has no token endpoint" from a single 404, but - * a reverse proxy answers 404 for *every* route while the relay behind it - * is down, so an outage at the wrong moment latched it against a relay that - * does issue tokens, and every request thereafter went out with no token at - * all. The 401 that brings callers here is direct evidence the conclusion - * was wrong: a deployment that didn't want tokens would not refuse us for - * lacking one. */ fun invalidate() { cached = null @@ -107,13 +84,6 @@ class RelayAuth( /** * The proof headers for one request. - * - * `path` is the logical §8 path, not the URL's path. The relay mounts - * these routes at both `/e2e/…` and under a REST prefix, and signs against - * the former, so the proof must be computed over the same string whichever - * mount the request is addressed to. Signing the URL path instead produces - * a valid signature over the wrong thing, and the relay rejects it with no - * hint as to why. */ fun proofHeaders( method: String, @@ -123,8 +93,11 @@ class RelayAuth( ): Map { val timestamp = now.toString() val nonce = UUID.randomUUID().toString() - val digest = MessageDigest.getInstance("SHA-256").digest(body) - .joinToString("") { "%02x".format(it) } + val digest = + MessageDigest + .getInstance("SHA-256") + .digest(body) + .joinToString("") { "%02x".format(it) } val canonical = "$method\n$path\n$timestamp\n$nonce\n$digest".toByteArray(Charsets.UTF_8) return mapOf( "X-FN-Timestamp" to timestamp, diff --git a/client/src/main/kotlin/io/flamenet/client/RelayClient.kt b/client/src/main/kotlin/io/flamenet/client/RelayClient.kt index a1088e1..6773cbc 100644 --- a/client/src/main/kotlin/io/flamenet/client/RelayClient.kt +++ b/client/src/main/kotlin/io/flamenet/client/RelayClient.kt @@ -3,11 +3,11 @@ package io.flamenet.client import io.flamenet.e2e.DeviceIdentity import io.flamenet.e2e.Envelope import io.flamenet.e2e.PeerBundle +import io.flamenet.obfs4.Obfs4Bridge +import io.flamenet.obfs4.Obfs4SocketFactory import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.MediaType.Companion.toMediaType -import io.flamenet.obfs4.Obfs4Bridge -import io.flamenet.obfs4.Obfs4SocketFactory import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody @@ -19,15 +19,6 @@ import java.util.concurrent.TimeUnit /** * The relay's §8 routes. - * - * Field names are the protocol. The one bug this system has actually - * shipped was a client and a relay disagreeing about a field name, and nothing - * caught it until a live round trip, not the type checker, not a unit test. - * So every name here appears exactly once, and the tests run against a real - * HTTP server rather than a fake that agrees with this file by construction. - * - * Two authentication modes, discovered rather than configured, so one build - * talks to a relay that mints tokens and to one that does not. See [RelayAuth]. */ class RelayClient( baseUrl: String, @@ -36,16 +27,11 @@ class RelayClient( private val restPrefix: String = "api", private val http: OkHttpClient = defaultHttpClient(), ) { - private val base: HttpUrl = baseUrl.trimEnd('/').toHttpUrl() /** * The relay this client talks to, for anything that has to remember a fact * about a specific one, §19's "has this relay ever served a log". - * - * The host, not the URL. Handing out the base would let a caller build a - * second client for the same relay with different credentials, which is the - * one thing this type exists to keep in one place. */ val host: String get() = base.host @@ -65,153 +51,113 @@ class RelayClient( const val HARD_ATTACHMENT_CAP = 12L * 1024 * 1024 /** - * @param bridge when set, every request is carried inside obfs4 to that - * bridge, which proxies on to the relay. - * - * Null by default, and that is the shipping configuration. obfs4 - * is for reaching a relay on a network that blocks or fingerprints TLS - * to it. Turning it on for everyone would change the network path for - * every user to solve a problem most of them do not have, and, on a - * host where the bridge shares the relay's address, would not even help - * against IP blocking. - * - * The bridge is a separate hop, so obfs4 hides traffic from the - * NETWORK, not from us. It is not a privacy feature against the - * operator, and must never be described as one. + * @param bridge when set, every request is carried inside obfs4 to + * that bridge, which proxies on to the relay. */ fun defaultHttpClient(bridge: Obfs4Bridge? = null): OkHttpClient = - OkHttpClient.Builder() + OkHttpClient + .Builder() .callTimeout(25, TimeUnit.SECONDS) - // No response cache, deliberately. The payloads are ciphertext, but - // sealed blobs and envelope traffic surviving on disk after the user - // deletes a chat is exactly what "deleted means deleted" forbids. + // No response cache, deliberately. .cache(null) - // Installed unconditionally. The - // parameter above is for tests and interop, which pin one - // bridge. Everything the app builds passes nothing, so if the - // factory were only installed when a bridge was already known, - // the setting could never reach the ten sites that construct - // clients, several of them from default parameters evaluated - // before the user has touched anything. With no bridge in force - // the factory delegates to the platform's own, so this changes - // nothing for the overwhelming majority who never set one. + // Installed unconditionally. .socketFactory(Obfs4SocketFactory { bridge ?: Obfs4Routing.bridge }) - // Connections must not outlive a transport change, and an - // obfs4 handshake makes each one expensive to establish, so the - // pool is shared, `Obfs4Routing.use` empties it, and its idle - // window is short: a bridge that goes away should not leave - // requests queued behind a dead tunnel for minutes. + // Connections must not outlive a transport change, and an obfs4 + // handshake makes each one expensive to establish, so the pool + // is shared, `Obfs4Routing.use` empties it, and its idle window + // is short: a bridge that goes away should not leave requests + // queued behind a dead tunnel for minutes. .connectionPool(Obfs4Routing.pool) .build() } /** * Adopt token authentication. - * - * Not a constructor parameter: the client is built during sign-in, before a - * device identity has been generated or loaded, and it has real work to do - * in that window. */ fun useRelayAuth(identity: DeviceIdentity) { - auth = RelayAuth( - deviceId = identity.deviceId, - identityKey = identity.identitySigning, - mint = ::mintToken, - ) + auth = + RelayAuth( + deviceId = identity.deviceId, + identityKey = identity.identitySigning, + mint = ::mintToken, + ) } - // -- Devices and keys ---------------------------------------------------- + // -- Devices and keys ----------------------------------------------------. /** `POST /e2e/devices`, publish this device's identity and prekeys. */ fun registerDevice( identity: DeviceIdentity, oneTimePreKeys: List, pqOneTimePreKeys: List = emptyList(), - ): JSONObject = - postJson("/e2e/devices", identity.registrationWire(oneTimePreKeys, pqOneTimePreKeys)) + ): JSONObject = postJson("/e2e/devices", identity.registrationWire(oneTimePreKeys, pqOneTimePreKeys)) /** * `POST /e2e/prekeys`, top the pools back up. - * - * Both pools, in one request. A post-quantum pool that drains ahead - * of the classical one silently pushes every new session onto the - * last-resort key, a real weakening, since they then all share one KEM key - * with nothing on either end saying so. Refilling them together is what - * keeps that from happening by omission. */ fun uploadPreKeys( deviceId: String, preKeys: List, pqPreKeys: List = emptyList(), ) { - val body = JSONObject() - .put("device_id", deviceId) - .put("prekeys", JSONArray().also { array -> - preKeys.forEach { - array.put(JSONObject().put("id", it.id).put("pub", Wire.b64(it.publicKey))) - } - }) + val body = + JSONObject() + .put("device_id", deviceId) + .put( + "prekeys", + JSONArray().also { array -> + preKeys.forEach { + array.put(JSONObject().put("id", it.id).put("pub", Wire.b64(it.publicKey))) + } + }, + ) if (pqPreKeys.isNotEmpty()) { - body.put("pq_prekeys", JSONArray().also { array -> - pqPreKeys.forEach { - array.put(JSONObject() - .put("id", it.id) - .put("pub", Wire.b64(it.publicKey)) - .put("sig", Wire.b64(it.signature))) - } - }) + body.put( + "pq_prekeys", + JSONArray().also { array -> + pqPreKeys.forEach { + array.put( + JSONObject() + .put("id", it.id) + .put("pub", Wire.b64(it.publicKey)) + .put("sig", Wire.b64(it.signature)), + ) + } + }, + ) } postJson("/e2e/prekeys", body) } /** * `GET /e2e/keys/{user}`, a bundle per device. - * - * This consumes a one-time prekey per device, every call. Use - * [listDevices] to look at who a user is; use this only when actually - * starting a session. Polling it drains the pool and pushes every peer onto - * the last-resort key, which costs initial forward secrecy for everyone. */ + /** A peer's bundles, and the address their key-log entries are filed under. */ - data class Keys(val fnid: String?, val bundles: List) + data class Keys( + val fnid: String?, + val bundles: List, + ) /** - * The `fnid` is not decoration; without it the key log cannot be - * checked. The relay files a leaf under `fnid:
` once an account - * has claimed one, and under `local:/` before that. A - * verifier that does not know the address computes the other subject, finds - * no matching leaf, and refuses to send, reporting "has published keys, - * but none match what this relay served" for a conversation in which - * nothing is wrong. - * - * This response has always carried it and this method always dropped it, so - * every newly registered device on an account with an address was - * unverifiable and therefore unsendable-to. It presents as messaging - * breaking the moment somebody reinstalls. + * The `fnid` is not decoration; without it the key log cannot be checked. */ fun fetchKeys(userId: Long): Keys { val body = getJson("/e2e/keys/$userId") val devices = body.optJSONArray("devices") ?: JSONArray() return Keys( fnid = body.optString("fnid").ifEmpty { null }, - bundles = (0 until devices.length()) - .map { PeerBundle.fromWire(devices.getJSONObject(it)) }, + bundles = + (0 until devices.length()) + .map { PeerBundle.fromWire(devices.getJSONObject(it)) }, ) } /** `GET /e2e/devices/{user}`, who a user is, costing no prekey. */ - fun listDevices(userId: Long): JSONArray = - listDirectory(userId).devices + fun listDevices(userId: Long): JSONArray = listDirectory(userId).devices /** * The directory, with the account's address alongside it. - * - * The address is what makes the key log checkable: a leaf is written - * under `fnid:` once an account has claimed one, and a verifier that does - * not know it computes the wrong subject and refuses the send. The send - * path deliberately fetches full bundles only when a session needs - * establishing, so on every other message this is the only place the - * address can come from. */ fun listDirectory(userId: Long): Directory { val o = getJson("/e2e/devices/$userId") @@ -224,41 +170,32 @@ class RelayClient( ) } - data class Directory(val devices: JSONArray, val fnid: String?) + data class Directory( + val devices: JSONArray, + val fnid: String?, + ) /** * `DELETE /e2e/devices/{id}`, remove one of the ACCOUNT'S OWN devices. - * The relay refuses anyone else's, and drops the device's prekeys and - * queued mail with it (§8.3a). */ - fun revokeDevice(deviceId: String): JSONObject = - requestJson("DELETE", url("/e2e/devices/$deviceId"), "/e2e/devices/$deviceId", null) + fun revokeDevice(deviceId: String): JSONObject = requestJson("DELETE", url("/e2e/devices/$deviceId"), "/e2e/devices/$deviceId", null) - // -- Messages ------------------------------------------------------------ + // -- Messages ------------------------------------------------------------. /** One envelope for one of the recipient's devices. */ - data class ToDevice(val deviceId: String, val payload: String) { + data class ToDevice( + val deviceId: String, + val payload: String, + ) { constructor(deviceId: String, envelope: Envelope) : this(deviceId, envelope.toPayload()) } /** * `POST /e2e/messages`, one recipient, one envelope per device of theirs. - * - * The recipient is named once, at the top, not per envelope. A body - * that repeats `to_user` inside each message is rejected outright as a - * malformed body, which at least fails loudly, the shape being wrong in a - * way the relay tolerates is the version that costs a week. */ + /** - * @param deliveryKey the recipient's capability, base64. When present the - * submission is sealed: the relay is not told who is sending, and - * `fromDevice` is omitted entirely rather than sent as a courtesy. - * - * Omitting `from_device` is the point, not tidiness. Sending it - * alongside a sealed payload would hand the relay the sender's identity at - * request time, so the seal would hide the sender from the stored row and - * from nobody else, strictly worse than not sealing, because it looks like - * it worked. + * @param deliveryKey the recipient's capability, base64. */ fun sendEnvelopes( toUser: Long, @@ -267,50 +204,42 @@ class RelayClient( deliveryKey: String? = null, /** * §21.4, a single-use capability, spent on acceptance. - * - * Sent instead of [deliveryKey], never alongside it. Sending both - * would let the relay accept on the key when the capability had been - * revoked, so it would never learn the capability was refused, and - * revocation is the whole lever §21 adds. */ deliveryToken: String? = null, /** * Deliver without announcing: protocol traffic, not a message. - * - * The relay announced everything, because it cannot tell. It - * holds ciphertext, so a call answer, a delivery key and a birthday - * message are the same bytes to it, and every one of them raised - * "New message" on the far end. One call produced a burst of banners - * for things nobody sent. The sender is the only party that knows. - * - * Advisory and unauthenticated. A sender that lies suppresses its - * own notifications and nobody else's. */ silent: Boolean = false, ): JSONObject = - postJson("/e2e/messages", JSONObject() - .put("to", toUser) - .also { body -> - if (deliveryToken != null) body.put("delivery_token", deliveryToken) - else if (deliveryKey != null) body.put("delivery_key", deliveryKey) - else body.put("from_device", fromDevice) - if (silent) body.put("silent", true) - } - .put("messages", JSONArray().also { array -> - envelopes.forEach { - array.put(JSONObject() - .put("to_device", it.deviceId) - .put("payload", it.payload)) - } - })) + postJson( + "/e2e/messages", + JSONObject() + .put("to", toUser) + .also { body -> + if (deliveryToken != null) { + body.put("delivery_token", deliveryToken) + } else if (deliveryKey != null) { + body.put("delivery_key", deliveryKey) + } else { + body.put("from_device", fromDevice) + } + if (silent) body.put("silent", true) + }.put( + "messages", + JSONArray().also { array -> + envelopes.forEach { + array.put( + JSONObject() + .put("to_device", it.deviceId) + .put("payload", it.payload), + ) + } + }, + ), + ) /** * One envelope from the inbox, with the server id that acknowledges it. - * - * [createdAt] is when the RELAY accepted the envelope, in epoch millis, - * 0 when the relay sent nothing parseable. It exists for call signalling: - * an offer that sat in the mailbox while this device was closed must not - * ring minutes later for a caller who has long since given up. */ data class Incoming( val id: Long, @@ -322,29 +251,27 @@ class RelayClient( * The same value in every copy of one message, so a device reading * several mailboxes collapses them rather than showing the message once * per home. - * - * Empty on a relay older than multi-homing, and with one mailbox - * there is nothing to collapse, so an empty value means "no reason to - * think this is a duplicate", not "this is not a duplicate". */ val envelopeId: String = "", ) /** * `GET /e2e/messages`, this device's inbox. - * - * `after` is the highest id already ingested; the relay returns `id > after` - * in ascending order. Storing that watermark only once a message has been - * decrypted and saved is what makes the poll idempotent, advance it on - * receipt and a crash mid-batch loses the rest silently. */ - fun pollInbox(deviceId: String, after: Long, limit: Int = 100): List { - val url = base.newBuilder().apply { - addPathSegments(routePath("/e2e/messages")) - addQueryParameter("device_id", deviceId) - addQueryParameter("after", after.toString()) - addQueryParameter("limit", limit.toString()) - }.build() + fun pollInbox( + deviceId: String, + after: Long, + limit: Int = 100, + ): List { + val url = + base + .newBuilder() + .apply { + addPathSegments(routePath("/e2e/messages")) + addQueryParameter("device_id", deviceId) + addQueryParameter("after", after.toString()) + addQueryParameter("limit", limit.toString()) + }.build() val body = requestJson("GET", url, "/e2e/messages", null) val messages = body.optJSONArray("messages") ?: JSONArray() return (0 until messages.length()).map { @@ -354,11 +281,13 @@ class RelayClient( fromUser = m.optLong("from_user", 0), fromDevice = m.optString("from_device", ""), payload = m.getString("payload"), - // ISO8601, with or without fractional seconds. Unparseable or - // absent degrades to 0 rather than failing the whole poll. - createdAt = runCatching { - java.time.Instant.parse(m.optString("created_at")).toEpochMilli() - }.getOrDefault(0), + // ISO8601, with or without fractional seconds. + createdAt = + runCatching { + java.time.Instant + .parse(m.optString("created_at")) + .toEpochMilli() + }.getOrDefault(0), envelopeId = m.optString("envelope_id"), ) } @@ -366,8 +295,7 @@ class RelayClient( /** * `POST /e2e/delivery-key`, publish SHA-256 of our delivery key so peers - * may send sealed (§14.4). Only the digest goes to the relay; the key - * itself travels inside end-to-end messages. + * may send sealed (§14.4). */ fun setDeliveryKey(digest: ByteArray) { postJson("/e2e/delivery-key", JSONObject().put("digest", Wire.b64(digest))) @@ -375,35 +303,27 @@ class RelayClient( /** * `POST /e2e/delivery-tokens`, add and drop single-use capabilities - * (§21.4). Returns how many unspent ones this account now holds in total. - * - * One route with two lists, not two routes. Topping one contact up and - * cutting another off are the same set difference to the relay, and - * splitting them would let somebody watching request *shape* tell the two - * apart without reading a byte of the body. + * (§21.4). */ - fun setDeliveryTokens(add: List, revoke: List): Int = - postJson("/e2e/delivery-tokens", JSONObject() - .put("add", JSONArray(add)) - .put("revoke", JSONArray(revoke))).optInt("remaining", 0) + fun setDeliveryTokens( + add: List, + revoke: List, + ): Int = + postJson( + "/e2e/delivery-tokens", + JSONObject() + .put("add", JSONArray(add)) + .put("revoke", JSONArray(revoke)), + ).optInt("remaining", 0) - // -- Attachments --------------------------------------------------------- + // -- Attachments ---------------------------------------------------------. /** * `POST /e2e/attachments`, one sealed blob, raw `nonce||ct||tag` (§8.7). - * - * The returned id is the download capability. It must only ever travel - * inside the ratchet; putting it anywhere the relay can read defeats the - * point of encrypting the blob. */ + /** * `POST /e2e/moderation/report`, file a report (§6.25). - * - * The one call that deliberately sends readable content to the - * server. Everything else this client sends is sealed. A relay that - * cannot open a reported story cannot act on an accusation about it, so - * the reporter's own copy travels with the report, and the caller is - * required to have obtained consent naming that, before reaching here. */ fun submitReport( about: Long, @@ -412,15 +332,20 @@ class RelayClient( content: String, note: String, ) { - val body = JSONObject() - .put("about", about) - .put("kind", kind) - .put("reason", reason) - .put("content", content) - .put("note", note) - .toString() - request("POST", url("/e2e/moderation/report"), "/e2e/moderation/report", - body.toRequestBody(JSON)).use { it.close() } + val body = + JSONObject() + .put("about", about) + .put("kind", kind) + .put("reason", reason) + .put("content", content) + .put("note", note) + .toString() + request( + "POST", + url("/e2e/moderation/report"), + "/e2e/moderation/report", + body.toRequestBody(JSON), + ).use { it.close() } } fun uploadAttachment(blob: ByteArray): String = @@ -428,16 +353,12 @@ class RelayClient( .use { json(it).getString("id") } /** - * `GET /e2e/attachments/{id}`. The caller verifies the digest and decrypts. - * - * Bounded before it is read (audit M5). `maxBytes` is the size the - * sealed reference promised, it arrived through the ratchet, so the relay - * cannot inflate it. Without a ceiling, `body.bytes()` reads whatever the - * relay sends, and a malicious one answers a photo request with a - * multi-gigabyte body that kills the app before the digest check it would - * have failed. + * `GET /e2e/attachments/{id}`. */ - fun downloadAttachment(id: String, maxBytes: Long = HARD_ATTACHMENT_CAP): ByteArray = + fun downloadAttachment( + id: String, + maxBytes: Long = HARD_ATTACHMENT_CAP, + ): ByteArray = request("GET", url("/e2e/attachments/$id"), "/e2e/attachments/$id", null) .use { response -> val body = response.body ?: return@use ByteArray(0) @@ -463,20 +384,10 @@ class RelayClient( out.toByteArray() } - // -- Groups (§8.9) ------------------------------------------------------- + // -- Groups (§8.9) -------------------------------------------------------. /** * A group as the relay can describe it. - * - * Members are ids and nothing else. The relay has no user table and - * deliberately will not grow one, profile data staying with the identity - * provider is the boundary the whole split is about, and a relay that - * caches display names is a relay that leaks them. Names are resolved - * client-side against the contact list. - * - * This is the exact shape the iOS client got wrong: it required a `name` - * on each member that the relay has never sent, so every group decode - * failed and groups were completely broken while looking implemented. */ data class Group( val id: String, @@ -491,31 +402,27 @@ class RelayClient( id = wire.getString("gid"), name = wire.optString("name"), owner = wire.optLong("owner"), - memberIds = (0 until members.length()) - .map { members.getJSONObject(it).getLong("user_id") }, + memberIds = + (0 until members.length()) + .map { members.getJSONObject(it).getLong("user_id") }, ) } } } /** - * Create a group. The name is not a parameter, deliberately. - * - * The relay used to store it, in the clear, in a column it never read, - * "Chemotherapy support", "Union organising", sitting in a table that can - * be dumped or subpoenaed. It stopped, so the request now carries only the - * roster, which is the part the relay has to route. Members are told what - * the group is called over their pairwise ratchets, by [Conversations]. - * - * The `name` that comes back on [Group] is a placeholder, the same word - * for every group, kept so builds that predate the change do not draw a - * blank row. Callers substitute the name they hold locally. + * Create a group. */ fun createGroup(memberIds: List): Group = - Group.fromWire(postJson("/e2e/groups", JSONObject() - .put("members", JSONArray(memberIds)))) + Group.fromWire( + postJson( + "/e2e/groups", + JSONObject() + .put("members", JSONArray(memberIds)), + ), + ) - // -- §8.9b Sealed groups ------------------------------------------------- + // -- §8.9b Sealed groups -------------------------------------------------. /** A sealed group as the relay can describe it: a blob and a number. */ data class SealedGroup( @@ -527,53 +434,74 @@ class RelayClient( /** * Create a group whose roster the relay cannot read. - * - * The gid is ours to choose. The owner's signature covers it, and a - * client cannot sign an id the relay has not minted yet. */ fun createSealedGroup( - gid: String, ownerKey: String, rosterBlob: String, tags: List, sig: String + gid: String, + ownerKey: String, + rosterBlob: String, + tags: List, + sig: String, ): SealedGroup { - val o = postJson("/e2e/groups/sealed", JSONObject() - .put("gid", gid) - .put("owner_key", ownerKey) - .put("roster_blob", rosterBlob) - .put("tags", JSONArray(tags)) - .put("sig", sig)) - return SealedGroup(o.getString("gid"), o.getString("owner_key"), - o.getString("roster_blob"), o.optInt("roster_version", 1)) + val o = + postJson( + "/e2e/groups/sealed", + JSONObject() + .put("gid", gid) + .put("owner_key", ownerKey) + .put("roster_blob", rosterBlob) + .put("tags", JSONArray(tags)) + .put("sig", sig), + ) + return SealedGroup( + o.getString("gid"), + o.getString("owner_key"), + o.getString("roster_blob"), + o.optInt("roster_version", 1), + ) } /** * "Give me the groups these tags open", never "what groups am I in". - * The relay cannot answer the second question about a sealed group, and a - * client that asked it would be asking for a list only a relay holding the - * social graph could produce. */ fun fetchSealedGroups(tags: List): List { - val list = postJson("/e2e/groups/fetch", JSONObject().put("tags", JSONArray(tags))) - .optJSONArray("groups") ?: JSONArray() + val list = + postJson("/e2e/groups/fetch", JSONObject().put("tags", JSONArray(tags))) + .optJSONArray("groups") ?: JSONArray() return (0 until list.length()).map { val o = list.getJSONObject(it) - SealedGroup(o.getString("gid"), o.getString("owner_key"), - o.getString("roster_blob"), o.optInt("roster_version", 1)) + SealedGroup( + o.getString("gid"), + o.getString("owner_key"), + o.getString("roster_blob"), + o.optInt("roster_version", 1), + ) } } fun replaceRoster( - groupId: String, blob: String, version: Int, sig: String, - tags: List, newOwnerKey: String? = null, + groupId: String, + blob: String, + version: Int, + sig: String, + tags: List, + newOwnerKey: String? = null, ) { - postJson("/e2e/groups/$groupId/roster", JSONObject() - .put("roster_blob", blob) - .put("roster_version", version) - .put("sig", sig) - .put("tags", JSONArray(tags)) - .also { o -> newOwnerKey?.let { o.put("new_owner_key", it) } }) + postJson( + "/e2e/groups/$groupId/roster", + JSONObject() + .put("roster_blob", blob) + .put("roster_version", version) + .put("sig", sig) + .put("tags", JSONArray(tags)) + .also { o -> newOwnerKey?.let { o.put("new_owner_key", it) } }, + ) } /** Leaving is surrendering the tag that let you in. */ - fun leaveSealedGroup(groupId: String, tag: String) { + fun leaveSealedGroup( + groupId: String, + tag: String, + ) { postJson("/e2e/groups/$groupId/leave-sealed", JSONObject().put("tag", tag)) } @@ -582,117 +510,99 @@ class RelayClient( return (0 until list.length()).map { Group.fromWire(list.getJSONObject(it)) } } - fun updateGroupMembers(groupId: String, add: List, remove: List): Group = - Group.fromWire(postJson("/e2e/groups/$groupId/members", JSONObject() - .put("add", JSONArray(add)) - .put("remove", JSONArray(remove)))) + fun updateGroupMembers( + groupId: String, + add: List, + remove: List, + ): Group = + Group.fromWire( + postJson( + "/e2e/groups/$groupId/members", + JSONObject() + .put("add", JSONArray(add)) + .put("remove", JSONArray(remove)), + ), + ) fun leaveGroup(groupId: String) { postJson("/e2e/groups/$groupId/leave", JSONObject()) } - // -- Account identity (§20) ---------------------------------------------- + // -- Account identity (§20) ----------------------------------------------. /** - * `POST /e2e/account/identity`, register this account's Ed25519 account - * key and take the fnid derived from it. - * - * Self-certifying: the signature is checked with the very key being - * registered, so the relay confirms possession and forms no opinion about - * who the key belongs to, the property that lets a hostile relay be - * tolerated at all. - * - * Claiming changes what the key log records this account under. - * Leaves already in the tree keep the subject they were written with; - * publications after the claim are filed under `fnid:` instead of - * `local:/`. A verifier that does not know the fnid - * therefore cannot recompute the leaf, see [Trust.enforceKeyLog]. - * - * Idempotent for the key already on file. A *different* key is refused - * rather than rotated: rotation changes the address and strands every - * contact who pinned the old one. + * `POST /e2e/account/identity`, register this account's Ed25519 account key + * and take the fnid derived from it. */ - fun claimAccountIdentity(accountKeyB64: String, signatureB64: String): JSONObject = - postJson("/e2e/account/identity", JSONObject() - .put("account_key", accountKeyB64) - .put("sig", signatureB64)) + fun claimAccountIdentity( + accountKeyB64: String, + signatureB64: String, + ): JSONObject = + postJson( + "/e2e/account/identity", + JSONObject() + .put("account_key", accountKeyB64) + .put("sig", signatureB64), + ) /** * `GET /e2e/account/identity/{handle}`, the global address behind a local * handle, or null when that account has not claimed one. - * - * Unauthenticated by design: it discloses a public key's address, which was - * already public, and a directory only this relay's own users can read is - * not one a federated peer could use. - * - * 404 is not "no such person". The relay answers the same way for an - * account that does not exist and one that has never enrolled, deliberately, - * so this cannot be used to enumerate accounts. Null here means "no address - * to pin", nothing more. */ - fun resolveIdentity(handle: String): String? = try { - getJson("/e2e/account/identity/$handle").optString("fnid").ifEmpty { null } - } catch (_: RelayException.NotFound) { - null - } + fun resolveIdentity(handle: String): String? = + try { + getJson("/e2e/account/identity/$handle").optString("fnid").ifEmpty { null } + } catch (_: RelayException.NotFound) { + null + } // -- Federation (§20.5) -------------------------------------------------- - // - // These three go to OUR relay, not to the peer's. A client never speaks to - // a relay it has not signed in to: ours signs the server-to-server request - // with its own federation key, so pointing an app at somebody else's relay - // cannot forge traffic between two servers. + // These three go to OUR relay, not to the peer's. /** * The devices a remote address currently has. - * - * The directory, not the prekey route, this consumes nothing. Calling - * the bundle route once per message is what drained a local prekey pool - * already; the same mistake costs more here, because the pool being emptied - * belongs to somebody on another relay who cannot see why. */ - fun remoteDevices(fnid: String, relay: String): JSONArray = - getJson("/e2e/remote/$relay/devices/$fnid").optJSONArray("devices") ?: JSONArray() + fun remoteDevices( + fnid: String, + relay: String, + ): JSONArray = getJson("/e2e/remote/$relay/devices/$fnid").optJSONArray("devices") ?: JSONArray() /** * Prekey bundles for a remote address. - * - * Pops a one-time prekey on the far side, exactly as the local - * `/e2e/keys` route does. Ask only when there is a session to - * open, never to find out who somebody is. */ - fun remoteBundles(fnid: String, relay: String): JSONArray = - getJson("/e2e/remote/$relay/bundles/$fnid").optJSONArray("devices") ?: JSONArray() + fun remoteBundles( + fnid: String, + relay: String, + ): JSONArray = getJson("/e2e/remote/$relay/bundles/$fnid").optJSONArray("devices") ?: JSONArray() /** * Hand envelopes to our relay for delivery to [toFnid] at [toRelay]. - * - * The reply says how many copies were taken, which addresses the far relay - * did not recognise, and which of that account's mailboxes could not be - * reached, all three matter, because with several homes a send is - * partial and a client that cannot tell cannot decide between - * retrying and warning. */ - fun sendRemote(toFnid: String, toRelay: String, messages: List): JSONObject { + fun sendRemote( + toFnid: String, + toRelay: String, + messages: List, + ): JSONObject { val array = JSONArray() for (m in messages) { - array.put(JSONObject() - .put("to_device", m.toDevice) - .put("payload", m.payload) - .put("envelope_id", m.envelopeId)) + array.put( + JSONObject() + .put("to_device", m.toDevice) + .put("payload", m.payload) + .put("envelope_id", m.envelopeId), + ) } - return postJson("/e2e/send/remote", JSONObject() - .put("to_fnid", toFnid) - .put("to_relay", toRelay) - .put("messages", array)) + return postJson( + "/e2e/send/remote", + JSONObject() + .put("to_fnid", toFnid) + .put("to_relay", toRelay) + .put("messages", array), + ) } /** * One copy of one message, for one of the recipient's devices. - * - * [envelopeId] is per MESSAGE, not per device or per mailbox. A - * recipient polling several homes collapses the copies by it; a fresh id - * per copy would show them the same message once per mailbox. */ data class RemoteMessage( val toDevice: String, @@ -700,48 +610,37 @@ class RelayClient( val envelopeId: String, ) - // -- Multi-homing (§20.4) ------------------------------------------------ + // -- Multi-homing (§20.4) ------------------------------------------------. /** * `POST /e2e/account/homes`, say which relays hold mailboxes for this * account, so a sender delivers to all of them and losing one loses * nothing. - * - * The relay being written to must be in [relays]. Otherwise an - * account could point every sender elsewhere while still reading an inbox - * here, and mail addressed by the record would never reach the box the - * person actually opens. The relay refuses that rather than storing it. - * - * [serial] must exceed whatever is already published. It is not a version - * for humans to read, it is the only thing that stops a relay replaying an - * older, signed list that happens to name only itself. */ - fun publishHomes(serial: Int, relays: List, signatureB64: String): JSONObject = - postJson("/e2e/account/homes", JSONObject() - .put("serial", serial) - .put("relays", JSONArray(relays)) - .put("sig", signatureB64)) + fun publishHomes( + serial: Int, + relays: List, + signatureB64: String, + ): JSONObject = + postJson( + "/e2e/account/homes", + JSONObject() + .put("serial", serial) + .put("relays", JSONArray(relays)) + .put("sig", signatureB64), + ) /** * `GET /e2e/account/homes/{fnid}`, where to deliver, with the signature. - * - * Unauthenticated, and hands back the proof alongside the list precisely so - * the caller can check it instead of trusting this relay's copy. A relay - * serving a truncated list is the exact failure multi-homing exists to - * survive, so the answer has to be verifiable by whoever receives it, see - * [AccountIdentity.verifyHomes]. - * - * Null for an account that has published nothing, which is the ordinary - * case and not an error: it means one mailbox, at the relay you asked. */ - fun homes(fnid: String): JSONObject? = try { - getJson("/e2e/account/homes/$fnid") - } catch (_: RelayException.NotFound) { - null - } + fun homes(fnid: String): JSONObject? = + try { + getJson("/e2e/account/homes/$fnid") + } catch (_: RelayException.NotFound) { + null + } // -- Transparency log (§19) --------------------------------------------- - // // Unauthenticated on the relay, and no proof headers are sent: the value of // a transparency log is that anyone can read it, including someone who is // not a user of the relay. @@ -750,32 +649,41 @@ class RelayClient( fun logDeviceEntries(deviceId: String): JSONObject = getJson("/e2e/log/device/$deviceId") - fun logInclusion(index: Long, size: Long): JSONObject = - getJson("/e2e/log/inclusion", mapOf("index" to index.toString(), "size" to size.toString())) + fun logInclusion( + index: Long, + size: Long, + ): JSONObject = getJson("/e2e/log/inclusion", mapOf("index" to index.toString(), "size" to size.toString())) - fun logConsistency(from: Long, to: Long): JSONObject = - getJson("/e2e/log/consistency", mapOf("from" to from.toString(), "to" to to.toString())) + fun logConsistency( + from: Long, + to: Long, + ): JSONObject = getJson("/e2e/log/consistency", mapOf("from" to from.toString(), "to" to to.toString())) - // -- Calls (§8.10) ------------------------------------------------------- + // -- Calls (§8.10) -------------------------------------------------------. fun turnCredentials(): JSONObject = getJson("/e2e/turn") - // -- Internals ----------------------------------------------------------- + // -- Internals -----------------------------------------------------------. /** - * `POST /account/token`, the only route where the account password is - * still presented, and deliberately not under the REST prefix: it belongs - * to the identity provider, not to §8. + * `POST /account/token`, the only route where the account password is still + * presented, and deliberately not under the REST prefix: it belongs to the + * identity provider, not to §8. */ - private fun mintToken(deviceId: String, identityPublicB64: String): RelayAuth.Minted? { + private fun mintToken( + deviceId: String, + identityPublicB64: String, + ): RelayAuth.Minted? { val url = base.newBuilder().addPathSegments("account/token").build() val body = JSONObject().put("device_id", deviceId).put("ik_sig", identityPublicB64) - val request = Request.Builder() - .url(url) - .post(body.toString().toRequestBody(JSON)) - .header("Authorization", basicAuthHeader()) - .header("Accept", "application/json") - .build() + val request = + Request + .Builder() + .url(url) + .post(body.toString().toRequestBody(JSON)) + .header("Authorization", basicAuthHeader()) + .header("Accept", "application/json") + .build() http.newCall(request).execute().use { response -> if (response.code == 404) throw RelayException.NotFound("/account/token") if (!response.isSuccessful) return null @@ -787,45 +695,58 @@ class RelayClient( } } - private fun routePath(path: String): String = "$restPrefix/flamenet/v1${path}".trimStart('/') + private fun routePath(path: String): String = "$restPrefix/flamenet/v1$path".trimStart('/') - private fun url(path: String, query: Map = emptyMap()): HttpUrl = - base.newBuilder().apply { - addPathSegments(routePath(path)) - query.forEach { (name, value) -> addQueryParameter(name, value) } - }.build() + private fun url( + path: String, + query: Map = emptyMap(), + ): HttpUrl = + base + .newBuilder() + .apply { + addPathSegments(routePath(path)) + query.forEach { (name, value) -> addQueryParameter(name, value) } + }.build() - private fun getJson(path: String, query: Map = emptyMap()): JSONObject = - requestJson("GET", url(path, query), path, null) + private fun getJson( + path: String, + query: Map = emptyMap(), + ): JSONObject = requestJson("GET", url(path, query), path, null) /** - * `/.well-known/flamenet/relay`, the relay's self-description, carrying - * the issuer key its log heads are signed with (audit M1). - * - * Root-relative: [routePath] prefixes the REST mount, and a well-known - * URI is not under it. Unauthenticated by design, so no proof is signed. + * `/.well-known/flamenet/relay`, the relay's self-description, carrying the + * issuer key its log heads are signed with (audit M1). */ fun relayDescriptor(): JSONObject { - val url = base.newBuilder() - .addPathSegments(".well-known/flamenet/relay").build() + val url = + base + .newBuilder() + .addPathSegments(".well-known/flamenet/relay") + .build() return requestJson("GET", url, "/.well-known/flamenet/relay", null) } - private fun postJson(path: String, body: JSONObject): JSONObject = - requestJson("POST", url(path), path, body.toString().toByteArray(Charsets.UTF_8)) + private fun postJson( + path: String, + body: JSONObject, + ): JSONObject = requestJson("POST", url(path), path, body.toString().toByteArray(Charsets.UTF_8)) - private fun requestJson(method: String, url: HttpUrl, logicalPath: String, body: ByteArray?): JSONObject = - request(method, url, logicalPath, body?.toRequestBody(JSON)).use(::json) + private fun requestJson( + method: String, + url: HttpUrl, + logicalPath: String, + body: ByteArray?, + ): JSONObject = request(method, url, logicalPath, body?.toRequestBody(JSON)).use(::json) /** * One request, with one retry on 401. - * - * A token can expire between being chosen and the request landing, and a - * relay restart invalidates every outstanding one. Without the retry the - * client stays broken until something else happens to refresh it, which, - * on a phone that is only polling, may be never. */ - private fun request(method: String, url: HttpUrl, logicalPath: String, body: RequestBody?): Response { + private fun request( + method: String, + url: HttpUrl, + logicalPath: String, + body: RequestBody?, + ): Response { val first = http.newCall(build(method, url, logicalPath, body)).execute() if (first.code != 401 || auth == null) return checked(first, logicalPath) first.close() @@ -833,7 +754,12 @@ class RelayClient( return checked(http.newCall(build(method, url, logicalPath, body)).execute(), logicalPath) } - private fun build(method: String, url: HttpUrl, logicalPath: String, body: RequestBody?): Request { + private fun build( + method: String, + url: HttpUrl, + logicalPath: String, + body: RequestBody?, + ): Request { val builder = Request.Builder().url(url).header("Accept", "application/json") if (method == "GET") builder.get() else builder.method(method, body ?: ByteArray(0).toRequestBody(JSON)) @@ -858,48 +784,56 @@ class RelayClient( return sink.readByteArray() } - private fun checked(response: Response, path: String): Response { + private fun checked( + response: Response, + path: String, + ): Response { // ios#45, learn the clock skew from the header every response already - // carries. Done HERE because this is the one funnel every relay call - // passes through, and before the success check, so a 4xx teaches us the - // time just as well as a 200 does: a phone whose clock is wrong enough - // to be refused is exactly the phone that needs to be told why. + // carries. DeviceClock.observe(response.header("Date")) if (response.isSuccessful) return response val body = response.body?.string().orEmpty() val code = response.code response.close() - // Carry the relay's own sentence, not its JSON. These messages - // reach the screen: creating a group with a member who has never signed - // in showed the user - // - // relay returned 400: {"code":"bad_request","message":"No reachable - // device for user: 30.","data":{"status":400}} - // - // when the relay had written a perfectly readable sentence inside it. - // `MessengerApi.send` and `RelayAccounts` already unwrap `message`; - // this path did not, so which client a feature happened to use decided - // whether its errors were legible. - val readable = runCatching { JSONObject(body).optString("message") } - .getOrNull().orEmpty().ifBlank { body } + // Carry the relay's own sentence, not its JSON. + val readable = + runCatching { JSONObject(body).optString("message") } + .getOrNull() + .orEmpty() + .ifBlank { body } throw when (code) { - 401 -> RelayException.Unauthorized(readable) - // The relay's own code, not the status. A 403 is also "that - // device belongs to another account", and telling somebody to check - // their email for that would send them looking for a message that - // was never sent. + 401 -> { + RelayException.Unauthorized(readable) + } + + // The relay's own code, not the status. 403 -> { val relayCode = runCatching { JSONObject(body).optString("code") }.getOrNull() - if (relayCode == "email_unverified") RelayException.EmailUnverified(readable) - else RelayException.Http(code, readable) + if (relayCode == "email_unverified") { + RelayException.EmailUnverified(readable) + } else { + RelayException.Http(code, readable) + } + } + + 404 -> { + RelayException.NotFound(path) + } + + 429 -> { + RelayException.RateLimited(response.header("Retry-After")?.toIntOrNull()) + } + + // The geo-gate, not the relay. + 451 -> { + RelayException.Unavailable(readable) + } + + else -> { + RelayException.Http(code, readable) } - 404 -> RelayException.NotFound(path) - 429 -> RelayException.RateLimited(response.header("Retry-After")?.toIntOrNull()) - // The geo-gate, not the relay. Needs a sentence, not a number. - 451 -> RelayException.Unavailable(readable) - else -> RelayException.Http(code, readable) } } @@ -913,6 +847,5 @@ class RelayClient( } } - private fun basicAuthHeader(): String = - "Basic " + Wire.b64("$username:$appPassword".toByteArray(Charsets.UTF_8)) + private fun basicAuthHeader(): String = "Basic " + Wire.b64("$username:$appPassword".toByteArray(Charsets.UTF_8)) } diff --git a/client/src/main/kotlin/io/flamenet/client/RelayDiscovery.kt b/client/src/main/kotlin/io/flamenet/client/RelayDiscovery.kt index f667401..381d591 100644 --- a/client/src/main/kotlin/io/flamenet/client/RelayDiscovery.kt +++ b/client/src/main/kotlin/io/flamenet/client/RelayDiscovery.kt @@ -7,42 +7,19 @@ import org.json.JSONObject /** * What a relay says about itself (`GET /.well-known/flamenet/relay`). - * - * A client must know where accounts live before it sends a credential - * anywhere. The two modes stretch the passphrase differently and send the - * result to different hosts; guessing wrong means sending an authentication - * secret to a service that was never meant to receive it. - * - * This replaces a hardcoded list of hostnames. The list was safe but wrong: it - * meant a self-hosted relay could never use the shared account service, - * because it would never be on the list, so the "self-hosted, shared identity" - * deployment shape was unreachable in practice. */ object RelayDiscovery { - /** * Relays whose account service is pinned here and not taken from them. - * - * These are ours, and what they advertise is ignored: a compromise of - * one must not be able to redirect its users' passphrase-derived secrets to - * a host the attacker controls. - * - * Every other relay is believed, and that is deliberate rather than - * lax. A self-hoster running Messenger and Files together wants one account - * across both, held by their own service, the file server has taken - * `ENCLAVE_ACCOUNTS_URL` for exactly that since it shipped, and a client - * that refused the relay's answer would make "run both" mean "run them with - * two separate logins". The user typed that hostname themselves; a server - * they chose naming its own account service is a choice they already made, - * and it cannot redirect anybody else's users. */ - val PINNED_RELAYS = mapOf( - "flamenet.io" to OUR_ACCOUNT_SERVICE, - "www.flamenet.io" to OUR_ACCOUNT_SERVICE, - "messenger.flamenet.io" to OUR_ACCOUNT_SERVICE, - // The same service under a second name, not a second service. - "relay.flamenet.io" to OUR_ACCOUNT_SERVICE, - ) + val PINNED_RELAYS = + mapOf( + "flamenet.io" to OUR_ACCOUNT_SERVICE, + "www.flamenet.io" to OUR_ACCOUNT_SERVICE, + "messenger.flamenet.io" to OUR_ACCOUNT_SERVICE, + // The same service under a second name, not a second service. + "relay.flamenet.io" to OUR_ACCOUNT_SERVICE, + ) const val OUR_ACCOUNT_SERVICE = "https://accounts.flamenet.io" @@ -59,11 +36,6 @@ object RelayDiscovery { /** * The service to actually authenticate against. - * - * For a relay we pin, ours, whatever it advertised. For anyone - * else, what they advertised. That asymmetry is the whole rule: our - * relays cannot be made to redirect their users, and a relay somebody - * chose to type can name its own service. */ val effectiveAccountsUrl: String? get() = PINNED_RELAYS[audience.lowercase()] ?: accountsUrl @@ -71,33 +43,46 @@ object RelayDiscovery { /** * Ask a relay what it is. - * - * Returns null when the relay does not answer, an older deployment, or - * something that is not a relay at all. The caller then falls back to - * local, which is the safe default: it sends the passphrase only to the - * server the user typed in. */ - fun describe(baseUrl: String, http: OkHttpClient = RelayClient.defaultHttpClient()): Descriptor? { - val url = baseUrl.trimEnd('/').toHttpUrl().newBuilder() - .addPathSegments(".well-known/flamenet/relay").build() + fun describe( + baseUrl: String, + http: OkHttpClient = RelayClient.defaultHttpClient(), + ): Descriptor? { + val url = + baseUrl + .trimEnd('/') + .toHttpUrl() + .newBuilder() + .addPathSegments(".well-known/flamenet/relay") + .build() return try { - http.newCall(Request.Builder().url(url).get().build()).execute().use { response -> - if (!response.isSuccessful) return null - val json = JSONObject(response.body?.string().orEmpty()) - val versions = json.optJSONArray("protocolVersions") - Descriptor( - audience = json.optString("audience"), - accounts = if (json.optString("accounts") == "shared") { - Accounts.SHARED - } else { - Accounts.LOCAL - }, - accountsUrl = json.optString("accountsURL").takeIf { it.isNotEmpty() }, - federation = json.optString("federation", "closed"), - protocolVersions = (0 until (versions?.length() ?: 0)) - .map { versions!!.getInt(it) }, - ) - } + http + .newCall( + Request + .Builder() + .url(url) + .get() + .build(), + ).execute() + .use { response -> + if (!response.isSuccessful) return null + val json = JSONObject(response.body?.string().orEmpty()) + val versions = json.optJSONArray("protocolVersions") + Descriptor( + audience = json.optString("audience"), + accounts = + if (json.optString("accounts") == "shared") { + Accounts.SHARED + } else { + Accounts.LOCAL + }, + accountsUrl = json.optString("accountsURL").takeIf { it.isNotEmpty() }, + federation = json.optString("federation", "closed"), + protocolVersions = + (0 until (versions?.length() ?: 0)) + .map { versions!!.getInt(it) }, + ) + } } catch (_: Exception) { null } diff --git a/client/src/main/kotlin/io/flamenet/client/RelayException.kt b/client/src/main/kotlin/io/flamenet/client/RelayException.kt index f8cfae3..dc294bc 100644 --- a/client/src/main/kotlin/io/flamenet/client/RelayException.kt +++ b/client/src/main/kotlin/io/flamenet/client/RelayException.kt @@ -2,126 +2,116 @@ package io.flamenet.client /** * What can go wrong talking to a relay. - * - * Typed rather than a single message-carrying exception because two of these - * are decisions, not diagnostics: [NotFound] tells [RelayAuth] this deployment - * mints no tokens, and [Unauthorized] triggers exactly one retry. A caller that - * has to match on a string to make those decisions eventually matches wrong. */ -sealed class RelayException(message: String, cause: Throwable? = null) : Exception(message, cause) { - +sealed class RelayException( + message: String, + cause: Throwable? = null, +) : Exception(message, cause) { /** The network, TLS, or a response that was not HTTP at all. */ - class Transport(message: String, cause: Throwable? = null) : RelayException(message, cause) + class Transport( + message: String, + cause: Throwable? = null, + ) : RelayException(message, cause) /** 401. The caller discards its token and tries once more. */ - class Unauthorized(val body: String) : RelayException("relay refused the credential") + class Unauthorized( + val body: String, + ) : RelayException("relay refused the credential") /** - * 404. On the token route this means "this deployment has no token - * endpoint", which is a supported configuration and not an error. + * 404. */ - class NotFound(val path: String) : RelayException("no such route: $path") + class NotFound( + val path: String, + ) : RelayException("no such route: $path") /** 429, with the relay's own retry hint when it gave one. */ - class RateLimited(val retryAfterSeconds: Int?) : - RelayException("rate limited" + (retryAfterSeconds?.let { ", retry in ${it}s" } ?: "")) + class RateLimited( + val retryAfterSeconds: Int?, + ) : RelayException("rate limited" + (retryAfterSeconds?.let { ", retry in ${it}s" } ?: "")) /** * 451, this service is not offered in the caller's country. - * - * Its own type because the generic one reads as a broken app. The - * edge geo-gate answers 451 before the request reaches the relay, and the - * download site is deliberately NOT gated, so somebody can read the page, - * install the app, choose a password, tap sign up, and be shown "relay - * returned 451". Nothing in that mentions their country, and it is not a - * failure they can retry past, so the only sensible reading is that the app - * does not work. */ - class Unavailable(val body: String) : RelayException( - "Flamenet isn't available in your country yet. " + - "This isn't a problem with your account or your connection.") + class Unavailable( + val body: String, + ) : RelayException( + "Flamenet isn't available in your country yet. " + + "This isn't a problem with your account or your connection.", + ) /** * This relay charges a few seconds of arithmetic before a new account, and * this app is too old to pay it. - * - * The remedy is an update, and it is the only refusal in sign-up where - * that is true, which is exactly why it cannot share a sentence with the - * one below. Telling somebody to try again when the app will never be able - * to answer sends them round a loop that has no exit. */ - class ChallengeRequired(val body: String) : RelayException( - "This server asks new accounts to do a quick check first, and this " + - "version of the app can't do it yet. Update the app and try again.") + class ChallengeRequired( + val body: String, + ) : RelayException( + "This server asks new accounts to do a quick check first, and this " + + "version of the app can't do it yet. Update the app and try again.", + ) /** * The check was answered and the answer did not hold up. - * - * Nearly always a challenge that expired while somebody was typing. - * The relay deliberately gives one message for all four of its refusals, - * forged, stale, unsolved, malformed, because telling an attacker which - * knob to turn is worse than being vague with everybody else, so this side - * cannot be more specific than "start a fresh one" either. */ - class ChallengeFailed(val body: String) : RelayException( - "That check took too long. Tap Create account again and it will " + - "start a fresh one.") + class ChallengeFailed( + val body: String, + ) : RelayException( + "That check took too long. Tap Create account again and it will " + + "start a fresh one.", + ) /** Any other non-2xx. The body is kept because relays put the reason in it. */ + /** * The account exists and the credential is right; the address has not been * proved reachable yet. - * - * Its own type because everything else about it looks like a - * credential failure. Without this it arrives as a bare 403 and - * [RelayAuth] logs "minting failed" before falling back to Basic, which - * the e2e routes refuse, so the person sees calls not working and an inbox - * that does nothing, and nothing anywhere says the remedy is a code sitting - * in their email. - * - * Do not write the e2e route glob in a KDoc here: Kotlin NESTS block - * comments, so a slash-star inside one opens a comment that never closes - * and takes the whole module down with ninety unrelated errors. */ - class EmailUnverified(val body: String) : RelayException( - "this account's email address has not been confirmed yet") + class EmailUnverified( + val body: String, + ) : RelayException("this account's email address has not been confirmed yet") - class Http(val status: Int, val body: String) : RelayException("relay returned $status: $body") + class Http( + val status: Int, + val body: String, + ) : RelayException("relay returned $status: $body") /** A 2xx whose body was not what the route promised. */ - class Malformed(message: String) : RelayException(message) + class Malformed( + message: String, + ) : RelayException(message) /** * Sealing was required and this send could not be sealed (§14.5, ios#16). - * - * Its own type rather than a generic failure. The user turned this on - * deliberately and the message did not go for a reason they chose, a bare - * "couldn't send" reads as the app being broken, and the next thing - * somebody does is switch the setting off without knowing that is what - * they are doing. */ - class SealingRequired : RelayException( - "Not sent. This message could not be sealed, so the server would have seen " + - "that you sent it, and you asked Flamenet to refuse rather than send " + - "anyway. It will seal again once they message you.") + class SealingRequired : + RelayException( + "Not sent. This message could not be sealed, so the server would have seen " + + "that you sent it, and you asked Flamenet to refuse rather than send " + + "anyway. It will seal again once they message you.", + ) } /** * Base64 for this module. - * - * The engine keeps its own helper `internal` on purpose, its public surface is - * the protocol, not its utilities, and widening that so the transport can - * reach in would be the tail wagging the dog. Base64 has one right answer, so a - * second call site cannot drift. */ internal object Wire { - fun b64(data: ByteArray): String = java.util.Base64.getEncoder().encodeToString(data) - fun unb64(text: String): ByteArray = java.util.Base64.getDecoder().decode(text) + fun b64(data: ByteArray): String = + java.util.Base64 + .getEncoder() + .encodeToString(data) - /// The engine keeps its own `Bytes.sha256` `internal`, for the reason - /// above: its public surface is the protocol, not its utilities. §21.4 - /// needs one here to hash a capability before uploading it, and SHA-256 has - /// one right answer, so a second call site cannot drift. + fun unb64(text: String): ByteArray = + java.util.Base64 + .getDecoder() + .decode(text) + + // / The engine keeps its own `Bytes.sha256` `internal`, for the reason + // / above: its public surface is the protocol, not its utilities. §21.4 + // / needs one here to hash a capability before uploading it, and SHA-256 has + // / one right answer, so a second call site cannot drift. fun sha256(data: ByteArray): ByteArray = - java.security.MessageDigest.getInstance("SHA-256").digest(data) + java.security.MessageDigest + .getInstance("SHA-256") + .digest(data) } diff --git a/client/src/main/kotlin/io/flamenet/client/RelayIdentityPin.kt b/client/src/main/kotlin/io/flamenet/client/RelayIdentityPin.kt index 6328a68..1b4b54f 100644 --- a/client/src/main/kotlin/io/flamenet/client/RelayIdentityPin.kt +++ b/client/src/main/kotlin/io/flamenet/client/RelayIdentityPin.kt @@ -2,48 +2,16 @@ package io.flamenet.client /** * Which relay this is, beyond which certificate it presented (ios#19). - * - * TLS answers "is this host who DNS says it is". It does not answer "is - * this the same relay I have been talking to". A mis-issued certificate, a - * hijacked DNS record, or a hostile party who obtains the domain all produce a - * server with a perfectly valid certificate for the right name, and every one - * of them is a different relay wearing the same address. - * - * The relay publishes an issuer public key in its descriptor. Pinning that on - * first contact turns "the certificate is valid" into "this is the same relay", - * which is a materially stronger statement and the one that matters. - * - * Android already pinned this key and drew the wrong conclusion from it. - * `Trust.verifyHeadSignature` pins `issuerPublicKey` the first time a signed log - * head arrives, and if the key later changes the signature simply fails to - * verify, so the client raises `KeyLogForkException`, sets `forkDetected`, and - * tells somebody the relay's key log does not add up. - * - * A rotated relay key is not a forked log. They need different responses: a - * fork means the relay is showing two different histories and nothing it says - * can be trusted; a rotation means somebody ran a command, or somebody else is - * answering on that address. Reporting the second as the first is both alarming - * and unactionable, and an alarm people cannot act on is one they learn to - * dismiss, which costs the real fork detection its credibility too. - * - * A relay that publishes no key is a weaker deployment, not an attack. - * Somebody self-hosting an older build has nothing to pin, and refusing to talk - * to them would be a worse failure than the one this prevents. That case is - * distinguished from a key that CHANGED, which is not weakness. - * - * A change is surfaced, never silently accepted and never silently refused. - * The same shape as a contact's identity changing: stop, say so, and let a - * person decide, because a legitimate rotation exists and an app that cannot - * survive one is an app that gets uninstalled. */ object RelayIdentityPin { - /** What a descriptor's key means against what was pinned before. */ sealed interface Verdict { /** Nothing pinned and a key published: pin it. */ - data class FirstSight(val offered: ByteArray) : Verdict { - override fun equals(other: Any?) = - other is FirstSight && offered.contentEquals(other.offered) + data class FirstSight( + val offered: ByteArray, + ) : Verdict { + override fun equals(other: Any?) = other is FirstSight && offered.contentEquals(other.offered) + override fun hashCode() = offered.contentHashCode() } @@ -54,41 +22,43 @@ object RelayIdentityPin { data object NotPublished : Verdict /** - * A different key than the one pinned. Either a deliberate rotation - * or a different server answering on this address, and nothing here can - * tell those apart, only a person can. + * A different key than the one pinned. */ - data class Changed(val pinned: ByteArray, val offered: ByteArray) : Verdict { - override fun equals(other: Any?) = other is Changed && - pinned.contentEquals(other.pinned) && offered.contentEquals(other.offered) + data class Changed( + val pinned: ByteArray, + val offered: ByteArray, + ) : Verdict { + override fun equals(other: Any?) = + other is Changed && + pinned.contentEquals(other.pinned) && offered.contentEquals(other.offered) + override fun hashCode() = 31 * pinned.contentHashCode() + offered.contentHashCode() } } /** * Compare what the relay just published against what is pinned. - * - * An empty or unparseable key counts as "not published" rather than as a - * change. A relay that answers with rubbish is broken, and treating broken - * as hostile would raise an alarm nobody can act on. */ - fun check(offeredBase64: String?, pinned: ByteArray?): Verdict { - val offered = offeredBase64 - ?.takeIf { it.isNotEmpty() } - ?.let { runCatching { Wire.unb64(it) }.getOrNull() } - ?.takeIf { it.isNotEmpty() } - ?: return Verdict.NotPublished + fun check( + offeredBase64: String?, + pinned: ByteArray?, + ): Verdict { + val offered = + offeredBase64 + ?.takeIf { it.isNotEmpty() } + ?.let { runCatching { Wire.unb64(it) }.getOrNull() } + ?.takeIf { it.isNotEmpty() } + ?: return Verdict.NotPublished if (pinned == null || pinned.isEmpty()) return Verdict.FirstSight(offered) - return if (offered.contentEquals(pinned)) Verdict.Unchanged - else Verdict.Changed(pinned = pinned, offered = offered) + return if (offered.contentEquals(pinned)) { + Verdict.Unchanged + } else { + Verdict.Changed(pinned = pinned, offered = offered) + } } /** * What to tell somebody when it changed. - * - * Names the two possibilities and does not pretend to know which. An - * alarm that asserts an attack when a rotation is equally likely trains - * people to dismiss alarms. */ fun warning(host: String): String = "The server at $host is presenting a different identity than the one " + @@ -101,12 +71,7 @@ object RelayIdentityPin { /** * The relay is not the one this app pinned. - * - * Deliberately NOT a [KeyLogForkException]. A fork means the relay is - * showing two different histories and nothing it says can be trusted. This means - * the server's identity key is not the one recorded, a rotation somebody - * performed, or somebody else's server. The remedies are different, so the - * exceptions are different. */ -class RelayIdentityChangedException(val host: String) : - RelayException(RelayIdentityPin.warning(host)) +class RelayIdentityChangedException( + val host: String, +) : RelayException(RelayIdentityPin.warning(host)) diff --git a/client/src/main/kotlin/io/flamenet/client/SignupProofOfWork.kt b/client/src/main/kotlin/io/flamenet/client/SignupProofOfWork.kt index 2d791b5..0f0ba53 100644 --- a/client/src/main/kotlin/io/flamenet/client/SignupProofOfWork.kt +++ b/client/src/main/kotlin/io/flamenet/client/SignupProofOfWork.kt @@ -8,61 +8,54 @@ import java.security.MessageDigest /** * A few seconds of this phone's arithmetic, spent before an account exists. - * - * Why this and not a CAPTCHA. A CAPTCHA hands a third party every - * visitor's connection, which is a strange thing to do in a product whose - * argument is that it collects almost nothing. This collects nothing at all: - * no personal data, no third party, no picture of a crosswalk. It costs one - * person a couple of seconds and costs a farm of registrations real money. - * - * Every branch here has to end in "sign up anyway". A relay that - * predates the feature, a request that times out, a proxy that returns HTML, - * none of those are reasons somebody cannot create an account. The relay - * decides whether a challenge is *required*; this side only tries to satisfy - * one when it is offered. */ object SignupProofOfWork { - /** - * What the relay hands out. `bits == 0` means it wants no work, the relay - * answers that rather than 404ing, so asking is always safe and the answer - * never reveals how a deployment is configured. + * What the relay hands out. */ - data class Challenge(val challenge: String, val bits: Int, val expiresIn: Int) + data class Challenge( + val challenge: String, + val bits: Int, + val expiresIn: Int, + ) /** A solved challenge, ready to travel beside the sign-up fields. */ - data class Solution(val challenge: String, val nonce: String) + data class Solution( + val challenge: String, + val nonce: String, + ) /** - * Refuse to grind past this even if the relay asks. The relay caps - * itself at 24 bits, but a client that trusts a number from the network - * with an unbounded loop is one misconfiguration away from an app that - * appears to hang on the sign-up button, which reads as broken, not as - * spam protection. Above the cap we send nothing and let the relay refuse, - * because a refusal with a sentence beats a spinner that never ends. + * Refuse to grind past this even if the relay asks. */ const val MAXIMUM_BITS = 24 /** - * Hashes to try before giving up. At 24 bits the expected count is ~16.7M, - * so this is roughly a 4x margin, generous enough that an unlucky run - * still finishes, bounded enough that a wrong difficulty cannot run for - * ever. + * Hashes to try before giving up. */ const val CEILING = 64_000_000 /** * Ask what work this server wants, if any. - * - * Returns null for every failure, deliberately. 404 is the common one, - * an older relay, or a site that has no such route, and it is - * indistinguishable from success-with-no-work as far as what the caller - * should do next. */ - fun fetch(baseUrl: String, http: OkHttpClient = RelayClient.defaultHttpClient()): Challenge? { - val url = baseUrl.trimEnd('/').toHttpUrl().newBuilder() - .addPathSegments("account/challenge").build() - val request = Request.Builder().url(url).get().header("Accept", "application/json").build() + fun fetch( + baseUrl: String, + http: OkHttpClient = RelayClient.defaultHttpClient(), + ): Challenge? { + val url = + baseUrl + .trimEnd('/') + .toHttpUrl() + .newBuilder() + .addPathSegments("account/challenge") + .build() + val request = + Request + .Builder() + .url(url) + .get() + .header("Accept", "application/json") + .build() return try { http.newCall(request).execute().use { response -> if (!response.isSuccessful) return null @@ -74,8 +67,8 @@ object SignupProofOfWork { ) } } catch (_: Exception) { - // Transport, a 404 page that is not JSON, a missing field, all - // mean the same thing here, and it is not "no account for you". + // Transport, a 404 page that is not JSON, a missing field, all mean + // the same thing here, and it is not "no account for you". null } } @@ -88,15 +81,16 @@ object SignupProofOfWork { } /** - * The search itself. Separated so tests can drive it at a low difficulty - * without going through a [Challenge] that claims an expiry. + * The search itself. */ - fun nonce(challenge: String, bits: Int, ceiling: Int = CEILING): String? { + fun nonce( + challenge: String, + bits: Int, + ceiling: Int = CEILING, + ): String? { val digest = MessageDigest.getInstance("SHA-256") // Hashed as `challenge.nonce`, which is exactly what the relay - // verifies. The prefix never changes, so it is encoded once rather than - // per attempt, at tens of millions of iterations that is not a - // micro-optimisation, it is most of the running time. + // verifies. val prefix = "$challenge.".toByteArray(Charsets.UTF_8) for (n in 0 until ceiling) { digest.reset() @@ -108,16 +102,16 @@ object SignupProofOfWork { } /** - * Counted in BITS, not leading zero bytes. Bytes move in steps of 256x, - * which leaves no way to ask for two seconds rather than half a second or - * two minutes. This must agree with the relay exactly or every solution - * this phone produces is rejected by a server that is working perfectly. + * Counted in BITS, not leading zero bytes. */ fun leadingZeroBits(digest: ByteArray): Int { var count = 0 for (byte in digest) { val value = byte.toInt() and 0xFF - if (value == 0) { count += 8; continue } + if (value == 0) { + count += 8 + continue + } count += Integer.numberOfLeadingZeros(value) - 24 break } @@ -126,15 +120,9 @@ object SignupProofOfWork { /** * Fetch and solve, for the caller that just wants fields to send. - * - * Returns null when this server asks for no work, which is the normal case - * today and stays the normal case for anyone self-hosting who never turns - * it on. - * - * Blocking and CPU-bound, like every other call in this module. The - * caller is already off the main thread; if it were not, sign-up would - * already be an ANR without any of this. */ - fun satisfy(baseUrl: String, http: OkHttpClient = RelayClient.defaultHttpClient()): Solution? = - fetch(baseUrl, http)?.let { solve(it) } + fun satisfy( + baseUrl: String, + http: OkHttpClient = RelayClient.defaultHttpClient(), + ): Solution? = fetch(baseUrl, http)?.let { solve(it) } } diff --git a/client/src/main/kotlin/io/flamenet/client/Trust.kt b/client/src/main/kotlin/io/flamenet/client/Trust.kt index f790642..f5e77df 100644 --- a/client/src/main/kotlin/io/flamenet/client/Trust.kt +++ b/client/src/main/kotlin/io/flamenet/client/Trust.kt @@ -8,9 +8,6 @@ import io.flamenet.e2e.Vault /** * What we believe about someone's identity keys. - * - * Ordered by how much a person has actually done, not by severity: [CHANGED] - * is last because it is the only one that needs them. */ enum class IdentityTrust { /** Never seen. Nothing to compare against, so nothing can be said. */ @@ -24,145 +21,98 @@ enum class IdentityTrust { /** * A key we have never seen is being offered for someone we have. - * - * This is not "they got a new phone" and it is not "you are being - * attacked". It is exactly the state where those two are - * indistinguishable to software, which is why it stops and asks a human - * rather than picking one. */ CHANGED, } /** The relay showed two different logs, or a log that shrank. */ + /** Turn a key-log entry into a sentence. S4's "name what changed". Pure. */ object KeyChangeDescription { - fun describe(kind: String, atIso: String?): String { - val what = when (kind) { - "publish" -> "A new device key was added" - "expire" -> "A device key expired" - "revoke" -> "A device key was revoked" - else -> "Their key set changed" - } - val date = atIso?.let { - try { - val inst = java.time.Instant.parse(it) - java.time.format.DateTimeFormatter - .ofLocalizedDateTime(java.time.format.FormatStyle.MEDIUM) - .withZone(java.time.ZoneId.systemDefault()) - .format(inst) - } catch (_: Exception) { null } - } + fun describe( + kind: String, + atIso: String?, + ): String { + val what = + when (kind) { + "publish" -> "A new device key was added" + "expire" -> "A device key expired" + "revoke" -> "A device key was revoked" + else -> "Their key set changed" + } + val date = + atIso?.let { + try { + val inst = java.time.Instant.parse(it) + java.time.format.DateTimeFormatter + .ofLocalizedDateTime(java.time.format.FormatStyle.MEDIUM) + .withZone(java.time.ZoneId.systemDefault()) + .format(inst) + } catch (_: Exception) { + null + } + } return if (date != null) "$what on $date." else "$what." } } -class KeyLogForkException(detail: String) : - RelayException("the relay's key log does not add up: $detail") +class KeyLogForkException( + detail: String, +) : RelayException("the relay's key log does not add up: $detail") /** * A served identity key could not be found in the log the relay publishes. - * - * Two very different situations, and the difference matters enough to - * carry. [hadEntries] is false when the log holds nothing at all for this - * device, the key was never published, which is what a substitution looks - * like. It is true when the device HAS publications but none of them hash to - * the leaf this bundle implies under the subject we were able to compute. - * - * The second case is usually not an attack. The subject is inside the hashed - * bytes, and an account that has claimed an fnid is logged under it rather than - * under `local:/`, so a verifier that does not know the - * fnid cannot recompute the leaf even though the key was published perfectly - * well. Both refuse, because refusing is the safe direction, but reporting them - * identically means an honest conversation and an interception produce the same - * sentence. */ -class KeyNotLoggedException(val deviceId: String, val hadEntries: Boolean = false) : - RelayException( +class KeyNotLoggedException( + val deviceId: String, + val hadEntries: Boolean = false, +) : RelayException( if (hadEntries) { "device $deviceId has published keys, but none match what this relay served, " + "if this account has a global identity, its fnid is needed to check it" } else { "device $deviceId offered a key the relay never logged" - } + }, ) /** Someone's identity changed and the change has not been accepted. */ -class IdentityChangedException(val userId: Long) : - RelayException("identity keys changed for user $userId") +class IdentityChangedException( + val userId: Long, +) : RelayException("identity keys changed for user $userId") /** * Identity pinning and key-transparency enforcement (spec §19). - * - * This is the part that makes the transparency log worth having. A log - * nothing checks is a log that faithfully records the substitution and is - * never read; the relay hands out a key it minted, the conversation encrypts - * to it, and every byte on the wire is correctly encrypted to the wrong - * person. Two independent checks are needed and neither is sufficient alone: - * - * - Pinning catches a key changing under a person you already talk to, - * but says nothing the first time you meet them. - * - The log says a key was published where the world can see it, but a - * relay that rewrites history reissues valid proofs against the new tree, - * so inclusion is only meaningful against a head this device already - * pinned and can demand a consistency proof for. - * - * Automatic wherever the relay can answer. This used to be behind - * [requireKeyLog], off by default, so the protection shipped and almost - * nobody had it. The argument for opt-in (a relay predating the log has none, - * and refusing every peer there is worse than the attack) was an argument for - * skipping relays that *cannot* answer, never for skipping the ones that can. - * [enforceKeyLogIfServed] draws exactly that line. Pinning is always on, it - * costs nothing and needs no cooperation from the relay. */ class Trust( private val identity: DeviceIdentity, private val vault: Vault, private val relay: RelayClient, ) { - /** * Refuse a peer even when the relay keeps no log at all. - * - * Not the switch that turns checking on, checking is automatic, see - * [enforceKeyLogIfServed]. This is the narrower question underneath it: - * what to do about a relay that publishes no log, where there is nothing to - * check a key against. - * - * Off by default because the failure it prevents is rarer than the one it - * causes. A relay predating §19 answers nothing, so turning this on there - * makes every contact unreachable, and somebody locked out of their - * messages moves to something with no ratchet at all. */ var requireKeyLog: Boolean = false /** * Set when gossip proved the relay showed two different logs (§19.4). - * - * A standing condition rather than a thrown error: a fork is discovered - * while processing inbound traffic, where there is nothing to fail, and it - * stays true once discovered. Nothing here clears it, a relay that forked - * once does not become trustworthy because the next request looked fine. */ var forkDetected: Boolean = false private set - // -- Pinning ------------------------------------------------------------- + // -- Pinning -------------------------------------------------------------. /** * Compare what the directory just offered against what we pinned. - * - * Trust on first use: with nothing pinned, the offered keys are taken and - * recorded. That is the weakest link in the whole scheme and it is a - * deliberate one, the alternative is a client that cannot start a - * conversation without an out-of-band channel it does not have. */ - fun record(userId: Long, offered: List, fnid: String? = null): IdentityTrust { + fun record( + userId: Long, + offered: List, + fnid: String? = null, + ): IdentityTrust { val encoded = offered.map(Wire::b64) val existing = vault.loadTrust(userId) // A different address for someone we already know is not a new device. - // The fnid is derived from the account key, so a new one means a new - // account key, a different person, however the relay presents it. if (fnid != null && existing?.fnid != null && existing.fnid != fnid) { markChanged(userId, existing) return IdentityTrust.CHANGED @@ -176,19 +126,9 @@ class Trust( ) return IdentityTrust.PINNED } - // An empty offer is not agreement (audit LOW). `all {}` on an - // empty list is vacuously true, so a directory answering with NO keys - // for someone already pinned read as "unchanged", the strongest - // verdict, when it had said nothing at all. It is not evidence of a - // swap either, so it reports UNKNOWN: nothing to compare, nothing to - // send to. The first-contact branch above already guards this case; - // this is the same rule once keys exist. + // An empty offer is not agreement (audit LOW). if (encoded.isEmpty()) return IdentityTrust.UNKNOWN - // EVERY offered key must already be pinned, not "any". A relay - // adding one key of its own alongside the real ones would otherwise - // pass, and that is precisely the attack: the victim's real devices - // still work, so nothing looks wrong, while a device the attacker - // controls receives a copy of everything. + // EVERY offered key must already be pinned, not "any". if (!encoded.all { existing.identities.contains(it) }) { markChanged(userId, existing) return IdentityTrust.CHANGED @@ -203,38 +143,21 @@ class Trust( /** * Learn this contact's global address and pin it. - * - * Pinned on first sight and never quietly replaced. The address is - * what the key log files their publications under, so it decides which leaf - * a served key has to hash to. Re-reading it from the relay on every check - * would let a relay that wants to substitute a key simply also change the - * name it files it under, and every verification would agree. - * - * A DIFFERENT address later is therefore treated like a changed identity - * key, and for a stronger reason than one: an fnid is derived from the - * account key, so a new address is not a new device, it is a different - * account key, which is to say a different person. - * - * First contact is still trust-on-first-use. Taking the relay's word - * once is not a check; what it buys is that every later answer has to match - * it. The safety number is what covers the first sighting, and nothing here - * replaces reading it aloud. - * - * Cheap and idempotent: does nothing once an address is pinned. Safe to - * call whenever the contact list is refreshed. */ - fun rememberIdentity(userId: Long, handle: String): IdentityTrust { + fun rememberIdentity( + userId: Long, + handle: String, + ): IdentityTrust { val existing = vault.loadTrust(userId) if (existing?.fnid != null) return state(userId) - val resolved = try { - relay.resolveIdentity(handle) - } catch (_: Exception) { - // Unreachable is not "has no identity". Pinning null on a failed - // lookup would bake a wrong answer in permanently, and the next - // successful lookup would then read as an identity change. - return state(userId) - } ?: return state(userId) + val resolved = + try { + relay.resolveIdentity(handle) + } catch (_: Exception) { + // Unreachable is not "has no identity". + return state(userId) + } ?: return state(userId) vault.saveTrust( userId, @@ -249,22 +172,14 @@ class Trust( /** * Record the address the relay served for this contact. - * - * Not a trust decision, it changes nothing about which identity keys - * are accepted. It only records the SUBJECT their key-log leaves are filed - * under, which a verifier needs and the device directory does not serve. - * Storing it means a later send that establishes no new session can still - * check the log. */ - fun rememberFnid(userId: Long, fnid: String) { + fun rememberFnid( + userId: Long, + fnid: String, + ) { val existing = vault.loadTrust(userId) ?: return if (existing.fnid == fnid) return - // The paragraph above was documentation, not behaviour. This - // overwrote a DIFFERENT address in place, the one thing it says it must - // never do, and it went unnoticed while only the bundle route carried - // an address, because that route is reached once per conversation. The - // directory carries it on every send, so a silent replacement here - // would erase the change the pin exists to notice. + // The paragraph above was documentation, not behaviour. if (existing.fnid != null) { markChanged(userId, existing) return @@ -275,12 +190,7 @@ class Trust( /** Trust as it stands, for UI that has not triggered a fetch. */ fun state(userId: Long): IdentityTrust { val t = vault.loadTrust(userId) ?: return IdentityTrust.UNKNOWN - // CHANGED first, and it is persisted rather than recomputed. This - // function used to be unable to return it at all: CHANGED was worked - // out inside `record()` during a send and then dropped, so the header - // said "keys unchanged since you met" while every send was refused for - // exactly that reason, and the Accept button, which only shows for - // CHANGED, could never appear. + // CHANGED first, and it is persisted rather than recomputed. if (t.changed) return IdentityTrust.CHANGED if (t.identities.isEmpty()) return IdentityTrust.UNKNOWN return if (t.verified) IdentityTrust.VERIFIED else IdentityTrust.PINNED @@ -288,12 +198,6 @@ class Trust( /** * The number both people read to each other. - * - * Covers the FIRST pinned key only. Someone with several devices has - * several identity keys and one number cannot represent all of them, - * per-device numbers are the honest fix and are not done here. Saying so - * matters: a number that silently describes one of three devices invites - * people to believe they checked more than they did. */ fun safetyNumber(userId: Long): String? { val theirs = vault.loadTrust(userId)?.identities?.firstOrNull() ?: return null @@ -301,61 +205,58 @@ class Trust( } /** Record that two people compared numbers out of band. */ - fun setVerified(userId: Long, verified: Boolean) { + fun setVerified( + userId: Long, + verified: Boolean, + ) { val current = vault.loadTrust(userId) ?: return vault.saveTrust(userId, current.copy(verified = verified)) } /** * Accept a changed identity, they really did reinstall or add a device. - * - * Re-pins AND drops the verified flag. The old comparison was about a - * key that is no longer in use and says nothing about the new one; leaving - * the tick in place would show "verified" for something nobody verified. */ - fun acceptIdentityChange(userId: Long, handle: String? = null) { + fun acceptIdentityChange( + userId: Long, + handle: String? = null, + ) { // The address is taken from the SAME response that carries the keys. - // Accepting a change and then filing it under a stale address leaves - // the key log uncheckable for the very keys just accepted. val served = relay.fetchKeys(userId) val offered = served.bundles.map { it.identitySigning } // An empty answer is a FAILURE, not an acceptance of nothing. - // This returned quietly, so a fetch that was rate-limited, timed out or - // 500'd left the pin exactly as it was, while the UI reported the - // change accepted. The person taps "accept", nothing happens, every - // message stays refused, and the app insists the problem is on the - // other end. Observed after a burst of retries tripped the per-contact - // key-fetch limit, which is precisely when somebody is trying to - // recover a conversation and most needs this to work. if (offered.isEmpty()) { throw RelayException.Malformed( "the server returned no keys for this contact, so there was " + - "nothing to accept, try again in a moment") + "nothing to accept, try again in a moment", + ) } served.fnid?.let { rememberFnid(userId, it) } // Re-resolve rather than keep the old address: if what changed WAS the // address, keeping it would leave every later check recomputing against // a name this account no longer publishes under, and honest keys would // stay refused for ever. - val address = handle?.let { h -> runCatching { relay.resolveIdentity(h) }.getOrNull() } - ?: vault.loadTrust(userId)?.fnid + val address = + handle?.let { h -> runCatching { relay.resolveIdentity(h) }.getOrNull() } + ?: vault.loadTrust(userId)?.fnid vault.saveTrust( userId, - // changed = false: this IS the acceptance. Leaving it set would - // re-raise the banner on the very keys the user just approved. - Vault.Trust(offered.map(Wire::b64), verified = false, fnid = address, - changed = false), + // changed = false: this IS the acceptance. + Vault.Trust( + offered.map(Wire::b64), + verified = false, + fnid = address, + changed = false, + ), ) } /** * Remember that something other than the pinned keys was offered. - * - * The pinned `identities` are left exactly as they were, and `verified` - * is dropped: whatever was checked in person was checked against the OLD - * key, so the tick cannot carry over to a key nobody has compared. */ - private fun markChanged(userId: Long, existing: Vault.Trust) { + private fun markChanged( + userId: Long, + existing: Vault.Trust, + ) { if (existing.changed && !existing.verified) return vault.saveTrust(userId, existing.copy(changed = true, verified = false)) } @@ -363,63 +264,36 @@ class Trust( /** Forget a person entirely, used when they are removed as a contact. */ fun forget(userId: Long) = vault.forgetTrust(userId) - // -- Key transparency ---------------------------------------------------- + // -- Key transparency ----------------------------------------------------. /** - * Enforce §19 for one served bundle. Throws rather than returning a verdict: - * there is no sensible way to continue with a key that failed this. + * Enforce §19 for one served bundle. */ /** - * @param fnid the peer's global identity. Defaults to the address pinned - * for this contact by [rememberIdentity], which is the answer that should - * almost always be used. - * - * Required for any account that has claimed one, because the relay - * files a leaf under the fnid once an account has it and under - * `local:/` before that, and the subject is inside the - * hashed bytes, so guessing wrong makes an honest key unverifiable. - * - * Taking it from the contact record rather than from the relay's current - * answer is the point: it checks the key was logged under the person this - * contact has always been, not under whatever subject the relay would - * like to file it against today. + * @param fnid the peer's global identity. */ + /** - * §19 enforcement, applied wherever the relay can answer. - * - * ## Why the head is asked for separately - * - * The obvious shape, call [enforceKeyLog] and read a 404 as "no log here" - * hands the relay the switch. [enforceKeyLog] reads the head, then the - * device's leaves, then an inclusion proof, so a relay wanting to skip the - * check need only 404 the *middle* one to make a substituted key look like - * a deployment without transparency. The "is there a log" question is - * therefore asked once, of the head route alone, and every later 404 means - * what it has always meant: this key is not in the log. - * - * ## Why a relay cannot stop answering - * - * The pin is in the vault and outlives a restart. A relay that served a - * head before and 404s now has not lost its log, logs do not disappear, - * so that is a fork, not an absence. Without this the downgrade is one - * line: serve the log until a key is pinned, then stop. + * §19 enforcement, applied wherever the relay can answer. ## Why the head + * is asked for separately The obvious shape, call [enforceKeyLog] and read + * a 404 as "no log here" hands the relay the switch. */ fun enforceKeyLogIfServed( - bundle: PeerBundle, userId: Long, fnid: String? = pinnedFnid(userId) + bundle: PeerBundle, + userId: Long, + fnid: String? = pinnedFnid(userId), ) { - val head = try { - head().also { vault.rememberKeyLogServed(relay.host) } - } catch (e: RelayException.NotFound) { - // Only a route that is absent. A timeout, a 500 or a 429 - // is a relay having a bad day and must not read as one without - // transparency, that would make enforcement skippable by anybody - // able to make one request fail. - if (vault.keyLogServed(relay.host)) { - throw KeyLogForkException("this relay served a log before and now serves none") + val head = + try { + head().also { vault.rememberKeyLogServed(relay.host) } + } catch (e: RelayException.NotFound) { + // Only a route that is absent. + if (vault.keyLogServed(relay.host)) { + throw KeyLogForkException("this relay served a log before and now serves none") + } + if (requireKeyLog) throw KeyNotLoggedException(bundle.deviceId, hadEntries = false) + return } - if (requireKeyLog) throw KeyNotLoggedException(bundle.deviceId, hadEntries = false) - return - } if (forkDetected) { throw KeyLogForkException("a fork was already detected on this relay") } @@ -433,7 +307,9 @@ class Trust( * checked against a root this device never pinned. */ fun enforceKeyLog( - bundle: PeerBundle, userId: Long, fnid: String? = pinnedFnid(userId), + bundle: PeerBundle, + userId: Long, + fnid: String? = pinnedFnid(userId), servedHead: Head? = null, ) { val head = servedHead ?: head() @@ -454,53 +330,36 @@ class Trust( } // Recomputed from the bundle we were served, NEVER read from the - // relay's own reply. Taking the leaf the relay names would let it echo - // its own bytes back and satisfy the check against anything at all. - // - // `head.audience` rather than the host we dialled: the leaf was written - // by whichever relay signed this head, and the qualified subject embeds - // that relay's own name. - // - // Both subject forms are accepted, and the reason is a migration - // rather than a weakness. A leaf names its subject as `fnid:` once - // that account has claimed an address and `local:/` - // before, and the leaf hash committed to whichever string was true - // when it was written. A contact who registered a device and then - // claimed an address therefore has older leaves under one form and - // newer ones under the other, so a verifier computing a single subject - // would refuse half the network on the day claiming turned on. - // - // This concedes nothing to the relay: either candidate still requires - // it to have appended a leaf binding the exact key it just served, in a - // tree it has already committed to and must prove inclusion against. - // What it gives up is the stronger property of checking the key is - // logged under *that person*, which needs the address pinned in the - // contact record. - val wants = setOf( - KeyLog.logSubject(fnid, head.audience, userId), - KeyLog.logSubject(null, head.audience, userId), - ).map { subject -> - Wire.b64( - KeyLog.leafHash( - KeyLog.canonicalLeaf( - kind = "publish", - subject = subject, - deviceId = bundle.deviceId, - ikSigB64 = Wire.b64(bundle.identitySigning), - ikDhB64 = Wire.b64(bundle.identityDH), - ) + // relay's own reply. + val wants = + setOf( + KeyLog.logSubject(fnid, head.audience, userId), + KeyLog.logSubject(null, head.audience, userId), + ).map { subject -> + Wire.b64( + KeyLog.leafHash( + KeyLog.canonicalLeaf( + kind = "publish", + subject = subject, + deviceId = bundle.deviceId, + ikSigB64 = Wire.b64(bundle.identitySigning), + ikDhB64 = Wire.b64(bundle.identityDH), + ), + ), ) - ) - } + } - val entries = relay.logDeviceEntries(bundle.deviceId).optJSONArray("entries") - ?: throw KeyNotLoggedException(bundle.deviceId, hadEntries = false) + val entries = + relay.logDeviceEntries(bundle.deviceId).optJSONArray("entries") + ?: throw KeyNotLoggedException(bundle.deviceId, hadEntries = false) var index = -1 var want = "" for (i in 0 until entries.length()) { val leaf = entries.getJSONObject(i).optString("leaf") if (leaf in wants) { - index = entries.getJSONObject(i).getInt("index"); want = leaf; break + index = entries.getJSONObject(i).getInt("index") + want = leaf + break } } if (index < 0) { @@ -510,28 +369,25 @@ class Trust( } val inc = relay.logInclusion(index.toLong(), head.size.toLong()) - val included = inc.optString("leaf") == want && KeyLog.verifyInclusion( - leaf = Wire.unb64(inc.getString("leaf")), - index = inc.getInt("index"), - size = inc.getInt("size"), - proof = inc.strings("proof").map(Wire::unb64), - root = Wire.unb64(head.root), - ) + val included = + inc.optString("leaf") == want && + KeyLog.verifyInclusion( + leaf = Wire.unb64(inc.getString("leaf")), + index = inc.getInt("index"), + size = inc.getInt("size"), + proof = inc.strings("proof").map(Wire::unb64), + root = Wire.unb64(head.root), + ) // A proof that does not check out IS evidence, not a naming problem. if (!included) throw KeyNotLoggedException(bundle.deviceId, hadEntries = false) vault.saveKeyLogPin(Vault.KeyLogPin(head.size, head.root), relay.host) } - // -- Gossip (§19.4) ------------------------------------------------------ + // -- Gossip (§19.4) ------------------------------------------------------. /** * Our view of the log, to ride along with a message. - * - * Gossip is what turns a per-device check into a detectable fork. Alone, - * each client only knows the relay was consistent *with itself*; two - * clients comparing heads make split-view serving visible, because - * reconciling them needs a proof that cannot exist. */ fun headPayload(): String? { val pin = vault.loadKeyLogPin(relay.host) ?: return null @@ -539,27 +395,28 @@ class Trust( } /** Compare a peer's view of the log against ours. */ - fun gossip(size: Int, root: String) { + fun gossip( + size: Int, + root: String, + ) { val pin = vault.loadKeyLogPin(relay.host) ?: return if (size == pin.size) { if (root != pin.root) forkDetected = true return } // Different sizes are not evidence by themselves, one of us is simply - // behind. The relay has to reconcile them, and if it served two - // branches it cannot. + // behind. val older = if (size < pin.size) size else pin.size val newer = if (size < pin.size) pin.size else size val olderRoot = if (size < pin.size) root else pin.root val newerRoot = if (size < pin.size) pin.root else root - val ok = try { - consistent(older, newer, olderRoot, newerRoot) - } catch (_: Exception) { - // A failed fetch is NOT a fork. The network is allowed to be - // down, and calling that a fork cries wolf until the warning means - // nothing, which is worse than not warning at all. - return - } + val ok = + try { + consistent(older, newer, olderRoot, newerRoot) + } catch (_: Exception) { + // A failed fetch is NOT a fork. + return + } if (!ok) forkDetected = true } @@ -570,87 +427,95 @@ class Trust( /** * Verify the log is still an append-only extension of our pin, on a * schedule rather than only when a session is being established (S4). - * - * This is what makes transparency act instead of merely record: a relay - * that quietly forked or rewound its log between two bundle fetches would - * otherwise go unseen, for an established conversation, possibly for ever. - * Best effort on the network (a failed fetch is retried next cycle); a - * proven fork raises [forkDetected], the standing alarm. Call from the - * poll loop; it self-throttles. */ fun verifyLogConsistencyIfDue(now: Long = System.currentTimeMillis()) { - val pin = vault.loadKeyLogPin(relay.host) ?: return // nothing pinned to extend + val pin = vault.loadKeyLogPin(relay.host) ?: return // nothing pinned to extend if (now - lastLogCheck < logCheckIntervalMs) return - val head = try { head() } catch (_: Exception) { return } // network, not evidence - val ok = try { - when { - pin.size > head.size -> false // shrank - pin.size == head.size -> pin.root == head.root // same size, same root - else -> consistent(pin.size, head.size, pin.root, head.root) + val head = + try { + head() + } catch (_: Exception) { + return + } // network, not evidence + val ok = + try { + when { + pin.size > head.size -> false + + // shrank + pin.size == head.size -> pin.root == head.root + + // same size, same root + else -> consistent(pin.size, head.size, pin.root, head.root) + } + } catch (_: Exception) { + return } - } catch (_: Exception) { return } - if (!ok) { forkDetected = true; return } + if (!ok) { + forkDetected = true + return + } vault.saveKeyLogPin(Vault.KeyLogPin(head.size, head.root), relay.host) lastLogCheck = now } - // -- Reading the relay's log --------------------------------------------- + // -- Reading the relay's log ---------------------------------------------. /** - * A verified log head. Public because [enforceKeyLog] takes one that its - * caller has already fetched, the head is part of that contract rather - * than an internal detail. + * A verified log head. */ - data class Head(val size: Int, val root: String, val audience: String) + data class Head( + val size: Int, + val root: String, + val audience: String, + ) - private fun head(): Head = relay.logHead().let { - val head = Head(it.getInt("size"), it.getString("root"), it.optString("audience")) - verifyHeadSignature(head, it.optString("signature")) - head - } + private fun head(): Head = + relay.logHead().let { + val head = Head(it.getInt("size"), it.getString("root"), it.optString("audience")) + verifyHeadSignature(head, it.optString("signature")) + head + } /** * Check the relay's signature over a head against the pinned issuer key * (§19.3, audit M1). - * - * Silent when nothing is published or the relay predates the field, a - * deployment that cannot sign is weaker, not hostile, and refusing it - * outright is a worse failure than the one prevented. A signature that is - * present and WRONG, or that stops matching a key already pinned, is a - * fork and is treated as one. */ - private fun verifyHeadSignature(head: Head, signatureB64: String) { + private fun verifyHeadSignature( + head: Head, + signatureB64: String, + ) { if (signatureB64.isEmpty()) return val signature = runCatching { Wire.unb64(signatureB64) }.getOrNull() ?: return - // ios#19, decide what the relay's published identity MEANS before - // using it, rather than inferring it from a signature that failed. - // - // This is the bug the pin previously hid. A rotated relay key made - // the signature stop verifying, and the only conclusion available at - // that point was "fork", so a command somebody ran on purpose was - // reported as the relay showing two different histories. Different - // situations, different remedies, and an alarm nobody can act on is one - // people learn to dismiss, which costs real fork detection too. - val offered = runCatching { - relay.relayDescriptor().optString("issuerPublicKey") - }.getOrNull() - val issuer = when (val verdict = RelayIdentityPin.check(offered, vault.keyLogIssuerPin())) { - is RelayIdentityPin.Verdict.NotPublished -> - // Nothing published and nothing pinned: a weaker deployment, - // not an attack. Nothing to check the signature against. - vault.keyLogIssuerPin() ?: return - is RelayIdentityPin.Verdict.FirstSight -> { - vault.saveKeyLogIssuerPin(verdict.offered) - verdict.offered + // ios#19, decide what the relay's published identity MEANS before using + // it, rather than inferring it from a signature that failed. + val offered = + runCatching { + relay.relayDescriptor().optString("issuerPublicKey") + }.getOrNull() + val issuer = + when (val verdict = RelayIdentityPin.check(offered, vault.keyLogIssuerPin())) { + is RelayIdentityPin.Verdict.NotPublished -> { + // Nothing published and nothing pinned: a weaker deployment, + // not an attack. + vault.keyLogIssuerPin() ?: return + } + + is RelayIdentityPin.Verdict.FirstSight -> { + vault.saveKeyLogIssuerPin(verdict.offered) + verdict.offered + } + + is RelayIdentityPin.Verdict.Unchanged -> { + vault.keyLogIssuerPin() ?: return + } + + is RelayIdentityPin.Verdict.Changed -> { + // NOT a fork, and `forkDetected` is deliberately not set. + throw RelayIdentityChangedException(relayHostForPin()) + } } - is RelayIdentityPin.Verdict.Unchanged -> vault.keyLogIssuerPin() ?: return - is RelayIdentityPin.Verdict.Changed -> - // NOT a fork, and `forkDetected` is deliberately not set. - // The log may be perfectly consistent; it is the server's - // identity that is not the one recorded. - throw RelayIdentityChangedException(relayHostForPin()) - } if (!KeyLog.verifyHead(head.size, head.root, signature, issuer)) { forkDetected = true @@ -661,7 +526,12 @@ class Trust( /** The host to name in an identity-change warning. */ private fun relayHostForPin(): String = relay.host - private fun consistent(from: Int, to: Int, oldRoot: String, newRoot: String): Boolean { + private fun consistent( + from: Int, + to: Int, + oldRoot: String, + newRoot: String, + ): Boolean { val c = relay.logConsistency(from.toLong(), to.toLong()) return KeyLog.verifyConsistency( from = from, diff --git a/client/src/main/kotlin/io/flamenet/client/UpdateCheck.kt b/client/src/main/kotlin/io/flamenet/client/UpdateCheck.kt index 741b652..4f9beda 100644 --- a/client/src/main/kotlin/io/flamenet/client/UpdateCheck.kt +++ b/client/src/main/kotlin/io/flamenet/client/UpdateCheck.kt @@ -1,47 +1,16 @@ package io.flamenet.client -import java.util.concurrent.TimeUnit import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.OkHttpClient import okhttp3.Request import org.json.JSONObject +import java.util.concurrent.TimeUnit /** * Whether a newer build than this one is published, for an app that is * installed by hand. - * - * Nothing updates this app on its own. It is downloaded as an APK from a - * web page, so there is no store to notice a new release and no background - * updater to install it. Without this check a person can run a build from - * months ago indefinitely and never know, and the builds they are missing - * include security fixes. A sideloaded app that cannot tell you it is out of - * date is the part of sideloading that is worse, and this is the only - * thing that closes it. - * - * Asked of the relay you already talk to, not of a fixed address of ours. - * The obvious implementation points every install at flamenetmessenger.com, and - * that would mean an app whose whole claim is that it does not phone home - * checking in with us on a timer, from every device, including the self-hosted - * ones that deliberately never contact us at all. Their traffic is their - * server's business. So the manifest is fetched from the relay this install is - * signed in to, which learns nothing it does not already know from the traffic - * it is carrying. - * - * The consequence, stated rather than hidden: a relay that publishes no - * manifest produces no prompt. Self-hosters who do not republish the APK get no - * update prompts, which is correct, we do not know what build they are meant - * to be running, but it does mean this cannot be relied on as the only way a - * fix reaches everybody. - * - * The download URL is built here and never read from the manifest. A - * `url` field in the JSON would let any relay send its users to an APK on a - * host of its choosing, which is a much larger thing to hand a server than - * "tell me a version number". Android's own signature check is the backstop, - * it refuses to install a differently-signed APK over an existing one, but a - * backstop is not a reason to build the hole in front of it. */ object UpdateCheck { - /** Where a relay publishes what it considers the current Android build. */ const val MANIFEST_PATH = "downloads/android-latest.json" @@ -50,10 +19,8 @@ object UpdateCheck { /** * A published release, as the relay describes it. - * - * @param sha256 the APK's fingerprint, so somebody who wants to verify the - * file they downloaded can. Optional: a manifest without one is still - * usable, it just cannot be checked by hand. + * @param sha256 the APK's fingerprint, so somebody who wants to verify + * the file they downloaded can. */ data class Release( val versionCode: Int, @@ -64,56 +31,65 @@ object UpdateCheck { ) /** Short timeouts: this is a background courtesy, never worth a stall. */ - private val http = OkHttpClient.Builder() - .connectTimeout(5, TimeUnit.SECONDS) - .readTimeout(5, TimeUnit.SECONDS) - .callTimeout(8, TimeUnit.SECONDS) - .build() + private val http = + OkHttpClient + .Builder() + .connectTimeout(5, TimeUnit.SECONDS) + .readTimeout(5, TimeUnit.SECONDS) + .callTimeout(8, TimeUnit.SECONDS) + .build() /** A manifest is a handful of fields; anything larger is not one. */ private const val MAX_BYTES = 16L * 1024 /** * What the relay publishes, or null if it publishes nothing usable. - * - * Null for every unusual case, no manifest, a refusal, a timeout, a body - * that is not the JSON expected. None of them are worth telling the user - * about: the failure mode of this whole feature is "no prompt", and a - * messenger that interrupts you to say it could not check for updates has - * made itself the subject. */ - fun published(baseUrl: String, client: OkHttpClient = http): Release? = runCatching { - val base = baseUrl.trimEnd('/').toHttpUrl() - val url = base.newBuilder().addPathSegments(MANIFEST_PATH).build() - client.newCall(Request.Builder().url(url).get().build()).execute().use { response -> - if (!response.isSuccessful) return null - val body = response.body?.source()?.let { source -> - source.request(MAX_BYTES) - source.buffer.snapshot(minOf(MAX_BYTES, source.buffer.size).toInt()).utf8() - } ?: return null + fun published( + baseUrl: String, + client: OkHttpClient = http, + ): Release? = + runCatching { + val base = baseUrl.trimEnd('/').toHttpUrl() + val url = base.newBuilder().addPathSegments(MANIFEST_PATH).build() + client + .newCall( + Request + .Builder() + .url(url) + .get() + .build(), + ).execute() + .use { response -> + if (!response.isSuccessful) return null + val body = + response.body?.source()?.let { source -> + source.request(MAX_BYTES) + source.buffer.snapshot(minOf(MAX_BYTES, source.buffer.size).toInt()).utf8() + } ?: return null - val json = JSONObject(body) - val code = json.optInt("versionCode", 0) - // A manifest that cannot say which build it describes is not one. - if (code <= 0) return null - Release( - versionCode = code, - versionName = json.optString("versionName").ifEmpty { code.toString() }, - notes = json.optString("notes"), - sha256 = json.optString("sha256").takeIf { it.length == 64 }, - downloadUrl = base.newBuilder().addPathSegments(APK_PATH).build().toString(), - ) - } - }.getOrNull() + val json = JSONObject(body) + val code = json.optInt("versionCode", 0) + // A manifest that cannot say which build it describes is not one. + if (code <= 0) return null + Release( + versionCode = code, + versionName = json.optString("versionName").ifEmpty { code.toString() }, + notes = json.optString("notes"), + sha256 = json.optString("sha256").takeIf { it.length == 64 }, + downloadUrl = + base + .newBuilder() + .addPathSegments(APK_PATH) + .build() + .toString(), + ) + } + }.getOrNull() /** - * The published release if it is newer than [installedVersionCode], else null. - * - * Strictly greater. Equal is the common case, almost every check finds - * the build the user already has, and a prompt offering to install what is - * already installed teaches people to dismiss these without reading. Older - * is possible too, on a relay that has not been updated in a while, and is - * likewise not something to offer. + * The published release if it is newer than [installedVersionCode], else + * null. */ fun availableUpdate( baseUrl: String, diff --git a/client/src/test/kotlin/io/flamenet/client/AccountIdentityTest.kt b/client/src/test/kotlin/io/flamenet/client/AccountIdentityTest.kt index 8b1b8c0..10abb27 100644 --- a/client/src/test/kotlin/io/flamenet/client/AccountIdentityTest.kt +++ b/client/src/test/kotlin/io/flamenet/client/AccountIdentityTest.kt @@ -19,23 +19,23 @@ import kotlin.test.assertTrue /** * An address nobody issued, and the record that says where mail for it waits. - * - * The relay must not be able to lie about either. The address is the - * public half of a key derived from the passphrase, so claiming it proves - * possession and nothing else, a relay confirms, it does not vouch. The home - * record is signed by that same key, so a relay serving a truncated list is - * caught by arithmetic rather than by trust. These tests are about the places - * that could quietly stop being true. */ class AccountIdentityTest { - private lateinit var server: MockWebServer - @BeforeTest fun start() { server = MockWebServer().also { it.start() } } - @AfterTest fun stop() { server.shutdown() } + @BeforeTest fun start() { + server = MockWebServer().also { it.start() } + } - private fun json(body: String) = MockResponse() - .setResponseCode(200).setHeader("Content-Type", "application/json").setBody(body) + @AfterTest fun stop() { + server.shutdown() + } + + private fun json(body: String) = + MockResponse() + .setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody(body) private fun seed(): ByteArray = ByteArray(32).also { SecureRandom().nextBytes(it) } @@ -44,10 +44,7 @@ class AccountIdentityTest { // MARK: - Claiming /** - * The audience is not the caller's to guess. It is the string the - * relay checks the signature against, so a caller inferring it from a URL - * would produce a claim that verifies nowhere, silently, because at the - * point of failure a wrong audience and a wrong key look identical. + * The audience is not the caller's to guess. */ fun claimingSignsWhatTheRelaySaysItsNameIs() {} @@ -58,34 +55,45 @@ class AccountIdentityTest { val expected = FNID.address(key.publicKey) var seenBody: JSONObject? = null - server.dispatcher = object : Dispatcher() { - override fun dispatch(request: RecordedRequest): MockResponse = when { - ".well-known/flamenet/relay" in request.path.orEmpty() -> - json("""{"audience":"relay.example.com","issuerPublicKey":""}""") - "/e2e/account/identity" in request.path.orEmpty() -> { - seenBody = JSONObject(request.body.readUtf8()) - json("""{"ok":true,"fnid":"$expected","created":true}""") - } - else -> MockResponse().setResponseCode(404) + server.dispatcher = + object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse = + when { + ".well-known/flamenet/relay" in request.path.orEmpty() -> { + json("""{"audience":"relay.example.com","issuerPublicKey":""}""") + } + + "/e2e/account/identity" in request.path.orEmpty() -> { + seenBody = JSONObject(request.body.readUtf8()) + json("""{"ok":true,"fnid":"$expected","created":true}""") + } + + else -> { + MockResponse().setResponseCode(404) + } + } } - } assertEquals(expected, AccountIdentity.claim(relay(), seed, userId = 42)) val body = seenBody!! val signature = Wire.unb64(body.getString("sig")) assertTrue( - Ed25519.verify(key.publicKey, signature, - FNID.claimCanonical("relay.example.com", 42, body.getString("account_key"))), + Ed25519.verify( + key.publicKey, + signature, + FNID.claimCanonical("relay.example.com", 42, body.getString("account_key")), + ), "the claim must verify against the audience the relay named", ) - // And not against a different one. A claim captured at one relay must - // not be replayable at another, the same account key is MEANT to be - // registered in several places, but each registration has to be an act - // the holder took there. + // And not against a different one. assertFalse( - Ed25519.verify(key.publicKey, signature, - FNID.claimCanonical("other.example.com", 42, body.getString("account_key")))) + Ed25519.verify( + key.publicKey, + signature, + FNID.claimCanonical("other.example.com", 42, body.getString("account_key")), + ), + ) } /** The address is derived, never taken from the relay's reply. */ @@ -99,35 +107,43 @@ class AccountIdentityTest { // MARK: - Home records - private fun signedRecord(seed: ByteArray, serial: Int, relays: List): JSONObject { + private fun signedRecord( + seed: ByteArray, + serial: Int, + relays: List, + ): JSONObject { val fnid = AccountIdentity.address(seed) val sig = Wire.b64(Ed25519.sign(seed, FNID.homesCanonical(fnid, serial, relays))) return JSONObject() - .put("fnid", fnid).put("serial", serial) - .put("relays", JSONArray(relays)).put("sig", sig) + .put("fnid", fnid) + .put("serial", serial) + .put("relays", JSONArray(relays)) + .put("sig", sig) } @Test fun `a record signed by the account verifies`() { val seed = seed() val record = signedRecord(seed, 3, listOf("a.example.com", "b.example.com")) - assertEquals(listOf("a.example.com", "b.example.com"), - AccountIdentity.verifiedRelays(record)) + assertEquals( + listOf("a.example.com", "b.example.com"), + AccountIdentity.verifiedRelays(record), + ) } /** - * A relay serving a truncated list is the exact failure multi-homing - * exists to survive. Drop a home from the record and it stops being - * signed, the sender then delivers everywhere the real record names, - * because it checks rather than trusts. + * A relay serving a truncated list is the exact failure multi-homing exists + * to survive. */ @Test fun `a shortened list does not verify`() { val seed = seed() val record = signedRecord(seed, 3, listOf("a.example.com", "b.example.com")) record.put("relays", JSONArray(listOf("a.example.com"))) - assertNull(AccountIdentity.verifiedRelays(record), - "a relay removed itself from the competition and was believed") + assertNull( + AccountIdentity.verifiedRelays(record), + "a relay removed itself from the competition and was believed", + ) } /** And neither does an added one, which is the same attack pointed outward. */ @@ -141,9 +157,9 @@ class AccountIdentityTest { /** * The serial is inside the signature, which is what stops the rollback a - * signature alone permits: an old list is signed, so without a - * counter a relay could serve one from before a second home was added and - * quietly become the only mailbox again. + * signature alone permits: an old list is signed, so without a counter a + * relay could serve one from before a second home was added and quietly + * become the only mailbox again. */ @Test fun `the serial is bound into the signature`() { @@ -164,47 +180,53 @@ class AccountIdentityTest { @Test fun `a malformed record is refused rather than guessed at`() { assertNull(AccountIdentity.verifiedRelays(JSONObject())) - assertNull(AccountIdentity.verifiedRelays( - JSONObject().put("fnid", "not-an-address").put("serial", 1) - .put("relays", JSONArray(listOf("a.example.com"))).put("sig", "!!"))) + assertNull( + AccountIdentity.verifiedRelays( + JSONObject() + .put("fnid", "not-an-address") + .put("serial", 1) + .put("relays", JSONArray(listOf("a.example.com"))) + .put("sig", "!!"), + ), + ) } // MARK: - Serial numbers /** - * Read from what is already published, not from local state. A device - * offline while another added a relay would otherwise reuse a serial and - * have its record refused as a rollback, by the very check that exists to - * stop an attacker doing the same thing. + * Read from what is already published, not from local state. */ @Test fun `the next serial is one past the highest any home admits to`() { val seed = seed() val fnid = AccountIdentity.address(seed) - server.dispatcher = object : Dispatcher() { - override fun dispatch(request: RecordedRequest) = - json(signedRecord(seed, 11, listOf("a.example.com")).toString()) - } + server.dispatcher = + object : Dispatcher() { + override fun dispatch(request: RecordedRequest) = json(signedRecord(seed, 11, listOf("a.example.com")).toString()) + } assertEquals(12, AccountIdentity.nextSerial(fnid, listOf(relay()))) } /** A home that answers nothing does not drag the serial backwards. */ @Test fun `a home with no record contributes nothing`() { - server.dispatcher = object : Dispatcher() { - override fun dispatch(request: RecordedRequest) = MockResponse().setResponseCode(404) - } - assertEquals(1, AccountIdentity.nextSerial(AccountIdentity.address(seed()), - listOf(relay()))) + server.dispatcher = + object : Dispatcher() { + override fun dispatch(request: RecordedRequest) = MockResponse().setResponseCode(404) + } + assertEquals( + 1, + AccountIdentity.nextSerial( + AccountIdentity.address(seed()), + listOf(relay()), + ), + ) } // MARK: - Publishing /** - * Every listed relay is told, not only the one being added. A home that - * does not know it is one still accepts mail, but a sender asking IT for - * the record gets a stale list and skips the others, so the redundancy - * would quietly depend on which relay the sender happened to ask. + * Every listed relay is told, not only the one being added. */ @Test fun `publishing reaches every home and reports the ones that refuse`() { @@ -212,25 +234,35 @@ class AccountIdentityTest { val fnid = AccountIdentity.address(seed) val good = MockWebServer().also { it.start() } - good.dispatcher = object : Dispatcher() { - override fun dispatch(request: RecordedRequest) = json("""{"ok":true}""") - } + good.dispatcher = + object : Dispatcher() { + override fun dispatch(request: RecordedRequest) = json("""{"ok":true}""") + } val bad = MockWebServer().also { it.start() } - bad.dispatcher = object : Dispatcher() { - override fun dispatch(request: RecordedRequest) = - MockResponse().setResponseCode(500).setBody("{}") - } + bad.dispatcher = + object : Dispatcher() { + override fun dispatch(request: RecordedRequest) = MockResponse().setResponseCode(500).setBody("{}") + } - val failures = AccountIdentity.publishHomes( - seed, fnid, serial = 4, - homes = mapOf( - "good.example.com" to RelayClient(good.url("/").toString(), "a", "b"), - "bad.example.com" to RelayClient(bad.url("/").toString(), "a", "b"), - )) + val failures = + AccountIdentity.publishHomes( + seed, + fnid, + serial = 4, + homes = + mapOf( + "good.example.com" to RelayClient(good.url("/").toString(), "a", "b"), + "bad.example.com" to RelayClient(bad.url("/").toString(), "a", "b"), + ), + ) - assertEquals(setOf("bad.example.com"), failures.keys, - "a partial success has to say WHICH copies landed") - good.shutdown(); bad.shutdown() + assertEquals( + setOf("bad.example.com"), + failures.keys, + "a partial success has to say WHICH copies landed", + ) + good.shutdown() + bad.shutdown() } /** What goes out is signed over the list as sent, in order. */ @@ -239,22 +271,34 @@ class AccountIdentityTest { val seed = seed() val fnid = AccountIdentity.address(seed) var body: JSONObject? = null - server.dispatcher = object : Dispatcher() { - override fun dispatch(request: RecordedRequest): MockResponse { - body = JSONObject(request.body.readUtf8()) - return json("""{"ok":true}""") + server.dispatcher = + object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse { + body = JSONObject(request.body.readUtf8()) + return json("""{"ok":true}""") + } } - } - AccountIdentity.publishHomes(seed, fnid, serial = 2, - homes = mapOf("only.example.com" to relay())) + AccountIdentity.publishHomes( + seed, + fnid, + serial = 2, + homes = mapOf("only.example.com" to relay()), + ) val sent = body!! - val relays = sent.getJSONArray("relays").let { a -> - (0 until a.length()).map { a.getString(it) } - } + val relays = + sent.getJSONArray("relays").let { a -> + (0 until a.length()).map { a.getString(it) } + } assertEquals(listOf("only.example.com"), relays) - assertTrue(AccountIdentity.verifyHomes(fnid, sent.getInt("serial"), relays, - sent.getString("sig"))) + assertTrue( + AccountIdentity.verifyHomes( + fnid, + sent.getInt("serial"), + relays, + sent.getString("sig"), + ), + ) } } diff --git a/client/src/test/kotlin/io/flamenet/client/AccountsClientStatusTest.kt b/client/src/test/kotlin/io/flamenet/client/AccountsClientStatusTest.kt index 99e2d03..78cc7e8 100644 --- a/client/src/test/kotlin/io/flamenet/client/AccountsClientStatusTest.kt +++ b/client/src/test/kotlin/io/flamenet/client/AccountsClientStatusTest.kt @@ -9,32 +9,33 @@ import kotlin.test.assertFailsWith /** * What a 401 from the account service is allowed to mean. - * - * Guards a bug found by driving the app: every 401 and 403 became - * [AccountsClient.Failure.BadCredentials], so a session that simply aged out - * told the user "that handle or passphrase isn't right", mid-send, hours - * after a successful sign-in. The obvious reading is that the password stopped - * working, and the obvious response is to change one that was never wrong. */ class AccountsClientStatusTest { - private lateinit var server: MockWebServer - @BeforeTest fun start() { server = MockWebServer().also { it.start() } } + @BeforeTest fun start() { + server = MockWebServer().also { it.start() } + } + @AfterTest fun stop() = server.shutdown() private fun client() = AccountsClient(server.url("/").toString()) - private fun unauthorized() = MockResponse().setResponseCode(401) - .setHeader("Content-Type", "application/json") - .setBody("""{"error":true,"reason":"unauthorized"}""") + private fun unauthorized() = + MockResponse() + .setResponseCode(401) + .setHeader("Content-Type", "application/json") + .setBody("""{"error":true,"reason":"unauthorized"}""") @Test fun `a rejected passphrase is reported as bad credentials`() { // The params lookup succeeds; the session request is what gets refused. - server.enqueue(MockResponse().setResponseCode(200) - .setHeader("Content-Type", "application/json") - .setBody("""{"kdfSalt":"AAAAAAAAAAAAAAAAAAAAAA==","kdfIterations":1,"kdfID":1}""")) + server.enqueue( + MockResponse() + .setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody("""{"kdfSalt":"AAAAAAAAAAAAAAAAAAAAAA==","kdfIterations":1,"kdfID":1}"""), + ) server.enqueue(unauthorized()) assertFailsWith { @@ -45,8 +46,7 @@ class AccountsClientStatusTest { @Test fun `a refused token is an expired session, not a wrong passphrase`() { server.enqueue(unauthorized()) - // Any authenticated call that does NOT present a passphrase. Telling - // this user their passphrase is wrong would be a lie. + // Any authenticated call that does NOT present a passphrase. assertFailsWith { client().parameters("someone") } diff --git a/client/src/test/kotlin/io/flamenet/client/AutomaticKeyLogTest.kt b/client/src/test/kotlin/io/flamenet/client/AutomaticKeyLogTest.kt index 2300391..572128c 100644 --- a/client/src/test/kotlin/io/flamenet/client/AutomaticKeyLogTest.kt +++ b/client/src/test/kotlin/io/flamenet/client/AutomaticKeyLogTest.kt @@ -17,22 +17,24 @@ import kotlin.test.assertTrue /** * §19 enforcement with no setting to find first. - * - * This used to be opt-in and off, so the protection shipped and almost - * nobody had it. The argument for a switch, a relay predating the log has - * none, and refusing every peer there is worse than the attack, was an - * argument for skipping relays that *cannot* answer, never for skipping the - * ones that can. These tests pin that distinction, because it is the whole - * feature: the relay decides which case it is in, and it must not be able to - * choose the lenient one after having already chosen the strict one. */ class AutomaticKeyLogTest { - private class MapStorage : Vault.Storage { val records = LinkedHashMap() + override fun read(name: String) = records[name] - override fun write(name: String, bytes: ByteArray) { records[name] = bytes } - override fun delete(name: String) { records.remove(name) } + + override fun write( + name: String, + bytes: ByteArray, + ) { + records[name] = bytes + } + + override fun delete(name: String) { + records.remove(name) + } + override fun names() = records.keys.toList() } @@ -42,8 +44,11 @@ class AutomaticKeyLogTest { @BeforeTest fun start() { server = MockWebServer().also { it.start() } - vault = Vault(MapStorage(), - KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()) + vault = + Vault( + MapStorage(), + KeyGenerator.getInstance("AES").apply { init(256) }.generateKey(), + ) identity = DeviceIdentity.create() } @@ -51,8 +56,11 @@ class AutomaticKeyLogTest { private fun trust() = Trust(identity, vault, RelayClient(server.url("/").toString(), "a", "b")) - private fun json(body: String) = MockResponse() - .setResponseCode(200).setHeader("Content-Type", "application/json").setBody(body) + private fun json(body: String) = + MockResponse() + .setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody(body) /** A peer whose keys are real, so nothing here passes on a malformed bundle. */ private fun peer(): PeerBundle { @@ -62,37 +70,40 @@ class AutomaticKeyLogTest { /** * A relay's log routes, answered by path. - * - * [head] null means the route is absent, a deployment from before §19. - * An unsigned head on purpose: `verifyHeadSignature` is silent when the - * field is empty, which is the documented behaviour for a relay that - * publishes no issuer key, and it keeps these tests about the presence - * question rather than about signing. */ - private fun relayServing(head: String?, entries: String = """{"entries":[]}""") { - server.dispatcher = object : Dispatcher() { - override fun dispatch(request: RecordedRequest): MockResponse { - val path = request.path.orEmpty() - return when { - "/e2e/log/head" in path -> - head?.let(::json) ?: MockResponse().setResponseCode(404) - "/e2e/log/device/" in path -> json(entries) - else -> MockResponse().setResponseCode(404) + private fun relayServing( + head: String?, + entries: String = """{"entries":[]}""", + ) { + server.dispatcher = + object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse { + val path = request.path.orEmpty() + return when { + "/e2e/log/head" in path -> { + head?.let(::json) ?: MockResponse().setResponseCode(404) + } + + "/e2e/log/device/" in path -> { + json(entries) + } + + else -> { + MockResponse().setResponseCode(404) + } + } } } - } } - private val aHead = """{"size":4,"root":"${Wire.b64(ByteArray(32) { 3 })}",""" + - """"audience":"relay.example.com"}""" + private val aHead = + """{"size":4,"root":"${Wire.b64(ByteArray(32) { 3 })}",""" + + """"audience":"relay.example.com"}""" // MARK: - A relay that keeps no log /** - * The case the old switch existed for. A relay from before §19 answers - * nothing, and refusing every contact on it would push people to something - * with no ratchet at all, a far worse outcome than the substitution being - * guarded against. + * The case the old switch existed for. */ @Test fun `a relay with no log at all does not block the send`() { @@ -111,9 +122,7 @@ class AutomaticKeyLogTest { // MARK: - A relay that keeps one /** - * The behaviour change. Before this, a relay serving a log and a key - * that is not in it was accepted without a murmur unless the user had found - * a switch in Privacy settings. Now the absent leaf is the answer. + * The behaviour change. */ @Test fun `a key that is not in the log is refused, with no setting turned on`() { @@ -125,28 +134,26 @@ class AutomaticKeyLogTest { /** * Entries that exist but do not match are still a refusal, and the flag - * says which case it was, "we could not compute your subject" and "this - * key is not published" call for different things being said to the user. + * says which case it was, "we could not compute your subject" and "this key + * is not published" call for different things being said to the user. */ @Test fun `entries that match nothing are refused and say so`() { - relayServing(head = aHead, - entries = """{"entries":[{"index":0,"leaf":"${Wire.b64(ByteArray(32))}"}]}""") - val failure = assertFailsWith { - trust().enforceKeyLogIfServed(peer(), userId = 7) - } + relayServing( + head = aHead, + entries = """{"entries":[{"index":0,"leaf":"${Wire.b64(ByteArray(32))}"}]}""", + ) + val failure = + assertFailsWith { + trust().enforceKeyLogIfServed(peer(), userId = 7) + } assertTrue(failure.hadEntries, "the device is in the log, this key is not") } // MARK: - The downgrade /** - * The attack automatic enforcement would otherwise invite. Skipping - * relays that keep no log means a relay can opt out of being checked, so - * publish a log until clients are talking, then stop answering, and every - * client silently goes back to trusting whatever key it is handed. Logs do - * not disappear; a relay that answered before and 404s now is not a - * deployment without transparency, it is one that changed its mind. + * The attack automatic enforcement would otherwise invite. */ @Test fun `a relay that served a log and then stops is a fork, not an absence`() { @@ -164,25 +171,19 @@ class AutomaticKeyLogTest { } /** - * A bad day is not an absence. A timeout, a 500 or a 429 must not - * read as "this relay keeps no log", or enforcement becomes skippable by - * anybody able to make one request fail, including the relay itself, which - * would only have to be unreliable rather than dishonest. + * A bad day is not an absence. */ @Test fun `a server error is not treated as a relay without a log`() { - server.dispatcher = object : Dispatcher() { - override fun dispatch(request: RecordedRequest) = MockResponse().setResponseCode(500) - } + server.dispatcher = + object : Dispatcher() { + override fun dispatch(request: RecordedRequest) = MockResponse().setResponseCode(500) + } assertFailsWith { trust().enforceKeyLogIfServed(peer(), userId = 7) } } /** - * The flag is per relay, and the first version was not. A single - * global one meant that signing in to a second relay which keeps no log - * read as the FIRST going quiet, so an honest deployment without - * transparency became a fork warning, and every contact on it became - * unreachable. + * The flag is per relay, and the first version was not. */ @Test fun `the served flag is remembered per relay`() { @@ -190,8 +191,11 @@ class AutomaticKeyLogTest { assertFailsWith { trust().enforceKeyLogIfServed(peer(), userId = 7) } assertTrue(vault.keyLogServed(server.url("/").host)) - assertEquals(false, vault.keyLogServed("somewhere.else"), - "one relay answering must say nothing about another") + assertEquals( + false, + vault.keyLogServed("somewhere.else"), + "one relay answering must say nothing about another", + ) } /** diff --git a/client/src/test/kotlin/io/flamenet/client/BlockListShapeTest.kt b/client/src/test/kotlin/io/flamenet/client/BlockListShapeTest.kt index d39edea..76c2885 100644 --- a/client/src/test/kotlin/io/flamenet/client/BlockListShapeTest.kt +++ b/client/src/test/kotlin/io/flamenet/client/BlockListShapeTest.kt @@ -10,26 +10,19 @@ import kotlin.test.assertTrue /** * The block list's wire shape, parsed from a NON-EMPTY list. - * - * This is the test that was missing, and its absence had teeth. The - * client read `user_id` from rows the relay emits as `{id, name, slug}`, the - * buddy-list shape, not the `user_id` shape the §8 routes use. It threw - * `No value for user_id`, and it survived because the only thing ever asserted - * was an EMPTY block list: with no rows, the map never runs and the parser is - * never exercised. Everything passed until the first person blocked somebody. - * - * So the case that matters is a list with something in it. An empty-list - * assertion here would be worse than none, because it looks like coverage. */ class BlockListShapeTest { - private lateinit var server: MockWebServer - @BeforeTest fun start() { server = MockWebServer().also { it.start() } } - @AfterTest fun stop() { server.shutdown() } + @BeforeTest fun start() { + server = MockWebServer().also { it.start() } + } - private fun api() = MessengerApi( - server.url("/").toString().trimEnd('/'), "someone", "app-password") + @AfterTest fun stop() { + server.shutdown() + } + + private fun api() = MessengerApi(server.url("/").toString().trimEnd('/'), "someone", "app-password") private fun serve(body: String) { server.enqueue(MockResponse().setResponseCode(200).setBody(body)) @@ -37,13 +30,14 @@ class BlockListShapeTest { @Test fun `a populated block list parses, using id rather than user_id`() { - // Exactly what the relay emits, see BlockListResponse in Wire.swift - // and the conformance assertion "the block list carries id, name and - // slug". - serve("""{"blocked":[ + // Exactly what the relay emits, see BlockListResponse in Wire.swift and + // the conformance assertion "the block list carries id, name and slug". + serve( + """{"blocked":[ {"id": 42, "name": "Spam Lord", "slug": "spamlord"}, {"id": 77, "name": "Someone Else", "slug": "someone-else"} - ]}""") + ]}""", + ) assertEquals(listOf(42L, 77L), api().blocked()) } @@ -54,10 +48,7 @@ class BlockListShapeTest { } /** - * A row shaped like the §8 routes must NOT quietly parse. If the relay - * ever changed shape, silently reading a different field would hand back - * an under-populated block list, and an under-populated block list is a - * blocked person becoming visible again. + * A row shaped like the §8 routes must NOT quietly parse. */ @Test fun `a row without id is a failure, not a silent gap in the list`() { diff --git a/client/src/test/kotlin/io/flamenet/client/BundleMergeTest.kt b/client/src/test/kotlin/io/flamenet/client/BundleMergeTest.kt index 989ab8c..764be32 100644 --- a/client/src/test/kotlin/io/flamenet/client/BundleMergeTest.kt +++ b/client/src/test/kotlin/io/flamenet/client/BundleMergeTest.kt @@ -7,49 +7,59 @@ import kotlin.test.assertTrue /** * The multi-device fan-out rule (found 2026-08-29 in live two-device testing). - * - * `listDevices` returns identity-only STUBS, enough for a device we already - * have a session with, useless for one we do not, because - * `E2ESession.initiate` verifies the signed prekey's signature and a stub has - * none. Choosing between the two lists meant that whenever ANY device already - * had a session, every device got a stub, and the one still needing a session - * dropped out of the fan-out. The other device still succeeded, so nothing - * reported a partial delivery: a contact who added a second device simply - * stopped receiving on it. - * - * The rule is UNION, not choice: full bundle where one exists, stub otherwise. */ class BundleMergeTest { + private fun stub(id: String) = + PeerBundle( + deviceId = id, + identityDH = ByteArray(32), + identitySigning = ByteArray(32), + signedPreKeyId = 0, + signedPreKey = ByteArray(0), + signedPreKeySignature = ByteArray(0), + oneTimePreKeyId = 0, + oneTimePreKey = null, + proto = 1, + ) - private fun stub(id: String) = PeerBundle( - deviceId = id, identityDH = ByteArray(32), identitySigning = ByteArray(32), - signedPreKeyId = 0, signedPreKey = ByteArray(0), - signedPreKeySignature = ByteArray(0), oneTimePreKeyId = 0, - oneTimePreKey = null, proto = 1) - - private fun full(id: String) = PeerBundle( - deviceId = id, identityDH = ByteArray(32), identitySigning = ByteArray(32), - signedPreKeyId = 7, signedPreKey = ByteArray(32) { 3 }, - signedPreKeySignature = ByteArray(64) { 4 }, oneTimePreKeyId = 1, - oneTimePreKey = ByteArray(32) { 5 }, proto = 3) + private fun full(id: String) = + PeerBundle( + deviceId = id, + identityDH = ByteArray(32), + identitySigning = ByteArray(32), + signedPreKeyId = 7, + signedPreKey = ByteArray(32) { 3 }, + signedPreKeySignature = ByteArray(64) { 4 }, + oneTimePreKeyId = 1, + oneTimePreKey = ByteArray(32) { 5 }, + proto = 3, + ) /** The merge as `sendPlaintext` performs it. */ - private fun merge(stubs: List, fetched: List): List { + private fun merge( + stubs: List, + fetched: List, + ): List { val byId = fetched.associateBy { it.deviceId } return stubs.map { byId[it.deviceId] ?: it } } @Test fun `every device the directory knows survives the merge`() { val merged = merge(listOf(stub("old"), stub("new")), listOf(full("new"))) - assertEquals(listOf("old", "new"), merged.map { it.deviceId }, - "a device must never drop out of the fan-out") + assertEquals( + listOf("old", "new"), + merged.map { it.deviceId }, + "a device must never drop out of the fan-out", + ) } @Test fun `a device needing a session gets the full bundle`() { val merged = merge(listOf(stub("old"), stub("new")), listOf(full("new"))) val fresh = merged.first { it.deviceId == "new" } - assertTrue(fresh.signedPreKey.isNotEmpty() && fresh.signedPreKeySignature.isNotEmpty(), - "initiate verifies this signature; a stub fails on empty bytes") + assertTrue( + fresh.signedPreKey.isNotEmpty() && fresh.signedPreKeySignature.isNotEmpty(), + "initiate verifies this signature; a stub fails on empty bytes", + ) } @Test fun `devices the key route omits keep their stub`() { diff --git a/client/src/test/kotlin/io/flamenet/client/ChainRpcTest.kt b/client/src/test/kotlin/io/flamenet/client/ChainRpcTest.kt index 08a519d..a5ec52c 100644 --- a/client/src/test/kotlin/io/flamenet/client/ChainRpcTest.kt +++ b/client/src/test/kotlin/io/flamenet/client/ChainRpcTest.kt @@ -2,6 +2,7 @@ package io.flamenet.client import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer +import org.json.JSONObject import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test @@ -9,46 +10,39 @@ import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertNull import kotlin.test.assertTrue -import org.json.JSONObject /** * Talking to a chain node. - * - * The failure this suite is really about is a refusal read as an answer. - * A JSON-RPC error arrives with HTTP 200, the transport succeeded, the node - * said no. A client that checks only the status code carries on with a body - * that has no `result` in it, fails somewhere less obvious, and throws away the - * node's own explanation on the way. "Insufficient funds for rent exemption" on - * screen versus a parse error nobody can act on is the whole difference. - * - * Against a `MockWebServer`, so these reach nothing real. See - * `TestSafetyTest`, a suite that signs up against a mock is the correct way to - * test signing up. */ class ChainRpcTest { - private lateinit var server: MockWebServer - @BeforeTest fun start() { server = MockWebServer().also { it.start() } } - @AfterTest fun stop() { server.shutdown() } + @BeforeTest fun start() { + server = MockWebServer().also { it.start() } + } + + @AfterTest fun stop() { + server.shutdown() + } private fun rpc() = ChainRpc(server.url("/").toString()) - private fun reply(json: String) = - server.enqueue(MockResponse().setBody(json).setHeader("Content-Type", "application/json")) + private fun reply(json: String) = server.enqueue(MockResponse().setBody(json).setHeader("Content-Type", "application/json")) - // ---- The refusal that looks like success ------------------------------ + // ---- The refusal that looks like success ------------------------------. /** - * The case the whole file exists for: HTTP 200, and a refusal in the - * body. + * The case the whole file exists for: HTTP 200, and a refusal in the body. */ @Test fun `a JSON-RPC error arriving with HTTP 200 is a failure, carrying the node's words`() { reply("""{"jsonrpc":"2.0","id":1,"error":{"code":-32002,"message":"insufficient funds"}}""") val thrown = assertFailsWith { rpc().balance("addr") } - assertEquals("insufficient funds", thrown.detail, - "the node's own explanation was replaced with a guess") + assertEquals( + "insufficient funds", + thrown.detail, + "the node's own explanation was replaced with a guess", + ) } /** An error with no message still refuses, rather than reading as success. */ @@ -73,8 +67,7 @@ class ChainRpcTest { /** * A body with neither `result` nor `error` is malformed rather than a - * balance of zero. Reading a missing result as zero would show somebody an - * empty wallet that is not empty. + * balance of zero. */ @Test fun `a body with no result is malformed rather than a zero balance`() { @@ -82,7 +75,7 @@ class ChainRpcTest { assertFailsWith { rpc().balance("addr") } } - // ---- Balance --------------------------------------------------------- + // ---- Balance ---------------------------------------------------------. @Test fun `a balance is read in lamports`() { @@ -99,8 +92,7 @@ class ChainRpcTest { /** * Lamports exceed what a 32-bit value holds long before the balance is - * interesting: 5 SOL is already over Int.MAX_VALUE. Read as a Long - * throughout, and this pins it. + * interesting: 5 SOL is already over Int.MAX_VALUE. */ @Test fun `a balance larger than an Int is read intact`() { @@ -118,12 +110,10 @@ class ChainRpcTest { assertEquals("SomeAddress", sent.getJSONArray("params").getString(0)) } - // ---- History --------------------------------------------------------- + // ---- History ---------------------------------------------------------. /** - * A failed transaction still appears in an address's history. Not - * showing that it failed would be the worst kind of wrong: somebody would - * believe a payment went through. + * A failed transaction still appears in an address's history. */ @Test fun `a failed transaction is reported as failed`() { @@ -131,7 +121,7 @@ class ChainRpcTest { """{"jsonrpc":"2.0","id":1,"result":[ {"signature":"sig-ok","slot":10,"blockTime":1700000000,"err":null}, {"signature":"sig-bad","slot":11,"blockTime":1700000100, - "err":{"InstructionError":[0,{"Custom":1}]}}]}""" + "err":{"InstructionError":[0,{"Custom":1}]}}]}""", ) val history = rpc().history("addr") assertEquals(2, history.size) @@ -141,34 +131,36 @@ class ChainRpcTest { /** * `err` is an object for one kind of failure and an array for another. - * Testing for "not null" rather than for a shape is what makes both - * visible, checking for an object alone would silently pass one class of - * failure off as a success. */ @Test fun `an error reported as an array is still a failure`() { - reply("""{"jsonrpc":"2.0","id":1,"result":[ - {"signature":"sig","slot":1,"blockTime":1,"err":["something"]}]}""") + reply( + """{"jsonrpc":"2.0","id":1,"result":[ + {"signature":"sig","slot":1,"blockTime":1,"err":["something"]}]}""", + ) assertTrue(rpc().history("addr").single().failed) } /** - * A very new transaction has no `blockTime` yet, the node has it and - * has not dated it. Null means "just now"; reading a missing value as 0 - * would date it to 1970 on somebody's screen. + * A very new transaction has no `blockTime` yet, the node has it and has + * not dated it. */ @Test fun `a transaction the node has not dated yet has no time, not 1970`() { - reply("""{"jsonrpc":"2.0","id":1,"result":[ - {"signature":"sig","slot":1,"blockTime":null,"err":null}]}""") + reply( + """{"jsonrpc":"2.0","id":1,"result":[ + {"signature":"sig","slot":1,"blockTime":null,"err":null}]}""", + ) assertNull(rpc().history("addr").single().blockTimeSeconds) } @Test fun `a row with no signature is dropped rather than shown blank`() { - reply("""{"jsonrpc":"2.0","id":1,"result":[ + reply( + """{"jsonrpc":"2.0","id":1,"result":[ {"slot":1,"err":null}, - {"signature":"sig","slot":2,"err":null}]}""") + {"signature":"sig","slot":2,"err":null}]}""", + ) assertEquals(listOf("sig"), rpc().history("addr").map { it.signature }) } diff --git a/client/src/test/kotlin/io/flamenet/client/ConversationsPollTest.kt b/client/src/test/kotlin/io/flamenet/client/ConversationsPollTest.kt index 2e87475..aa741ef 100644 --- a/client/src/test/kotlin/io/flamenet/client/ConversationsPollTest.kt +++ b/client/src/test/kotlin/io/flamenet/client/ConversationsPollTest.kt @@ -17,24 +17,24 @@ import kotlin.test.assertTrue /** * The inbound path, with real ciphertext. - * - * `poll` was at 0%. The tests that cover it, the `Live*` suites, are - * opt-in via `FN_LIVE_FLEET` and skip in an ordinary run, so the single most - * important path in the client contributed nothing to a normal build. That is - * worse than having no test, because the file listing says it is covered. - * - * Nothing here is stubbed below the transport: the peer is a real - * `DeviceIdentity`, the envelopes are real ciphertext from a real ratchet, and - * only the relay's HTTP is a mock. A decrypt that succeeds here succeeded for - * the same reason it does in the field. */ class ConversationsPollTest { - private class MapStorage : Vault.Storage { val records = LinkedHashMap() + override fun read(name: String) = records[name] - override fun write(name: String, bytes: ByteArray) { records[name] = bytes } - override fun delete(name: String) { records.remove(name) } + + override fun write( + name: String, + bytes: ByteArray, + ) { + records[name] = bytes + } + + override fun delete(name: String) { + records.remove(name) + } + override fun names() = records.keys.toList() } @@ -44,33 +44,44 @@ class ConversationsPollTest { @BeforeTest fun start() { server = MockWebServer().also { it.start() } - vault = Vault(MapStorage(), - KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()) + vault = + Vault( + MapStorage(), + KeyGenerator.getInstance("AES").apply { init(256) }.generateKey(), + ) me = DeviceIdentity.create() } @AfterTest fun stop() = server.shutdown() - private fun conversations() = - Conversations(me, vault, RelayClient(server.url("/").toString(), "a", "b")) + private fun conversations() = Conversations(me, vault, RelayClient(server.url("/").toString(), "a", "b")) - private fun json(body: String) = MockResponse() - .setResponseCode(200).setHeader("Content-Type", "application/json").setBody(body) + private fun json(body: String) = + MockResponse() + .setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody(body) /** My own bundle, as a peer would fetch it. */ private fun myBundle(): PeerBundle { val opk = me.generateOneTimePreKeys(1).first() - return PeerBundle.fromWire(me.registrationWire().put("opk", - JSONObject().put("id", opk.id).put("pub", Wire.b64(opk.publicKey)))) + return PeerBundle.fromWire( + me.registrationWire().put( + "opk", + JSONObject().put("id", opk.id).put("pub", Wire.b64(opk.publicKey)), + ), + ) } /** One inbox page. */ - private fun inbox(vararg messages: String) = - json("""{"messages":[${messages.joinToString(",")}]}""") + private fun inbox(vararg messages: String) = json("""{"messages":[${messages.joinToString(",")}]}""") - private fun envelopeJson(id: Long, fromDevice: String, payload: String, - fromUser: Long = 7) = - """{"id":$id,"from_user":$fromUser,"from_device":"$fromDevice","payload":"$payload"}""" + private fun envelopeJson( + id: Long, + fromDevice: String, + payload: String, + fromUser: Long = 7, + ) = """{"id":$id,"from_user":$fromUser,"from_device":"$fromDevice","payload":"$payload"}""" /** A peer who has never written to us before. */ private fun stranger(): Pair { @@ -94,15 +105,17 @@ class ConversationsPollTest { } /** - * An advancing watermark is what stops the same message arriving for - * ever. It must move even though the relay decides the ids. + * An advancing watermark is what stops the same message arriving for ever. */ @Test fun `the watermark advances past what was read`() { val (peer, session) = stranger() - server.enqueue(inbox( - envelopeJson(11, peer.deviceId, session.encrypt("one").toPayload()), - envelopeJson(12, peer.deviceId, session.encrypt("two").toPayload()))) + server.enqueue( + inbox( + envelopeJson(11, peer.deviceId, session.encrypt("one").toPayload()), + envelopeJson(12, peer.deviceId, session.encrypt("two").toPayload()), + ), + ) conversations().poll() assertEquals(12, vault.inboxWatermark) @@ -115,16 +128,7 @@ class ConversationsPollTest { } /** - * Three chains to try, not one. This used to be - * `existingSession(fromDevice) ?: respond(...)`, which reads as a fallback - * and is not one: when a stored session existed it was the ONLY thing - * tried, decrypt threw for a peer on a different chain, and the catch - * swallowed it, so `respond` was never reached. - * - * That happens whenever both sides run X3DH before either has processed the - * other's prekey: two people opening the same chat at once, which is - * ordinary behaviour. Each adopts the other's chain, and from then on every - * message arrives on a chain the receiver has thrown away. + * Three chains to try, not one. */ @Test fun `a peer who started a new chain is still readable`() { @@ -138,8 +142,15 @@ class ConversationsPollTest { // The peer restarts and initiates a brand new chain to the same device. val fresh = E2ESession.initiate(peer, myBundle()) - server.enqueue(inbox(envelopeJson(2, peer.deviceId, - fresh.encrypt("from the new chain").toPayload()))) + server.enqueue( + inbox( + envelopeJson( + 2, + peer.deviceId, + fresh.encrypt("from the new chain").toPayload(), + ), + ), + ) val received = conversations().poll() assertEquals(1, received.size, "the new chain was dropped") @@ -148,21 +159,23 @@ class ConversationsPollTest { /** * A message that cannot be opened must not take the rest of the page with - * it. One corrupt envelope from one peer would otherwise stall every - * conversation behind it. + * it. */ @Test fun `an unreadable message does not discard the rest of the batch`() { val (peer, session) = stranger() - server.enqueue(inbox( - envelopeJson(1, "garbage-device", "not-a-real-payload"), - envelopeJson(2, peer.deviceId, session.encrypt("still delivered").toPayload()))) + server.enqueue( + inbox( + envelopeJson(1, "garbage-device", "not-a-real-payload"), + envelopeJson(2, peer.deviceId, session.encrypt("still delivered").toPayload()), + ), + ) val received = conversations().poll() assertEquals(1, received.size) assertEquals("still delivered", received.single().text) - // And the watermark passes BOTH, or the unreadable one is retried - // for ever and the inbox never drains. + // And the watermark passes BOTH, or the unreadable one is retried for + // ever and the inbox never drains. assertEquals(2, vault.inboxWatermark) } @@ -174,20 +187,27 @@ class ConversationsPollTest { /** * Two peers in one page are each opened on their own chain. - * - * They must be different USERS. Two identities claiming one user id is - * exactly what the C1 gate refuses, so writing this with the default sender - * makes the second message vanish, correctly, and confusingly. */ @Test fun `messages from two peers are both delivered`() { val (alice, aliceSession) = stranger() val (bob, bobSession) = stranger() - server.enqueue(inbox( - envelopeJson(1, alice.deviceId, aliceSession.encrypt("from alice").toPayload(), - fromUser = 7), - envelopeJson(2, bob.deviceId, bobSession.encrypt("from bob").toPayload(), - fromUser = 8))) + server.enqueue( + inbox( + envelopeJson( + 1, + alice.deviceId, + aliceSession.encrypt("from alice").toPayload(), + fromUser = 7, + ), + envelopeJson( + 2, + bob.deviceId, + bobSession.encrypt("from bob").toPayload(), + fromUser = 8, + ), + ), + ) val texts = conversations().poll().map { it.text }.toSet() assertEquals(setOf("from alice", "from bob"), texts) @@ -200,14 +220,28 @@ class ConversationsPollTest { @Test fun `the opened session is stored for the next message`() { val (peer, session) = stranger() - server.enqueue(inbox(envelopeJson(1, peer.deviceId, - session.encrypt("first").toPayload()))) + server.enqueue( + inbox( + envelopeJson( + 1, + peer.deviceId, + session.encrypt("first").toPayload(), + ), + ), + ) conversations().poll() // A second message on the SAME chain, read by a fresh Conversations // over the same vault. - server.enqueue(inbox(envelopeJson(2, peer.deviceId, - session.encrypt("second").toPayload()))) + server.enqueue( + inbox( + envelopeJson( + 2, + peer.deviceId, + session.encrypt("second").toPayload(), + ), + ), + ) assertEquals("second", conversations().poll().single().text) } @@ -219,36 +253,41 @@ class ConversationsPollTest { val second = session.encrypt("two") val third = session.encrypt("three") - server.enqueue(inbox( - envelopeJson(1, peer.deviceId, first.toPayload()), - envelopeJson(3, peer.deviceId, third.toPayload()), - envelopeJson(2, peer.deviceId, second.toPayload()))) + server.enqueue( + inbox( + envelopeJson(1, peer.deviceId, first.toPayload()), + envelopeJson(3, peer.deviceId, third.toPayload()), + envelopeJson(2, peer.deviceId, second.toPayload()), + ), + ) val texts = conversations().poll().map { it.text } assertEquals(setOf("one", "two", "three"), texts.toSet()) } /** - * The receive-side identity gate (audit C1). Building a session from - * a prekey envelope means accepting whatever identity key it offers, so it - * must pass the SAME pin check the send path applies. Without it, anyone - * could hand us a handshake with their own key while claiming to be a - * contact we have already pinned, and every later message from that session - * would render as that contact. + * The receive-side identity gate (audit C1). */ @Test fun `a handshake from an unpinned key claiming a pinned contact is refused`() { val realContact = DeviceIdentity.create() // We have pinned the real contact's identity for user 7. - vault.saveTrust(7, Vault.Trust( - identities = listOf(Wire.b64(realContact.identitySigning.publicKey)), - verified = true)) + vault.saveTrust( + 7, + Vault.Trust( + identities = listOf(Wire.b64(realContact.identitySigning.publicKey)), + verified = true, + ), + ) // An impostor initiates, claiming to be user 7. val impostor = DeviceIdentity.create() val session = E2ESession.initiate(impostor, myBundle()) - server.enqueue(inbox(envelopeJson( - 1, impostor.deviceId, session.encrypt("trust me").toPayload(), fromUser = 7))) + server.enqueue( + inbox( + envelopeJson(1, impostor.deviceId, session.encrypt("trust me").toPayload(), fromUser = 7), + ), + ) val received = conversations().poll() assertEquals(0, received.size, "an unpinned key was accepted as a pinned contact") @@ -258,13 +297,20 @@ class ConversationsPollTest { @Test fun `a handshake from the pinned key is accepted`() { val contact = DeviceIdentity.create() - vault.saveTrust(7, Vault.Trust( - identities = listOf(Wire.b64(contact.identitySigning.publicKey)), - verified = true)) + vault.saveTrust( + 7, + Vault.Trust( + identities = listOf(Wire.b64(contact.identitySigning.publicKey)), + verified = true, + ), + ) val session = E2ESession.initiate(contact, myBundle()) - server.enqueue(inbox(envelopeJson( - 1, contact.deviceId, session.encrypt("it is me").toPayload(), fromUser = 7))) + server.enqueue( + inbox( + envelopeJson(1, contact.deviceId, session.encrypt("it is me").toPayload(), fromUser = 7), + ), + ) assertEquals("it is me", conversations().poll().single().text) } @@ -278,7 +324,11 @@ class ConversationsPollTest { fun `a failed poll is not reported as an empty inbox`() { server.enqueue(MockResponse().setResponseCode(503).setBody("down")) var threw = false - try { conversations().poll() } catch (_: Exception) { threw = true } + try { + conversations().poll() + } catch (_: Exception) { + threw = true + } assertTrue(threw, "a 503 was reported as an empty inbox") // The watermark did not move on a failure. assertEquals(0, vault.inboxWatermark) diff --git a/client/src/test/kotlin/io/flamenet/client/DeliveryDiagnosticsTest.kt b/client/src/test/kotlin/io/flamenet/client/DeliveryDiagnosticsTest.kt index d305899..69560e5 100644 --- a/client/src/test/kotlin/io/flamenet/client/DeliveryDiagnosticsTest.kt +++ b/client/src/test/kotlin/io/flamenet/client/DeliveryDiagnosticsTest.kt @@ -8,18 +8,16 @@ import kotlin.test.assertTrue /** What the app is allowed to say about why delivery failed (ios#21, #22, #23). */ class DeliveryDiagnosticsTest { - @Test fun `identifiers are stripped from reasons before they are counted`() { // A reading meant to be safe to screenshot must not carry device ids. - // These are the real shapes: the refusal messages name what was wrong, - // and that sometimes means naming an id. - val cases = listOf( - "device ce0c0fb4 has published keys, but none match what this relay served", - "device claims 41 but is bound to 12", - "we do not hold signed prekey 7", - "device claims 3f8a9c2e11bd but is bound to 9f0e", - ) + val cases = + listOf( + "device ce0c0fb4 has published keys, but none match what this relay served", + "device claims 41 but is bound to 12", + "we do not hold signed prekey 7", + "device claims 3f8a9c2e11bd but is bound to 9f0e", + ) for (raw in cases) { val out = DeliveryDiagnostics.normalisedReason(raw) assertFalse(out.any { it.isDigit() }, "'$out' still carries a digit from '$raw'") @@ -28,8 +26,7 @@ class DeliveryDiagnosticsTest { @Test fun `a reason with nothing identifying survives intact`() { - // It has to stay readable, or the screen says nothing. "base64" - // contains a digit and must NOT be mangled. + // It has to stay readable, or the screen says nothing. val plain = "the payload was not valid base64" assertEquals(plain, DeliveryDiagnostics.normalisedReason(plain)) } diff --git a/client/src/test/kotlin/io/flamenet/client/DeliveryTokenTest.kt b/client/src/test/kotlin/io/flamenet/client/DeliveryTokenTest.kt index 8654488..4c3114e 100644 --- a/client/src/test/kotlin/io/flamenet/client/DeliveryTokenTest.kt +++ b/client/src/test/kotlin/io/flamenet/client/DeliveryTokenTest.kt @@ -20,26 +20,24 @@ import kotlin.test.assertTrue /** * Single-use delivery capabilities (§21.4). - * - * The property under test is that a capability is SPENT, not merely - * checked. One that survives its use is a reusable per-contact key, which is - * a stable value the relay sees on every message from that sender, exactly the - * pseudonym §21.2 rejects. So "the same one twice is refused" is not a - * nice-to-have; it is the whole reason this is not per-contact delivery keys - * with extra steps. - * - * The fixture keeps capability hashes in one flat set per recipient, with no - * sender anywhere, because that is what the real relay stores. A stub that - * indexed them by sender would let these tests pass on a property the relay - * does not have. */ class DeliveryTokenTest { - private class MapStorage : Vault.Storage { val records = LinkedHashMap() + override fun read(name: String) = records[name] - override fun write(name: String, bytes: ByteArray) { records[name] = bytes } - override fun delete(name: String) { records.remove(name) } + + override fun write( + name: String, + bytes: ByteArray, + ) { + records[name] = bytes + } + + override fun delete(name: String) { + records.remove(name) + } + override fun names() = records.keys.toList() } @@ -52,59 +50,81 @@ class DeliveryTokenTest { private val held = mutableSetOf() private var spent = 0 private var lastSend: JSONObject? = null + /** Set to make the relay refuse the next capability, as a revocation does. */ private var refuseTokens = false @BeforeTest fun start() { me = DeviceIdentity.create() peer = DeviceIdentity.create() - server = MockWebServer().also { it.dispatcher = relay(); it.start() } - vault = Vault(MapStorage(), - KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()) + server = + MockWebServer().also { + it.dispatcher = relay() + it.start() + } + vault = + Vault( + MapStorage(), + KeyGenerator.getInstance("AES").apply { init(256) }.generateKey(), + ) vault.saveIdentity(me) } @AfterTest fun stop() = server.shutdown() - private fun json(body: String) = MockResponse() - .setResponseCode(200).setHeader("Content-Type", "application/json").setBody(body) + private fun json(body: String) = + MockResponse() + .setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody(body) - private fun relay() = object : Dispatcher() { - override fun dispatch(request: RecordedRequest): MockResponse { - val path = request.path.orEmpty() - return when { - "/e2e/log/" in path -> MockResponse().setResponseCode(404) - "/e2e/delivery-tokens" in path -> { - val b = JSONObject(request.body.readUtf8()) - // Revoke first, so a client cutting one contact off while - // topping another up in the same call keeps what it added. - b.optJSONArray("revoke")?.let { a -> - (0 until a.length()).forEach { held.remove(a.getString(it)) } + private fun relay() = + object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse { + val path = request.path.orEmpty() + return when { + "/e2e/log/" in path -> { + MockResponse().setResponseCode(404) } - b.optJSONArray("add")?.let { a -> - (0 until a.length()).forEach { held.add(a.getString(it)) } - } - json("""{"ok":true,"remaining":${held.size}}""") - } - "/e2e/delivery-key" in path -> json("""{"ok":true}""") - "/e2e/messages" in path && request.method == "POST" -> { - val b = JSONObject(request.body.readUtf8()) - lastSend = b - val token = b.optString("delivery_token").ifEmpty { null } - if (token != null) { - val hash = Wire.b64(Wire.sha256(Wire.unb64(token))) - if (refuseTokens || !held.contains(hash)) { - return MockResponse().setResponseCode(401).setBody("{}") + + "/e2e/delivery-tokens" in path -> { + val b = JSONObject(request.body.readUtf8()) + // Revoke first, so a client cutting one contact off while + // topping another up in the same call keeps what it added. + b.optJSONArray("revoke")?.let { a -> + (0 until a.length()).forEach { held.remove(a.getString(it)) } } - held.remove(hash) - spent++ + b.optJSONArray("add")?.let { a -> + (0 until a.length()).forEach { held.add(a.getString(it)) } + } + json("""{"ok":true,"remaining":${held.size}}""") + } + + "/e2e/delivery-key" in path -> { + json("""{"ok":true}""") + } + + "/e2e/messages" in path && request.method == "POST" -> { + val b = JSONObject(request.body.readUtf8()) + lastSend = b + val token = b.optString("delivery_token").ifEmpty { null } + if (token != null) { + val hash = Wire.b64(Wire.sha256(Wire.unb64(token))) + if (refuseTokens || !held.contains(hash)) { + return MockResponse().setResponseCode(401).setBody("{}") + } + held.remove(hash) + spent++ + } + json("""{"ok":true}""") + } + + else -> { + json("""{"devices":[${peer.registrationWire()}]}""") } - json("""{"ok":true}""") } - else -> json("""{"devices":[${peer.registrationWire()}]}""") } } - } private fun conversations() = Conversations(me, vault, RelayClient(server.url("/").toString(), "me", "pw")) @@ -119,25 +139,28 @@ class DeliveryTokenTest { private fun myBundle(): PeerBundle { val opk = me.generateOneTimePreKeys(1).first() - return PeerBundle.fromWire(me.registrationWire().put("opk", - JSONObject().put("id", opk.id).put("pub", Wire.b64(opk.publicKey)))) + return PeerBundle.fromWire( + me.registrationWire().put( + "opk", + JSONObject().put("id", opk.id).put("pub", Wire.b64(opk.publicKey)), + ), + ) } // MARK: - Issuing /** - * The batch rides the EXISTING `delivery_key` envelope. A new control - * kind would have arrived on shipped phones as a bubble full of raw JSON, - * because they read the `ctl` flag with `optBoolean` and it does not coerce - * the number the spec calls for. + * The batch rides the EXISTING `delivery_key` envelope. */ @Test fun `a batch travels with the delivery key, not as a kind of its own`() { val c = withDeliveryKey() c.send(toUserId = 42, text = "hello") - val envelope = JSONObject(SealedSender.deliveryKeyEnvelope( - vault.loadDelivery()!!.key, vault.issuedTokens(42))) + val envelope = + JSONObject( + SealedSender.deliveryKeyEnvelope(vault.loadDelivery()!!.key, vault.issuedTokens(42)), + ) assertEquals("delivery_key", envelope.getString("kind")) assertEquals(64, envelope.getJSONArray("tokens").length()) assertEquals(64, vault.issuedTokens(42).size) @@ -167,16 +190,13 @@ class DeliveryTokenTest { assertEquals(1, spent) assertTrue(lastSend!!.optString("delivery_token").isNotEmpty()) - // And NOT the key alongside it. Sending both would let the relay - // accept on the key when the capability had been revoked, so it would - // never learn the capability was refused. + // And NOT the key alongside it. assertTrue(lastSend!!.optString("delivery_key").isEmpty()) assertTrue(vault.peerTokens(42).isEmpty(), "the capability was not spent locally") } /** - * The load-bearing one. A capability that survived its use is a reusable - * per-contact key, and the relay then sees one constant per sender. + * The load-bearing one. */ @Test fun `the same capability is not presented twice`() { @@ -213,9 +233,8 @@ class DeliveryTokenTest { } /** - * Revoking moves a sender back onto the shared key; it does not - * silence them. Stated as a test because a client that implied otherwise - * would be lying about the one thing this section is for. + * Revoking moves a sender back onto the shared key; it does not silence + * them. */ @Test fun `a refused capability falls back to the shared key rather than failing`() { @@ -237,10 +256,8 @@ class DeliveryTokenTest { // MARK: - Replenishment, counted rather than asked for /** - * A "running low" control message would have been a new kind, with the - * same rendering problem on shipped phones. It is not needed: the issuer - * counts, because every message it receives from a contact spent one of - * their capabilities. + * A "running low" control message would have been a new kind, with the same + * rendering problem on shipped phones. */ @Test fun `the issuer counts what it receives and mints again at half a batch`() { @@ -272,15 +289,7 @@ class DeliveryTokenTest { } /** - * This is the act the whole section exists to make possible. The - * relay cannot enforce a block against a sealed sender, the request - * carries no sender to compare against a block list, so before §21.4, - * blocking stopped the identified path and nothing else, and a blocked - * person's sealed messages were dropped by the app after decryption rather - * than refused at the door. - * - * The view model calls this alongside the block; asserted at this layer - * because it is where the behaviour lives. + * This is the act the whole section exists to make possible. */ @Test fun `revoking is what stops a sealed sender the relay cannot identify`() { @@ -310,43 +319,62 @@ class DeliveryTokenTest { val fresh = (0 until 3).map { Wire.b64(ByteArray(32) { i -> (it + i).toByte() }) } val envelope = SealedSender.deliveryKeyEnvelope(ByteArray(32) { 4 }, fresh) - c.consumeControl(42, io.flamenet.e2e.Content.parse(envelope)) + c.consumeControl( + 42, + io.flamenet.e2e.Content + .parse(envelope), + ) assertEquals(fresh, vault.peerTokens(42)) - assertFalse(io.flamenet.e2e.Content.parse(envelope).isRenderable, - "a capability batch reached the thread") + assertFalse( + io.flamenet.e2e.Content + .parse(envelope) + .isRenderable, + "a capability batch reached the thread", + ) } - /** Malformed entries are dropped rather than stored: one that is not 32 - * bytes could never be spent, and keeping it would burn a send. */ + /** + * Malformed entries are dropped rather than stored: one that is not 32. + */ @Test fun `a malformed capability in a batch is dropped`() { val c = conversations() val good = Wire.b64(ByteArray(32) { 1 }) - val envelope = SealedSender.deliveryKeyEnvelope( - ByteArray(32) { 4 }, listOf(good, "!!!!", Wire.b64(ByteArray(31)))) + val envelope = + SealedSender.deliveryKeyEnvelope(ByteArray(32) { 4 }, listOf(good, "!!!!", Wire.b64(ByteArray(31)))) - c.consumeControl(42, io.flamenet.e2e.Content.parse(envelope)) + c.consumeControl( + 42, + io.flamenet.e2e.Content + .parse(envelope), + ) assertEquals(listOf(good), vault.peerTokens(42)) } /** * A contact who sends no batch leaves us on the shared key, which works. - * - * Needs our OWN key published, because a peer's key is stored inside our - * delivery record. That is the app's real state, `ensureDeliveryKey` runs - * at sign-in, and it is exactly why the capability store is separate and - * does NOT depend on it. */ @Test fun `an envelope with no batch leaves the shared key alone`() { val c = withDeliveryKey() val envelope = SealedSender.deliveryKeyEnvelope(ByteArray(32) { 4 }) - c.consumeControl(42, io.flamenet.e2e.Content.parse(envelope)) + c.consumeControl( + 42, + io.flamenet.e2e.Content + .parse(envelope), + ) assertTrue(vault.peerTokens(42).isEmpty()) - assertEquals(32, vault.loadDelivery()?.peers?.get(42L)?.size) + assertEquals( + 32, + vault + .loadDelivery() + ?.peers + ?.get(42L) + ?.size, + ) } } diff --git a/client/src/test/kotlin/io/flamenet/client/DeviceClockTest.kt b/client/src/test/kotlin/io/flamenet/client/DeviceClockTest.kt index 0275711..3162e40 100644 --- a/client/src/test/kotlin/io/flamenet/client/DeviceClockTest.kt +++ b/client/src/test/kotlin/io/flamenet/client/DeviceClockTest.kt @@ -10,24 +10,15 @@ import kotlin.test.assertTrue /** * Handling a wrong device clock (ios#45, Android half). - * - * The rule under test is an asymmetry, not a correction. A story - * disappearing after an hour is a promise the SENDER made, enforced on the - * READER's device. A reader whose clock runs behind keeps a message the sender - * was told would be gone, and nobody involved can tell. Being wrong the other - * way costs the reader something they were about to lose anyway. - * - * So expiry takes the LATER of the device's clock and the relay's, and these - * tests are mostly about that one decision holding in every direction. */ class DeviceClockTest { - @BeforeTest fun clean() = DeviceClock.forget() + @AfterTest fun tidy() = DeviceClock.forget() private val now = 1_700_000_000_000L - // ---- Learning the skew ---------------------------------------------- + // ---- Learning the skew ----------------------------------------------. @Test fun `a Date header teaches the skew`() { @@ -48,7 +39,7 @@ class DeviceClockTest { /** * A skew learned yesterday says nothing about today: the clock may have * been corrected, or the phone rebooted with a fresh answer from the - * network. An observation that has aged out is the same as none. + * network. */ @Test fun `an old observation is not used`() { @@ -58,13 +49,10 @@ class DeviceClockTest { assertNull(DeviceClock.usableSkew(now + DeviceClock.OBSERVATION_LIFETIME_MS + 1)) } - // ---- The rule that matters ------------------------------------------ + // ---- The rule that matters ------------------------------------------. /** - * A device clock set BACK cannot extend a message's life. This is the - * case the whole file exists for: the phone thinks it is earlier than it is, - * so on its own reading a disappearing message is still alive. The server's - * time overrules it. + * A device clock set BACK cannot extend a message's life. */ @Test fun `a device running behind does not keep a message alive`() { @@ -74,15 +62,16 @@ class DeviceClockTest { } /** - * And a relay that lies about the time cannot either. A hostile relay - * would like to move time BACKWARDS so disappearing messages linger. Taking - * the later of the two means its answer is simply ignored. + * And a relay that lies about the time cannot either. */ @Test fun `a relay claiming an earlier time is ignored`() { DeviceClock.observe(now - 600_000L, now) - assertEquals(now, DeviceClock.nowForExpiry(now), - "the relay's earlier time was taken, so a hostile relay can extend expiries") + assertEquals( + now, + DeviceClock.nowForExpiry(now), + "the relay's earlier time was taken, so a hostile relay can extend expiries", + ) } /** With nothing learned, the device's own clock is all there is. */ @@ -115,7 +104,7 @@ class DeviceClockTest { } } - // ---- Telling somebody ----------------------------------------------- + // ---- Telling somebody -----------------------------------------------. @Test fun `ordinary drift says nothing`() { @@ -130,8 +119,7 @@ class DeviceClockTest { assertTrue(DeviceClock.isSuspect(now)) val warning = DeviceClock.warning(now)!! assertTrue(warning.contains("15 minutes behind"), warning) - // The symptom before the cause. Somebody whose stories keep vanishing - // does not think "my clock is wrong"; they think the app is broken. + // The symptom before the cause. assertTrue(warning.contains("Stories and disappearing messages"), warning) } @@ -151,12 +139,10 @@ class DeviceClockTest { assertTrue(DeviceClock.isSuspect(now)) } - // ---- Shape ---------------------------------------------------------- + // ---- Shape ----------------------------------------------------------. /** - * Seconds as a Double, matching `Vault.Story.expires`. Flooring to a Long - * of seconds here would lose the sub-second part on the boundary that - * decides whether a story is still on screen. + * Seconds as a Double, matching `Vault.Story.expires`. */ @Test fun `expiry seconds keep sub-second precision`() { @@ -175,9 +161,7 @@ class DeviceClockTest { /** * The round trip through the header format, because that is what actually - * arrives. A formatter that took its locale from the device parses nothing - * on a phone set to most of the world, and the failure is silent, the - * header is simply never understood and the skew is never learned. + * arrives. */ @Test fun `the header format round-trips`() { diff --git a/client/src/test/kotlin/io/flamenet/client/DisappearingControlTest.kt b/client/src/test/kotlin/io/flamenet/client/DisappearingControlTest.kt index 29b96ce..0a49d45 100644 --- a/client/src/test/kotlin/io/flamenet/client/DisappearingControlTest.kt +++ b/client/src/test/kotlin/io/flamenet/client/DisappearingControlTest.kt @@ -12,33 +12,44 @@ import kotlin.test.assertEquals /** * The seam where a peer's timer announcement becomes a stored fact (§6.11). - * - * Symmetric or worthless. If only the party who set the timer expires - * their copy, the feature is a lie: they believe their words are gone from a - * phone that is still holding them. So a `ttl` envelope from the other side has - * to be adopted, not merely offered, which is what this asserts. */ class DisappearingControlTest { - private lateinit var server: MockWebServer - @BeforeTest fun start() { server = MockWebServer().also { it.start() } } - @AfterTest fun stop() { server.shutdown() } + @BeforeTest fun start() { + server = MockWebServer().also { it.start() } + } + + @AfterTest fun stop() { + server.shutdown() + } private class MapStorage : Vault.Storage { private val records = mutableMapOf() + override fun read(name: String) = records[name] - override fun write(name: String, bytes: ByteArray) { records[name] = bytes } - override fun delete(name: String) { records.remove(name) } + + override fun write( + name: String, + bytes: ByteArray, + ) { + records[name] = bytes + } + + override fun delete(name: String) { + records.remove(name) + } + override fun names() = records.keys.toList() } private fun conversations(): Pair { val identity = DeviceIdentity.create() - val vault = Vault( - MapStorage(), - KeyGenerator.getInstance("AES").apply { init(256) }.generateKey(), - ).also { it.saveIdentity(identity) } + val vault = + Vault( + MapStorage(), + KeyGenerator.getInstance("AES").apply { init(256) }.generateKey(), + ).also { it.saveIdentity(identity) } val relay = RelayClient(server.url("/").toString(), "alice", "app pass word") return Conversations(identity, vault, relay) to vault } diff --git a/client/src/test/kotlin/io/flamenet/client/EmailUnverifiedTest.kt b/client/src/test/kotlin/io/flamenet/client/EmailUnverifiedTest.kt index a4536db..1f9e31d 100644 --- a/client/src/test/kotlin/io/flamenet/client/EmailUnverifiedTest.kt +++ b/client/src/test/kotlin/io/flamenet/client/EmailUnverifiedTest.kt @@ -11,20 +11,8 @@ import kotlin.test.assertTrue /** * A 403 that means "check your email", told apart from every other 403. - * - * Without this it is indistinguishable from a credential problem. An - * unverified account arrives as a bare 403; `RelayAuth` logs "minting failed" - * and falls back to Basic, which the e2e routes refuse. The person then sees - * calls that do not work and an inbox that does nothing, and nothing anywhere - * says the remedy is a code sitting in their inbox. - * - * The status alone is not enough: 403 is also "that device belongs to - * another account". Telling somebody to check their email for that would send - * them looking for a message that was never sent, so the relay's own `code` is - * what decides, not the number. */ class EmailUnverifiedTest { - private lateinit var server: MockWebServer @BeforeTest fun start() { @@ -36,34 +24,41 @@ class EmailUnverifiedTest { server.shutdown() } - private fun relay() = RelayClient( - server.url("/").toString(), username = "ada", appPassword = "credential") + private fun relay() = RelayClient(server.url("/").toString(), username = "ada", appPassword = "credential") - private fun refuse(code: String?, status: Int = 403) { - val body = if (code == null) """{"message":"no"}""" - else """{"code":"$code","message":"Check your email and enter the code."}""" + private fun refuse( + code: String?, + status: Int = 403, + ) { + val body = + if (code == null) { + """{"message":"no"}""" + } else { + """{"code":"$code","message":"Check your email and enter the code."}""" + } server.enqueue( - MockResponse().setResponseCode(status) + MockResponse() + .setResponseCode(status) .setHeader("Content-Type", "application/json") - .setBody(body) + .setBody(body), ) } @Test fun `an unverified account is its own failure, not a generic 403`() { refuse("email_unverified") - val failure = assertFailsWith { - relay().listDevices(1) - } + val failure = + assertFailsWith { + relay().listDevices(1) + } assertTrue( failure.body.contains("code"), - "the relay's sentence should survive: ${failure.body}" + "the relay's sentence should survive: ${failure.body}", ) } /** - * The distinction that matters. A device conflict is also a 403 and must - * NOT be reported as an unverified address. + * The distinction that matters. */ @Test fun `another 403 is not mistaken for an unverified address`() { @@ -83,9 +78,10 @@ class EmailUnverifiedTest { @Test fun `an unparseable 403 body does not crash`() { server.enqueue( - MockResponse().setResponseCode(403) + MockResponse() + .setResponseCode(403) .setHeader("Content-Type", "text/html") - .setBody("a proxy wrote this") + .setBody("a proxy wrote this"), ) assertFailsWith { relay().listDevices(1) } } diff --git a/client/src/test/kotlin/io/flamenet/client/FederatedReceiveTest.kt b/client/src/test/kotlin/io/flamenet/client/FederatedReceiveTest.kt index 7d3254e..86eff40 100644 --- a/client/src/test/kotlin/io/flamenet/client/FederatedReceiveTest.kt +++ b/client/src/test/kotlin/io/flamenet/client/FederatedReceiveTest.kt @@ -20,25 +20,24 @@ import kotlin.test.assertTrue /** * Messages that crossed a relay boundary. - * - * The relay records no sender for one of these, deliberately: it was not - * told who wrote it and must not invent an attribution. So `from_user` arrives - * as 0 and the only thing naming the sender is the address sealed inside the - * blob. Every assertion here is about what happens to a message with no - * trustworthy sender on the transport, because the failure mode is not a - * crash, it is every federated message from everybody silently piling into one - * nameless thread, or worse, into a local contact's. - * - * Real ciphertext throughout: a real peer identity, a real ratchet, a real - * seal. Only the relay's HTTP is a mock. */ class FederatedReceiveTest { - private class MapStorage : Vault.Storage { val records = LinkedHashMap() + override fun read(name: String) = records[name] - override fun write(name: String, bytes: ByteArray) { records[name] = bytes } - override fun delete(name: String) { records.remove(name) } + + override fun write( + name: String, + bytes: ByteArray, + ) { + records[name] = bytes + } + + override fun delete(name: String) { + records.remove(name) + } + override fun names() = records.keys.toList() } @@ -48,47 +47,58 @@ class FederatedReceiveTest { @BeforeTest fun start() { server = MockWebServer().also { it.start() } - vault = Vault(MapStorage(), - KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()) + vault = + Vault( + MapStorage(), + KeyGenerator.getInstance("AES").apply { init(256) }.generateKey(), + ) me = DeviceIdentity.create() } @AfterTest fun stop() = server.shutdown() - private fun conversations() = - Conversations(me, vault, RelayClient(server.url("/").toString(), "a", "b")) + private fun conversations() = Conversations(me, vault, RelayClient(server.url("/").toString(), "a", "b")) - private fun json(body: String) = MockResponse() - .setResponseCode(200).setHeader("Content-Type", "application/json").setBody(body) + private fun json(body: String) = + MockResponse() + .setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody(body) private fun myBundle(): PeerBundle { val opk = me.generateOneTimePreKeys(1).first() - return PeerBundle.fromWire(me.registrationWire().put("opk", - JSONObject().put("id", opk.id).put("pub", Wire.b64(opk.publicKey)))) + return PeerBundle.fromWire( + me.registrationWire().put( + "opk", + JSONObject().put("id", opk.id).put("pub", Wire.b64(opk.publicKey)), + ), + ) } /** * One envelope as a federated deposit looks on our inbox: no sender at all. - * - * `from_user: 0` and an empty `from_device` are not laziness in the - * fixture, they are exactly what `federationDeposit` writes, and a test - * that filled them in would be testing the local path with extra steps. */ - private fun deposited(id: Long, payload: String) = - json("""{"messages":[{"id":$id,"from_user":0,"from_device":"","payload":"$payload"}]}""") + private fun deposited( + id: Long, + payload: String, + ) = json("""{"messages":[{"id":$id,"from_user":0,"from_device":"","payload":"$payload"}]}""") /** A peer on another relay, writing to us for the first time. */ - private fun remotePeer(address: String, text: String): Pair { + private fun remotePeer( + address: String, + text: String, + ): Pair { val peer = DeviceIdentity.create() val session = E2ESession.initiate(peer, myBundle()) - val blob = SealedSender.seal( - envelope = session.encrypt(text), - fromUser = 12, // their row number, meaningless here - fromDevice = peer.deviceId, - toDevice = me.deviceId, - recipientIKdh = me.identityDHPublic, - fromAddress = address, - ) + val blob = + SealedSender.seal( + envelope = session.encrypt(text), + fromUser = 12, // their row number, meaningless here + fromDevice = peer.deviceId, + toDevice = me.deviceId, + recipientIKdh = me.identityDHPublic, + fromAddress = address, + ) return peer to blob.toPayload() } @@ -107,32 +117,31 @@ class FederatedReceiveTest { assertEquals("hello from over there", received.text) assertEquals(theirAddress, received.fromAddress) assertEquals(peer.deviceId, received.fromDevice) - // The point of the whole test. Their `from_user` of 12 is a row - // number on their relay; adopting it here would file this under - // whoever user 12 happens to be on ours. + // The point of the whole test. assertEquals(0L, received.fromUser) } /** * The claim inside a seal is only a claim: anyone holding this device's - * published `IK_dh` can write any address they like there. The ratchet - * proves the DEVICE, so the two are bound first-use-wins, and a device - * that later claims somebody else's address keeps the one it was seen with. - * - * Without this, an established thread can be renamed out from under the - * person reading it, which is the whole value of a stable address undone. + * published `IK_dh` can write any address they like there. */ @Test fun `a device that changes its claimed address keeps the first one`() { val peer = DeviceIdentity.create() val session = E2ESession.initiate(peer, myBundle()) - fun sealed(address: String, text: String) = SealedSender.seal( - envelope = session.encrypt(text), - fromUser = 12, fromDevice = peer.deviceId, - toDevice = me.deviceId, recipientIKdh = me.identityDHPublic, - fromAddress = address, - ).toPayload() + fun sealed( + address: String, + text: String, + ) = SealedSender + .seal( + envelope = session.encrypt(text), + fromUser = 12, + fromDevice = peer.deviceId, + toDevice = me.deviceId, + recipientIKdh = me.identityDHPublic, + fromAddress = address, + ).toPayload() val conversations = conversations() server.enqueue(deposited(1, sealed(theirAddress, "first"))) @@ -143,8 +152,11 @@ class FederatedReceiveTest { val second = conversations.poll().single() assertEquals("second", second.text, "the message is genuine and still delivered") - assertEquals(theirAddress, second.fromAddress, - "a device keeps the address it was first bound to") + assertEquals( + theirAddress, + second.fromAddress, + "a device keeps the address it was first bound to", + ) } /** An ordinary local message is untouched by any of this. */ @@ -152,9 +164,13 @@ class FederatedReceiveTest { fun `a local message still has no address and keeps its user id`() { val peer = DeviceIdentity.create() val session = E2ESession.initiate(peer, myBundle()) - server.enqueue(json("""{"messages":[{"id":1,"from_user":7,""" + - """"from_device":"${peer.deviceId}","payload":"${ - session.encrypt("ordinary").toPayload()}"}]}""")) + server.enqueue( + json( + """{"messages":[{"id":1,"from_user":7,""" + + """"from_device":"${peer.deviceId}","payload":"${ + session.encrypt("ordinary").toPayload()}"}]}""", + ), + ) val received = conversations().poll().single() @@ -199,10 +215,7 @@ class FederatedReceiveTest { // MARK: - What an address is allowed to be /** - * A relay is a hostname, never a URL. An address is parsed from a seal - * written by somebody nobody authenticated, so accepting `https://x/y` here - * would let a stranger point this client at a path on a host of their - * choosing, and would do it from inside a message. + * A relay is a hostname, never a URL. */ @Test fun `an address carrying a URL is not an address`() { @@ -227,27 +240,28 @@ class FederatedReceiveTest { /** * A federated message cannot write control state keyed by user id. - * - * `consumeControl(0L, ...)` would poison user 0's delivery key and - * avatar, and user 0 is the id every unattributed message carries, so one - * stranger on another relay could break sealing for everybody. */ @Test fun `a remote sender cannot set a local delivery key`() { val peer = DeviceIdentity.create() val session = E2ESession.initiate(peer, myBundle()) val control = SealedSender.deliveryKeyEnvelope(ByteArray(32) { 9 }) - val blob = SealedSender.seal( - envelope = session.encrypt(control), - fromUser = 0, fromDevice = peer.deviceId, - toDevice = me.deviceId, recipientIKdh = me.identityDHPublic, - fromAddress = theirAddress, - ) + val blob = + SealedSender.seal( + envelope = session.encrypt(control), + fromUser = 0, + fromDevice = peer.deviceId, + toDevice = me.deviceId, + recipientIKdh = me.identityDHPublic, + fromAddress = theirAddress, + ) server.enqueue(deposited(1, blob.toPayload())) conversations().poll() - assertNull(vault.loadDelivery()?.peers?.get(0L), - "a federated sender must not write into the local keyspace") + assertNull( + vault.loadDelivery()?.peers?.get(0L), + "a federated sender must not write into the local keyspace", + ) } } diff --git a/client/src/test/kotlin/io/flamenet/client/GeoUnavailableTest.kt b/client/src/test/kotlin/io/flamenet/client/GeoUnavailableTest.kt index c18b8ae..640afdd 100644 --- a/client/src/test/kotlin/io/flamenet/client/GeoUnavailableTest.kt +++ b/client/src/test/kotlin/io/flamenet/client/GeoUnavailableTest.kt @@ -5,15 +5,8 @@ import kotlin.test.assertTrue /** * 451, the country gate (§ edge policy). - * - * The download site is deliberately NOT gated. Someone outside the - * served countries can read the page, install the app, choose a password, tap - * sign up, and only then does the edge answer 451, before the request ever - * reaches the relay. Reported as a status code, that reads as a broken app: - * nothing mentions their country, and it is not a failure they can retry past. */ class GeoUnavailableTest { - @Test fun `the message names the country, not the status code`() { val message = RelayException.Unavailable("").message.orEmpty() @@ -22,8 +15,7 @@ class GeoUnavailableTest { } /** - * It must also say what it is NOT. Someone who has just typed a password - * will otherwise assume they got it wrong and try again, and again. + * It must also say what it is NOT. */ @Test fun `the message rules out the account and the connection`() { diff --git a/client/src/test/kotlin/io/flamenet/client/GossipHeadSentTest.kt b/client/src/test/kotlin/io/flamenet/client/GossipHeadSentTest.kt index 2254029..eb910c1 100644 --- a/client/src/test/kotlin/io/flamenet/client/GossipHeadSentTest.kt +++ b/client/src/test/kotlin/io/flamenet/client/GossipHeadSentTest.kt @@ -15,81 +15,79 @@ import kotlin.test.assertTrue /** * §19.4, our view of the log has to actually go out. - * - * This was dead code on both apps. `Trust.headPayload` (and iOS's - * `keyLogHeadPayload`) built the envelope and nothing ever called it, while - * the *receiving* half was fully wired. The result is the failure that looks - * like success: every client verifies proofs, pins heads, and reports - * transparency working, and a relay serving two branches is never caught, - * because catching it requires two clients to COMPARE heads and no client ever - * offered one. Only the JS reference client sent it, so gossip worked web→app - * and in no other direction. - * - * The receive path already has tests. This asserts the half that did not: that - * a send carries the head, that it rides the ratchet rather than going out in - * the clear where the audited party could rewrite it, and that it is not - * repeated on every message. */ class GossipHeadSentTest { - private lateinit var server: MockWebServer - @BeforeTest fun start() { server = MockWebServer().also { it.start() } } - @AfterTest fun stop() { server.shutdown() } + @BeforeTest fun start() { + server = MockWebServer().also { it.start() } + } + + @AfterTest fun stop() { + server.shutdown() + } private class MapStorage : Vault.Storage { private val records = mutableMapOf() + override fun read(name: String) = records[name] - override fun write(name: String, bytes: ByteArray) { records[name] = bytes } - override fun delete(name: String) { records.remove(name) } + + override fun write( + name: String, + bytes: ByteArray, + ) { + records[name] = bytes + } + + override fun delete(name: String) { + records.remove(name) + } + override fun names() = records.keys.toList() } - private fun key(): SecretKey = - KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() + private fun key(): SecretKey = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() /** A real peer, published in the shape `PeerBundle.fromWire` parses. */ private val bob = DeviceIdentity.create() - private fun bobWire(): String = JSONObject() - .put("device_id", bob.deviceId) - .put("ik_dh", Wire.b64(bob.identityDHPublic)) - .put("ik_sig", Wire.b64(bob.identitySigningPublic)) - .put("spk_id", bob.signedPreKeyId) - .put("spk", Wire.b64(X25519.rawFromPublicKey(bob.signedPreKey.public))) - .put("spk_sig", Wire.b64(bob.signedPreKeySignature())) - .put("proto", 1) - .toString() + private fun bobWire(): String = + JSONObject() + .put("device_id", bob.deviceId) + .put("ik_dh", Wire.b64(bob.identityDHPublic)) + .put("ik_sig", Wire.b64(bob.identitySigningPublic)) + .put("spk_id", bob.signedPreKeyId) + .put("spk", Wire.b64(X25519.rawFromPublicKey(bob.signedPreKey.public))) + .put("spk_sig", Wire.b64(bob.signedPreKeySignature())) + .put("proto", 1) + .toString() - private fun json(body: String) = MockResponse() - .setResponseCode(200) - .setHeader("Content-Type", "application/json") - .setBody(body) + private fun json(body: String) = + MockResponse() + .setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody(body) /** * A relay that keeps no transparency log. - * - * Not incidental setup. Sending now asks for a head on its own, because - * key-log checking is automatic on any relay that serves one, so a queue - * of bodies matched purely by ORDER would hand the head request the device - * listing and fail on a parse error miles from the cause. Answering by path - * says what this fixture is: a relay from before §19, where gossip is the - * only part of transparency in play. */ private fun noKeyLog() { - server.dispatcher = object : okhttp3.mockwebserver.Dispatcher() { - override fun dispatch(request: okhttp3.mockwebserver.RecordedRequest): MockResponse { - val path = request.path.orEmpty() - return when { - // `contains`, not `startsWith`: every route is under the - // REST mount, so the recorded path begins with that prefix - // rather than with `/e2e`. - "/e2e/log/" in path -> MockResponse().setResponseCode(404) - path.contains("/e2e/messages") -> json("""{"ok":true}""") - else -> json("""{"devices":[${bobWire()}]}""") + server.dispatcher = + object : okhttp3.mockwebserver.Dispatcher() { + override fun dispatch(request: okhttp3.mockwebserver.RecordedRequest): MockResponse { + val path = request.path.orEmpty() + return when { + // `contains`, not `startsWith`: every route is under the + // REST mount, so the recorded path begins with that prefix + // rather than with `/e2e`. + "/e2e/log/" in path -> MockResponse().setResponseCode(404) + + path.contains("/e2e/messages") -> json("""{"ok":true}""") + + else -> json("""{"devices":[${bobWire()}]}""") + } } } - } } /** Alice, with a head already pinned, nothing is gossiped without one. */ @@ -103,8 +101,6 @@ class GossipHeadSentTest { return Conversations(identity, vault, relay) } - - /** Every payload POSTed to `/e2e/messages`, as the relay would see them. */ private fun postedPayloads(): List { val bodies = mutableListOf() @@ -127,18 +123,17 @@ class GossipHeadSentTest { val sent = alice(pinned = true).send(toUserId = 42, text = "an ordinary message") assertTrue(sent.failed.isEmpty(), "the send itself should succeed: ${sent.failed}") - // Two envelopes for the one device: the gossiped head, then the message. - // Before the fix this was one, and the head never left the phone. - assertEquals(2, postedPayloads().size, - "a first send to a contact must carry the §19.4 head alongside the message") + // Two envelopes for the one device: the gossiped head, then the + // message. + assertEquals( + 2, + postedPayloads().size, + "a first send to a contact must carry the §19.4 head alongside the message", + ) } /** - * In-band is the point. The relay is the party being audited, so - * it must not be able to read which head we hold nor rewrite it in transit. - * A head that appears in the clear in the POST body would let a forked - * relay answer each client with the branch that client already believes, - * which is exactly the attack this mechanism exists to catch. + * In-band is the point. */ @Test fun `the head is encrypted, never posted in the clear`() { @@ -151,10 +146,14 @@ class GossipHeadSentTest { // exactly like a test that checks everything. assertEquals(2, payloads.size, "expected the head and the message") for (payload in payloads) { - assertTrue("kt_head" !in payload, - "the gossiped head must ride the ratchet, not the envelope: $payload") - assertTrue("an ordinary message" !in payload, - "sanity: message text must not be in the clear either") + assertTrue( + "kt_head" !in payload, + "the gossiped head must ride the ratchet, not the envelope: $payload", + ) + assertTrue( + "an ordinary message" !in payload, + "sanity: message text must not be in the clear either", + ) } } @@ -166,8 +165,11 @@ class GossipHeadSentTest { conversations.send(toUserId = 42, text = "first") conversations.send(toUserId = 42, text = "second") - assertEquals(3, postedPayloads().size, - "the head goes once: head + first + second, not a head on each message") + assertEquals( + 3, + postedPayloads().size, + "the head goes once: head + first + second, not a head on each message", + ) } /** Nothing to gossip before a head has ever been verified and pinned. */ @@ -176,7 +178,10 @@ class GossipHeadSentTest { noKeyLog() alice(pinned = false).send(toUserId = 42, text = "an ordinary message") - assertEquals(1, postedPayloads().size, - "with no pin there is no view of the log to offer") + assertEquals( + 1, + postedPayloads().size, + "with no pin there is no view of the log to offer", + ) } } diff --git a/client/src/test/kotlin/io/flamenet/client/GroupNameTest.kt b/client/src/test/kotlin/io/flamenet/client/GroupNameTest.kt index 93d0d46..57e4507 100644 --- a/client/src/test/kotlin/io/flamenet/client/GroupNameTest.kt +++ b/client/src/test/kotlin/io/flamenet/client/GroupNameTest.kt @@ -19,37 +19,42 @@ import kotlin.test.assertTrue /** * Group names, after the relay stopped holding them. - * - * This was the largest single thing the server knew about anybody. Group - * membership it needs, to route. The NAME it never needed and never read, no - * push, no enforcement, no lookup, and it was sitting in a column in the - * clear: "Chemotherapy support", "Union organising", a sentence about somebody - * on a machine that can be dumped, seized or subpoenaed. - * - * So the name moved to the members, who already have a way to tell each other - * things the server cannot read. What these tests hold down is the pair of - * claims that makes that a real change rather than a rearrangement: the name - * never reaches the relay, and it still reaches the people in the group. */ class GroupNameTest { - private lateinit var server: MockWebServer - @BeforeTest fun start() { server = MockWebServer().also { it.start() } } - @AfterTest fun stop() { server.shutdown() } + @BeforeTest fun start() { + server = MockWebServer().also { it.start() } + } + + @AfterTest fun stop() { + server.shutdown() + } private class MapStorage : Vault.Storage { private val records = mutableMapOf() + override fun read(name: String) = records[name] - override fun write(name: String, bytes: ByteArray) { records[name] = bytes } - override fun delete(name: String) { records.remove(name) } + + override fun write( + name: String, + bytes: ByteArray, + ) { + records[name] = bytes + } + + override fun delete(name: String) { + records.remove(name) + } + override fun names() = records.keys.toList() } - private fun vault(): Vault = Vault( - MapStorage(), - KeyGenerator.getInstance("AES").apply { init(256) }.generateKey(), - ).also { it.saveIdentity(DeviceIdentity.create()) } + private fun vault(): Vault = + Vault( + MapStorage(), + KeyGenerator.getInstance("AES").apply { init(256) }.generateKey(), + ).also { it.saveIdentity(DeviceIdentity.create()) } private fun conversations(vault: Vault): Conversations { val identity = vault.loadIdentity() ?: DeviceIdentity.create() @@ -57,19 +62,17 @@ class GroupNameTest { return Conversations(identity, vault, relay) } - // -- What the relay is told ----------------------------------------------- + // -- What the relay is told + // -----------------------------------------------. /** - * Absent, not blank. A request that carried an empty `name` would - * still be a request in which the client had decided to trust the relay - * with the field; this asserts the field is not there at all, so there is - * no version of the server that could start writing it down again without - * a client change. + * Absent, not blank. */ @Test fun `creating a group sends no name to the relay`() { - server.enqueue(MockResponse().setBody( - """{"ok":true,"gid":"g1","name":"Group","owner":1,"members":[{"user_id":2}]}""")) + server.enqueue( + MockResponse().setBody("""{"ok":true,"gid":"g1","name":"Group","owner":1,"members":[{"user_id":2}]}"""), + ) val relay = RelayClient(server.url("/").toString(), "alice", "app pass word") relay.createGroup(listOf(2L)) @@ -79,13 +82,11 @@ class GroupNameTest { assertTrue(body.contains("\"members\""), "the roster is missing: $body") } - // -- What the members are told -------------------------------------------- + // -- What the members are told + // --------------------------------------------. /** - * Every group envelope repeats the name. The announcement sent at - * creation only reaches who was online and only the devices that existed - * then; a phone set up later would otherwise sit on the relay's placeholder - * for ever, because there is nobody left to ask. + * Every group envelope repeats the name. */ @Test fun `a group message carries the group name`() { @@ -103,21 +104,17 @@ class GroupNameTest { } /** - * The announcement rides `delivery_key` precisely because that kind is - * old. A new `group_name` kind would arrive on every phone running v5 as - * a bubble full of raw JSON: those builds recognise control traffic by kind - * and read the `ctl` flag with `optBoolean`, which does not coerce the - * number §6.10 specifies. This asserts the envelope is still a well-formed - * delivery-key message, the half a v5 phone acts on, with the name as - * extra fields it will ignore. + * The announcement rides `delivery_key` precisely because that kind is old. */ @Test fun `the announcement is a delivery key envelope an older build still understands`() { val key = ByteArray(32) { it.toByte() } val wire = SealedSender.deliveryKeyEnvelope(key, groupId = "g1", groupName = "Book club") - assertTrue(wire.startsWith("""{"fnc":1,"kind":"delivery_key""""), - "the marker or kind moved, so an older build would render this: $wire") + assertTrue( + wire.startsWith("""{"fnc":1,"kind":"delivery_key""""), + "the marker or kind moved, so an older build would render this: $wire", + ) val json = JSONObject(wire) assertEquals("delivery_key", json.optString("kind")) assertTrue(json.optString("key").isNotEmpty(), "the key an older build reads is gone") @@ -133,45 +130,44 @@ class GroupNameTest { assertFalse(wire.contains("gid")) } - // -- What is believed ------------------------------------------------------ + // -- What is believed + // ------------------------------------------------------. /** - * A claim, not a fact, and this layer must not adopt it. Anyone who - * can reach us can send one of these naming any gid they like. Adopting on - * the sender's word would put whatever a stranger wants into somebody's - * chat list, under a group heading the reader has no reason to doubt. The - * roster lives a layer up, so the check does too, `Session.drainGroupNames` - * is what turns a claim into a name. + * A claim, not a fact, and this layer must not adopt it. */ @Test fun `an announced name is recorded as a claim, not stored`() { val vault = vault() val conversations = conversations(vault) - val wire = SealedSender.deliveryKeyEnvelope( - ByteArray(32), groupId = "g1", groupName = "Everything is fine") + val wire = + SealedSender.deliveryKeyEnvelope(ByteArray(32), groupId = "g1", groupName = "Everything is fine") conversations.consumeControl(99L, Content.parse(wire)) assertNull(vault.groupName("g1"), "the name was adopted without a membership check") - assertEquals(listOf(Triple(99L, "g1", "Everything is fine")), - conversations.takeClaimedGroupNames()) + assertEquals( + listOf(Triple(99L, "g1", "Everything is fine")), + conversations.takeClaimedGroupNames(), + ) } /** Drained, so the same claim is not re-checked on every poll. */ @Test fun `claims are taken once`() { val conversations = conversations(vault()) - conversations.consumeControl(99L, Content.parse( - SealedSender.deliveryKeyEnvelope(ByteArray(32), groupId = "g1", groupName = "n"))) + conversations.consumeControl( + 99L, + Content.parse(SealedSender.deliveryKeyEnvelope(ByteArray(32), groupId = "g1", groupName = "n")), + ) assertEquals(1, conversations.takeClaimedGroupNames().size) assertTrue(conversations.takeClaimedGroupNames().isEmpty()) } /** - * The batch and the key still have to survive an envelope that also - * carries a group name. They are independent halves of the same message and - * a name must not be able to take either of them down with it. + * The batch and the key still have to survive an envelope that also carries + * a group name. */ @Test fun `a named announcement still delivers the delivery key`() { @@ -180,21 +176,22 @@ class GroupNameTest { val conversations = conversations(vault) val theirKey = ByteArray(32) { 9 } - conversations.consumeControl(42L, Content.parse( - SealedSender.deliveryKeyEnvelope(theirKey, groupId = "g1", groupName = "Book club"))) + conversations.consumeControl( + 42L, + Content.parse(SealedSender.deliveryKeyEnvelope(theirKey, groupId = "g1", groupName = "Book club")), + ) - assertTrue(vault.loadDelivery()!!.peers[42L]!!.contentEquals(theirKey), - "the delivery key was lost because the envelope also named a group") + assertTrue( + vault.loadDelivery()!!.peers[42L]!!.contentEquals(theirKey), + "the delivery key was lost because the envelope also named a group", + ) } - // -- Renaming -------------------------------------------------------------- + // -- Renaming + // --------------------------------------------------------------. /** - * Renaming is how a group gets its name back. Every group that - * existed before the relay dropped its names reads as the placeholder on - * every device, and nothing can recover the old one: the relay deleted its - * copy and no client had ever stored one. Names were previously fixed at - * creation, so this path did not exist and had to be built. + * Renaming is how a group gets its name back. */ @Test fun `a rename is stored and announced to the members`() { @@ -206,20 +203,17 @@ class GroupNameTest { vault.saveGroupName("g1", "Book club, Tuesdays") assertEquals("Book club, Tuesdays", vault.groupName("g1")) - // Asserted on the envelope rather than on delivery: the announcement - // is best-effort per member, and a test that required a member to be + // Asserted on the envelope rather than on delivery: the announcement is + // best-effort per member, and a test that required a member to be // reachable would be testing the fixture's relay, not the rename. - val wire = SealedSender.deliveryKeyEnvelope( - ByteArray(32), groupId = "g1", groupName = vault.groupName("g1")!!) + val wire = + SealedSender.deliveryKeyEnvelope(ByteArray(32), groupId = "g1", groupName = vault.groupName("g1")!!) assertEquals("Book club, Tuesdays", JSONObject(wire).optString("gname")) assertTrue(conversations.takeClaimedGroupNames().isEmpty()) } /** - * An announcement with no name is not sent at all. A group we have - * never been told the name of has nothing to announce, and an envelope - * carrying an empty `gname` would let a member overwrite a good name with - * a blank one. + * An announcement with no name is not sent at all. */ @Test fun `announcing a group with no known name sends nothing`() { @@ -227,25 +221,22 @@ class GroupNameTest { vault.saveDelivery(Vault.Delivery(key = ByteArray(32) { 3 }, published = true)) val conversations = conversations(vault) - // No throw, no request: it returns before building anything. If it did - // send, the MockWebServer with no queued response would fail the call. + // No throw, no request: it returns before building anything. conversations.announceGroupName("unknown-gid", listOf(2L)) assertEquals(0, server.requestCount) } /** * The sender decides how many claims to send, so the queue is capped. - * It is drained by a layer that may not run on every path, and an unbounded - * list whose growth a stranger controls is a slow leak with someone else's - * hand on the tap. */ @Test fun `the claim queue is bounded`() { val conversations = conversations(vault()) repeat(400) { i -> - conversations.consumeControl(99L, Content.parse( - SealedSender.deliveryKeyEnvelope( - ByteArray(32), groupId = "g$i", groupName = "n$i"))) + conversations.consumeControl( + 99L, + Content.parse(SealedSender.deliveryKeyEnvelope(ByteArray(32), groupId = "g$i", groupName = "n$i")), + ) } val drained = conversations.takeClaimedGroupNames() @@ -254,14 +245,11 @@ class GroupNameTest { assertEquals("g399", drained.last().second) } - // -- Surviving a reinstall ------------------------------------------------- + // -- Surviving a reinstall + // -------------------------------------------------. /** - * A restored backup must bring the group names with it. The relay - * used to answer with the name, so a reinstalled device got it back for - * free; it holds none now, which makes the device's copy the only copy. An - * export carrying the messages but not the names restores a chat list of - * groups all called "Group", the exact loss the export exists to stop. + * A restored backup must bring the group names with it. */ @Test fun `an exported backup carries group names into a fresh device`() { @@ -277,8 +265,7 @@ class GroupNameTest { } /** - * A backup is older than the device by definition. A rename made since - * the export must not be undone by restoring it. + * A backup is older than the device by definition. */ @Test fun `a restored backup does not overwrite a newer name`() { @@ -294,12 +281,8 @@ class GroupNameTest { } /** - * `Received` carries whether the sender was proven, because a caller - * that acts on `fromUser` has to be able to ask. An unproven device's - * sender id is a claim, the binding says it belongs to somebody else, - * so a roster check keyed on it asks "is the person they are pretending to - * be in this group", which answers yes for any member they name. - * `Session.receive` gates group-name adoption on this. + * `Received` carries whether the sender was proven, because a caller that + * acts on `fromUser` has to be able to ask. */ @Test fun `received messages say whether the sender was proven`() { @@ -309,21 +292,13 @@ class GroupNameTest { val proven = Conversations.Received(1L, 42L, "dev", Content("hi")) assertTrue(proven.senderProven) - val unproven = Conversations.Received( - 1L, 42L, "dev", Content("hi"), senderProven = false) + val unproven = + Conversations.Received(1L, 42L, "dev", Content("hi"), senderProven = false) assertFalse(unproven.senderProven) } /** * The announcement goes to our own account too. - * Fan-out excludes us everywhere else, so a second phone we own would be - * told by nobody: the relay has no name to serve it and the members only - * send to each other. - * - * Safe only because the fan-out loop already skips our own device - * ("never talk to ourselves"). This asserts the recipient list, which is - * the half this file owns; the device-level skip has its own guard in the - * send path. */ @Test fun `the announcement addresses our own account as well as the members`() { @@ -335,18 +310,19 @@ class GroupNameTest { val asked = mutableListOf() // Every send starts by listing the recipient's devices, so the paths // the relay is asked for name exactly who was addressed. - server.dispatcher = object : okhttp3.mockwebserver.Dispatcher() { - override fun dispatch(request: okhttp3.mockwebserver.RecordedRequest): - MockResponse { - val path = request.path.orEmpty() - Regex("/e2e/devices/(\\d+)").find(path)?.let { - asked += it.groupValues[1].toLong() + server.dispatcher = + object : okhttp3.mockwebserver.Dispatcher() { + override fun dispatch(request: okhttp3.mockwebserver.RecordedRequest): MockResponse { + val path = request.path.orEmpty() + Regex("/e2e/devices/(\\d+)").find(path)?.let { + asked += it.groupValues[1].toLong() + } + return MockResponse() + .setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody("""{"user_id":0,"devices":[]}""") } - return MockResponse().setResponseCode(200) - .setHeader("Content-Type", "application/json") - .setBody("""{"user_id":0,"devices":[]}""") } - } conversations.announceGroupName("g1", listOf(2L, 3L)) @@ -354,12 +330,11 @@ class GroupNameTest { assertTrue(2L in asked && 3L in asked, "a member was missed: $asked") } - // -- Sealed group invitations (§8.9b) -------------------------------------- + // -- Sealed group invitations (§8.9b) + // --------------------------------------. /** - * The invitation is the only way a member ever gets in. The relay - * holds neither the tag nor the group key, by construction, so somebody who - * is not told cannot fetch the group at all, there is nobody left to ask. + * The invitation is the only way a member ever gets in. */ @Test fun `an invitation carries the tag and the group key`() { @@ -367,8 +342,10 @@ class GroupNameTest { val key = SealedRoster.newGroupKey() val tag = SealedRoster.newTag() - conversations.consumeControlForTesting(7L, Content.parse( - SealedSender.sealedGroupEnvelope(ByteArray(32), "g1", tag, key, "Book club"))) + conversations.consumeControlForTesting( + 7L, + Content.parse(SealedSender.sealedGroupEnvelope(ByteArray(32), "g1", tag, key, "Book club")), + ) val invites = conversations.takeGroupInvitations() assertEquals(1, invites.size) @@ -379,20 +356,23 @@ class GroupNameTest { } /** - * A group key that is not 32 bytes is not a group key. Storing one would - * make every roster for that group fail to open, which reads as a group - * with no members rather than as a bad invitation. + * A group key that is not 32 bytes is not a group key. */ @Test fun `an invitation with a malformed key is ignored`() { val conversations = conversations(vault()) - // Built by hand rather than through `sealedGroupEnvelope`, which - // only ever emits a well-formed key, the shape under test is one a - // hostile or broken sender produces, not one this engine can make. - fun b64(b: ByteArray) = java.util.Base64.getEncoder().encodeToString(b) - val wire = """{"fnc":1,"kind":"delivery_key","key":"${b64(ByteArray(32))}",""" + - """"gid":"g1","gname":"G","gtag":"${SealedRoster.newTag()}",""" + - """"gkey":"${b64(ByteArray(8))}"}""" + + // Built by hand rather than through `sealedGroupEnvelope`, which only + // ever emits a well-formed key, the shape under test is one a hostile + // or broken sender produces, not one this engine can make. + fun b64(b: ByteArray) = + java.util.Base64 + .getEncoder() + .encodeToString(b) + val wire = + """{"fnc":1,"kind":"delivery_key","key":"${b64(ByteArray(32))}",""" + + """"gid":"g1","gname":"G","gtag":"${SealedRoster.newTag()}",""" + + """"gkey":"${b64(ByteArray(8))}"}""" conversations.consumeControlForTesting(7L, Content.parse(wire)) @@ -403,16 +383,25 @@ class GroupNameTest { @Test fun `invitations are taken once`() { val conversations = conversations(vault()) - conversations.consumeControlForTesting(7L, Content.parse( - SealedSender.sealedGroupEnvelope( - ByteArray(32), "g1", SealedRoster.newTag(), - SealedRoster.newGroupKey(), "G"))) + conversations.consumeControlForTesting( + 7L, + Content.parse( + SealedSender.sealedGroupEnvelope( + ByteArray(32), + "g1", + SealedRoster.newTag(), + SealedRoster.newGroupKey(), + "G", + ), + ), + ) assertEquals(1, conversations.takeGroupInvitations().size) assertTrue(conversations.takeGroupInvitations().isEmpty()) } - // -- Where it is kept ------------------------------------------------------ + // -- Where it is kept + // ------------------------------------------------------. @Test fun `a name is kept, replaced and forgotten`() { @@ -425,8 +414,7 @@ class GroupNameTest { vault.saveGroupName("g1", "Book club, Tuesdays") assertEquals("Book club, Tuesdays", vault.groupName("g1")) - // Leaving a group forgets it. The one string the server was asked to - // stop holding should not outlive the membership that justified it. + // Leaving a group forgets it. vault.clearGroupName("g1") assertNull(vault.groupName("g1")) } diff --git a/client/src/test/kotlin/io/flamenet/client/HomeRecordRollbackTest.kt b/client/src/test/kotlin/io/flamenet/client/HomeRecordRollbackTest.kt index 753f30e..3aecebd 100644 --- a/client/src/test/kotlin/io/flamenet/client/HomeRecordRollbackTest.kt +++ b/client/src/test/kotlin/io/flamenet/client/HomeRecordRollbackTest.kt @@ -2,35 +2,26 @@ package io.flamenet.client import io.flamenet.e2e.Ed25519 import io.flamenet.e2e.FNID +import org.json.JSONArray +import org.json.JSONObject import java.security.SecureRandom import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertNull -import org.json.JSONArray -import org.json.JSONObject /** * A home record that is older than one already seen must be refused (§20.4). - * - * A signature alone does not stop a rollback, and both clients checked - * only the signature. An older home record is *signed*, the - * account holder published it, before they added a second relay. So a relay - * that wants to be somebody's only mailbox does not have to forge anything: it - * serves the record it saw first, that record verifies perfectly, and the - * sender delivers to one relay believing it is delivering to all of them. - * - * That is precisely the attack multi-homing exists to survive, and the serial - * is what was added to stop it. The relay refuses a lower serial on WRITE; - * until this, nothing refused one on READ, on either platform. */ class HomeRecordRollbackTest { - private val seed = ByteArray(32).also { SecureRandom().nextBytes(it) } private val fnid = FNID.address(Ed25519.fromSeed(seed).publicKey) /** A record the account holder really did sign. */ - private fun record(serial: Int, relays: List): JSONObject { + private fun record( + serial: Int, + relays: List, + ): JSONObject { val sig = Wire.b64(Ed25519.sign(seed, FNID.homesCanonical(fnid, serial, relays))) return JSONObject() .put("fnid", fnid) @@ -42,16 +33,17 @@ class HomeRecordRollbackTest { private val old = record(1, listOf("one.example")) private val current = record(4, listOf("one.example", "two.example")) - // ---- The attack ------------------------------------------------------ + // ---- The attack ------------------------------------------------------. /** - * The whole finding, in one assertion. Both records verify; only the - * serial separates them. + * The whole finding, in one assertion. */ @Test fun `an older record is refused once a newer one has been seen`() { - assertNotNull(AccountIdentity.verifiedRelays(old), - "the old record does not verify, so this test proves nothing") + assertNotNull( + AccountIdentity.verifiedRelays(old), + "the old record does not verify, so this test proves nothing", + ) assertNotNull(AccountIdentity.verifiedRelays(current)) assertNull( @@ -71,8 +63,7 @@ class HomeRecordRollbackTest { } /** - * Equal is fine. Re-reading the same record is the ordinary case, and - * refusing it would break every second lookup. + * Equal is fine. */ @Test fun `the same serial twice is not a rollback`() { @@ -86,24 +77,23 @@ class HomeRecordRollbackTest { assertNotNull(AccountIdentity.verifiedRelays(current, floor = null)) } - // ---- The floor cannot be poisoned ------------------------------------ + // ---- The floor cannot be poisoned ------------------------------------. /** - * A serial is only reported for a record that VERIFIES. Otherwise a - * hostile relay could hand over an unsigned record carrying serial - * 2,000,000,000, have the client raise its floor to it, and lock out every - * record for ever, turning the rollback defence into a denial of - * service against the account holder. + * A serial is only reported for a record that VERIFIES. */ @Test fun `an unsigned record reports no serial to raise the floor with`() { - val forged = JSONObject() - .put("fnid", fnid) - .put("serial", Int.MAX_VALUE) - .put("relays", JSONArray(listOf("attacker.example"))) - .put("sig", Wire.b64(ByteArray(64))) - assertNull(AccountIdentity.verifiedSerial(forged), - "a forged record could raise the floor and lock out every real one") + val forged = + JSONObject() + .put("fnid", fnid) + .put("serial", Int.MAX_VALUE) + .put("relays", JSONArray(listOf("attacker.example"))) + .put("sig", Wire.b64(ByteArray(64))) + assertNull( + AccountIdentity.verifiedSerial(forged), + "a forged record could raise the floor and lock out every real one", + ) assertNull(AccountIdentity.verifiedRelays(forged, floor = 1)) } @@ -113,30 +103,37 @@ class HomeRecordRollbackTest { val otherSeed = ByteArray(32).also { SecureRandom().nextBytes(it) } val relays = listOf("attacker.example") val sig = Wire.b64(Ed25519.sign(otherSeed, FNID.homesCanonical(fnid, 9, relays))) - val impostor = JSONObject() - .put("fnid", fnid).put("serial", 9) - .put("relays", JSONArray(relays)).put("sig", sig) + val impostor = + JSONObject() + .put("fnid", fnid) + .put("serial", 9) + .put("relays", JSONArray(relays)) + .put("sig", sig) assertNull(AccountIdentity.verifiedSerial(impostor)) assertNull(AccountIdentity.verifiedRelays(impostor, floor = null)) } /** - * The relay list is part of what is signed, so swapping it invalidates - * the record, a relay cannot keep a valid signature and quietly drop the - * other homes out of the list. + * The relay list is part of what is signed, so swapping it invalidates the + * record, a relay cannot keep a valid signature and quietly drop the other + * homes out of the list. */ @Test fun `the relay list cannot be edited without breaking the signature`() { - val tampered = record(4, listOf("one.example", "two.example")) - .put("relays", JSONArray(listOf("one.example"))) - assertNull(AccountIdentity.verifiedRelays(tampered), - "a relay dropped the other homes and kept a valid signature") + val tampered = + record(4, listOf("one.example", "two.example")) + .put("relays", JSONArray(listOf("one.example"))) + assertNull( + AccountIdentity.verifiedRelays(tampered), + "a relay dropped the other homes and kept a valid signature", + ) } /** A negative or absent serial is malformed, not a floor of zero. */ @Test fun `a malformed serial is refused rather than treated as zero`() { - assertNull(AccountIdentity.verifiedRelays( - JSONObject().put("fnid", fnid).put("relays", JSONArray()).put("sig", "x"))) + assertNull( + AccountIdentity.verifiedRelays(JSONObject().put("fnid", fnid).put("relays", JSONArray()).put("sig", "x")), + ) } } diff --git a/client/src/test/kotlin/io/flamenet/client/KeyChangeDescriptionTest.kt b/client/src/test/kotlin/io/flamenet/client/KeyChangeDescriptionTest.kt index fbd1fa9..9ccbc86 100644 --- a/client/src/test/kotlin/io/flamenet/client/KeyChangeDescriptionTest.kt +++ b/client/src/test/kotlin/io/flamenet/client/KeyChangeDescriptionTest.kt @@ -6,7 +6,6 @@ import kotlin.test.assertTrue /** S4's "name what changed" formatter, pinned so the sentence can't drift. */ class KeyChangeDescriptionTest { - @Test fun `each kind names itself`() { assertTrue(KeyChangeDescription.describe("publish", null).startsWith("A new device key was added")) assertTrue(KeyChangeDescription.describe("expire", null).startsWith("A device key expired")) diff --git a/client/src/test/kotlin/io/flamenet/client/LiveConversationTest.kt b/client/src/test/kotlin/io/flamenet/client/LiveConversationTest.kt index 40bc177..e15bf11 100644 --- a/client/src/test/kotlin/io/flamenet/client/LiveConversationTest.kt +++ b/client/src/test/kotlin/io/flamenet/client/LiveConversationTest.kt @@ -1,8 +1,8 @@ package io.flamenet.client -import org.junit.jupiter.api.Assumptions.assumeTrue import io.flamenet.e2e.DeviceIdentity import io.flamenet.e2e.Vault +import org.junit.jupiter.api.Assumptions.assumeTrue import javax.crypto.KeyGenerator import javax.crypto.SecretKey import kotlin.test.Test @@ -11,37 +11,17 @@ import kotlin.test.assertTrue /** * Two accounts, one relay, a real conversation. - * - * This is the test the whole stack exists to pass. Everything below it, - * X3DH, the ratchet, the vault, the transport, has been proven in isolation; - * this is the first thing that proves they were assembled correctly. - * - * Opt-in via `FN_LIVE_FLEET`, and pointed at the throwaway fleet. It creates - * accounts and sends mail. - * - * FN_LIVE_FLEET=https://relay1.flamenet.io ./gradlew :client:test */ class LiveConversationTest { - private val fleet: String? = System.getenv("FN_LIVE_FLEET") - /** - * Skips are announced, not silent. `return` from an unmet - * precondition makes a test that did nothing report as PASSED, and a suite - * of those is indistinguishable from a suite that works. `assumeTrue` - * reports SKIPPED, which is the truth. `LiveShapesTest` has always done it - * this way; these files did not, so 6 tests here reported green on every - * pipeline while running nothing at all. + * Skips are announced, not silent. */ private fun needFnLiveFleet() { LiveTarget.refuseProduction(fleet) assumeTrue(fleet != null, "set FN_LIVE_FLEET to run this against a relay") - // The passphrase comes from the environment with no fallback. This - // file carried a hard-coded literal, a published password, used to - // create REAL accounts, and `TestSafetyTest` could - // not see it, because its check matched an ASSIGNMENT and this was an - // ARGUMENT. Both are fixed together. + // The passphrase comes from the environment with no fallback. assumeTrue( LiveTarget.passphrase().isNotEmpty(), "set FN_LIVE_PASSPHRASE, these tests create real accounts and this " + @@ -62,18 +42,34 @@ class LiveConversationTest { private class MapStorage : Vault.Storage { private val records = LinkedHashMap() + override fun read(name: String) = records[name] - override fun write(name: String, bytes: ByteArray) { records[name] = bytes } - override fun delete(name: String) { records.remove(name) } + + override fun write( + name: String, + bytes: ByteArray, + ) { + records[name] = bytes + } + + override fun delete(name: String) { + records.remove(name) + } + override fun names() = records.keys.toList() } private fun key(): SecretKey = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() - private fun signUp(base: String, tag: String, preKeys: Int = 20): Person { + private fun signUp( + base: String, + tag: String, + preKeys: Int = 20, + ): Person { val handle = "live$tag" + (System.nanoTime() % 10_000_000) - val account = RelayAccounts(base) - .signUp(handle, "$handle@example.invalid", LiveTarget.passphrase()) + val account = + RelayAccounts(base) + .signUp(handle, "$handle@example.invalid", LiveTarget.passphrase()) val identity = DeviceIdentity.create() val vault = Vault(MapStorage(), key()) @@ -97,20 +93,8 @@ class LiveConversationTest { /** * What a person would see, which is what every assertion below means. - * - * `poll` returns control traffic too, and since key-log checking - * became automatic there is reliably some: the first send to a contact now - * gossips this device's view of the transparency log (§19.4), because - * enforcement sets the head pin that gossip had nothing to send without. - * That envelope is consumed by the client and never shown, and asserting on - * the raw list counted it as a message, so these tests failed on a change - * that made the product more correct, not less. - * - * The app has always filtered this way; the tests were reading a layer - * lower than the thing they were describing. */ - private fun Conversations.readable(): List = - poll().filter { it.content.isRenderable } + private fun Conversations.readable(): List = poll().filter { it.content.isRenderable } @Test fun `two people exchange messages through a real relay`() { @@ -119,8 +103,7 @@ class LiveConversationTest { val alice = signUp(base, "a") val bob = signUp(base, "b") - // Alice opens the conversation. Bob has never heard of her, so this is - // a prekey envelope and it has to stand on its own. + // Alice opens the conversation. val sent = alice.conversations.send(bob.userId, "hello from the Android engine 👋") assertTrue(sent.reachedSomeone, "nothing was delivered: ${sent.failed}") assertEquals(listOf(bob.identity.deviceId), sent.delivered) @@ -141,17 +124,26 @@ class LiveConversationTest { // Several turns, so the ratchet is exercised past the handshake. for (turn in 1..3) { alice.conversations.send(bob.userId, "alice $turn") - assertEquals("alice $turn", bob.conversations.readable().single().text) + assertEquals( + "alice $turn", + bob.conversations + .readable() + .single() + .text, + ) bob.conversations.send(alice.userId, "bob $turn") - assertEquals("bob $turn", alice.conversations.readable().single().text) + assertEquals( + "bob $turn", + alice.conversations + .readable() + .single() + .text, + ) } } /** - * The watermark must not re-deliver. A client that re-reads its inbox - * from zero shows every message again on every poll, and the second - * decryption fails because the ratchet has moved, so the user sees their - * history replaced by errors. + * The watermark must not re-deliver. */ @Test fun `polling twice does not deliver the same message twice`() { @@ -167,10 +159,6 @@ class LiveConversationTest { /** * A person signed in on two devices must receive on both. - * - * This is the failure that looks fine in every test where everyone has - * one device: encrypting to "the" recipient device delivers to whichever - * came back first and silently drops the rest. */ @Test fun `a message reaches every device a person has`() { @@ -192,16 +180,18 @@ class LiveConversationTest { val sent = alice.conversations.send(bob.userId, "to both of your devices") assertEquals(2, sent.delivered.size, "only reached ${sent.delivered}; failed: ${sent.failed}") - assertEquals("to both of your devices", bob.conversations.readable().single().text) + assertEquals( + "to both of your devices", + bob.conversations + .readable() + .single() + .text, + ) assertEquals("to both of your devices", secondConversations.readable().single().text) } /** * The relay stores ciphertext and nothing else. - * - * Worth asserting rather than assuming: the entire claim this product - * makes is that the operator cannot read the mail, and the cheapest way to - * break it is a client that sends a plaintext field "just for the preview". */ @Test fun `the relay never sees the plaintext`() { @@ -223,12 +213,6 @@ class LiveConversationTest { /** * An image, end to end. - * - * This is the exact path that was 401 on iOS for months: the - * attachment routes were sending the account password, and the relay - * refuses that on `/e2e/…`. The upload here goes through the same - * device-scoped token and per-request signature as everything else, and - * this test is what says so. */ @Test fun `an image crosses end to end and the relay cannot read it`() { @@ -242,8 +226,8 @@ class LiveConversationTest { val marker = "PNG-MARKER-7f3c-not-encrypted".toByteArray() val picture = marker + ByteArray(4096) { (it % 251).toByte() } - val sent = alice.conversations.sendAttachment( - bob.userId, picture, "image/png", width = 64, height = 48) + val sent = + alice.conversations.sendAttachment(bob.userId, picture, "image/png", width = 64, height = 48) assertTrue(sent.reachedSomeone, "nothing delivered: ${sent.failed}") val received = bob.conversations.readable().single() @@ -257,19 +241,14 @@ class LiveConversationTest { // What the relay stores must not contain the marker. val stored = bob.relay.downloadAttachment(ref.id) - assertTrue(!String(stored, Charsets.ISO_8859_1).contains("PNG-MARKER"), - "the relay is holding the plaintext") + assertTrue( + !String(stored, Charsets.ISO_8859_1).contains("PNG-MARKER"), + "the relay is holding the plaintext", + ) } /** * A group, against the real relay. - * - * This is the shape the iOS client got wrong: it required a `name` on - * each member, and the relay has never sent one, it has no user table - * and deliberately will not grow one, because a relay caching display names - * is a relay that leaks them. Every group decode failed, so groups were - * completely broken while looking implemented. Asserting the member shape - * against live bytes is the point of this test. */ @Test fun `a group is created, described and messaged`() { @@ -281,33 +260,37 @@ class LiveConversationTest { val group = alice.relay.createGroup(listOf(bob.userId, carol.userId)) assertTrue(group.id.isNotEmpty(), "no group id came back") - // The relay does not know what the group is called, and this - // asserts it against a live one. It used to store the name in the clear - // in a column it never read, the single most revealing string it held - // and now answers with the same generic placeholder for every group. - // The real name travels between members inside their ratchets. - assertEquals("Group", group.name, - "the relay is serving a real group name again") + // The relay does not know what the group is called, and this asserts it + // against a live one. + assertEquals( + "Group", + group.name, + "the relay is serving a real group name again", + ) assertEquals(alice.userId, group.owner) - // Creator plus the two invited. If this is 2, the relay does not add - // the owner and the client must. - assertTrue(group.memberIds.containsAll(listOf(bob.userId, carol.userId)), - "members were ${group.memberIds}") + // Creator plus the two invited. + assertTrue( + group.memberIds.containsAll(listOf(bob.userId, carol.userId)), + "members were ${group.memberIds}", + ) - assertTrue(alice.relay.listGroups().any { it.id == group.id }, - "the group did not appear in the list") + assertTrue( + alice.relay.listGroups().any { it.id == group.id }, + "the group did not appear in the list", + ) - // The relay does no fan-out. A group message is still pairwise - // encryption, one ratchet per recipient device, that is the honest - // cost of the relay not being able to read anything. + // The relay does no fan-out. val sent = alice.conversations.sendToGroup(group, "hello everyone", alice.userId) assertTrue(sent.reachedSomeone, "nothing delivered: ${sent.failed}") for (member in listOf(bob, carol)) { val got = member.conversations.readable().single() assertEquals("hello everyone", got.text) - assertEquals(group.id, got.content.groupId, - "the message did not carry group routing, so it would file under the sender") + assertEquals( + group.id, + got.content.groupId, + "the message did not carry group routing, so it would file under the sender", + ) } // Membership is editable, and leaving works. diff --git a/client/src/test/kotlin/io/flamenet/client/LiveFileTransferTest.kt b/client/src/test/kotlin/io/flamenet/client/LiveFileTransferTest.kt index 858a40f..da2fb0e 100644 --- a/client/src/test/kotlin/io/flamenet/client/LiveFileTransferTest.kt +++ b/client/src/test/kotlin/io/flamenet/client/LiveFileTransferTest.kt @@ -5,6 +5,7 @@ import io.flamenet.e2e.DeviceIdentity import io.flamenet.e2e.FilesIdentity import io.flamenet.e2e.Label import io.flamenet.e2e.Vault +import org.junit.jupiter.api.Assumptions.assumeTrue import java.io.File import javax.crypto.KeyGenerator import javax.crypto.SecretKey @@ -13,38 +14,16 @@ import kotlin.test.assertContentEquals import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertTrue -import org.junit.jupiter.api.Assumptions.assumeTrue /** * A document leaves one person's vault and arrives in another's, through a real * relay. - * - * This is the seam nothing else covers. `FileShareWireTest` proves the - * envelope's shape, `LocalVaultTest` proves the vault at both ends, and neither - * of them puts a byte on a wire. What is only provable here is that the label - * survives an actual upload, an actual envelope, an actual poll and an actual - * save, the four steps between two vaults that each look right on their own. - * - * The classification is the only thing that survives the trip. The - * receiving phone files the document at whatever number arrives, because there - * is nothing else to consult and no server to ask. A label lost in transit does - * not error anywhere: the file simply arrives Unclassified and gets filed that - * way, on the most permissive setting it could possibly have. That is what this - * test is really watching for. - * - * Opt-in via `FN_LIVE_FLEET`, and pointed at a throwaway relay. It creates - * accounts and uploads blobs. - * - * FN_LIVE_FLEET=http://127.0.0.1:8080 ./gradlew :client:test */ class LiveFileTransferTest { - private val fleet: String? = System.getenv("FN_LIVE_FLEET") /** - * Announced, not silent. A `return` from an unmet precondition makes a - * test that did nothing report as PASSED, see the note in - * `LiveConversationTest`, which is where that lesson was paid for. + * Announced, not silent. */ private fun needFnLiveFleet() { LiveTarget.refuseProduction(fleet) @@ -58,20 +37,30 @@ class LiveFileTransferTest { private class MapStorage : Vault.Storage { private val records = LinkedHashMap() + override fun read(name: String) = records[name] - override fun write(name: String, bytes: ByteArray) { records[name] = bytes } - override fun delete(name: String) { records.remove(name) } + + override fun write( + name: String, + bytes: ByteArray, + ) { + records[name] = bytes + } + + override fun delete(name: String) { + records.remove(name) + } + override fun names() = records.keys.toList() } /** Nothing is linked in these tests, so nothing should ever ask. */ private object NoReferences : LocalVault.ReferenceResolver { - override fun exists(bookmark: String) = - error("a vaulted file asked for a referenced one") - override fun read(bookmark: String): ByteArray = - error("a vaulted file asked for a referenced one") - override fun size(bookmark: String) = - error("a vaulted file asked for a referenced one") + override fun exists(bookmark: String) = error("a vaulted file asked for a referenced one") + + override fun read(bookmark: String): ByteArray = error("a vaulted file asked for a referenced one") + + override fun size(bookmark: String) = error("a vaulted file asked for a referenced one") } private fun key(): SecretKey = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() @@ -87,10 +76,14 @@ class LiveFileTransferTest { val root: File, ) - private fun signUp(base: String, tag: String): Person { + private fun signUp( + base: String, + tag: String, + ): Person { val handle = "livef$tag" + (System.nanoTime() % 10_000_000) - val account = RelayAccounts(base) - .signUp(handle, "$handle@example.invalid", LiveTarget.passphrase()) + val account = + RelayAccounts(base) + .signUp(handle, "$handle@example.invalid", LiveTarget.passphrase()) val identity = DeviceIdentity.create() val vault = Vault(MapStorage(), key()) @@ -102,10 +95,20 @@ class LiveFileTransferTest { vault.saveIdentity(identity) // A real Files identity, so the label keys are the ones the app derives - // 1,000 rounds rather than 650,000 because this asserts the format, - // not the work factor. - val raw = FilesIdentity.rawFromPkcs8(io.flamenet.e2e.X25519.generate().private.encoded) - val root = File.createTempFile("livevault$tag", "").let { it.delete(); it.mkdirs(); it } + // 1,000 rounds rather than 650,000 because this asserts the format, not + // the work factor. + val raw = + FilesIdentity.rawFromPkcs8( + io.flamenet.e2e.X25519 + .generate() + .private.encoded, + ) + val root = + File.createTempFile("livevault$tag", "").let { + it.delete() + it.mkdirs() + it + } return Person( handle = handle, @@ -118,8 +121,7 @@ class LiveFileTransferTest { ) } - private fun Conversations.readable(): List = - poll().filter { it.content.isRenderable } + private fun Conversations.readable(): List = poll().filter { it.content.isRenderable } @Test fun `a classified document crosses between two vaults with its label intact`() { @@ -138,47 +140,63 @@ class LiveFileTransferTest { val plaintext = alice.files.contents(filed) assertContentEquals(bytes, plaintext, "the vault handed back the wrong bytes") - val sent = alice.conversations.sendAttachment( - bob.userId, plaintext, "application/octet-stream", Attachment.FILE, - filename = "Q3 Forecast.txt", - classification = label.classification.toInt(), - compartments = label.compartments.toList(), - ) + val sent = + alice.conversations.sendAttachment( + bob.userId, + plaintext, + "application/octet-stream", + Attachment.FILE, + filename = "Q3 Forecast.txt", + classification = label.classification.toInt(), + compartments = label.compartments.toList(), + ) assertTrue(sent.reachedSomeone, "nothing was delivered: ${sent.failed}") // In on Bob's side. val received = bob.conversations.readable() assertEquals(1, received.size, "Bob's inbox had ${received.size} items") - val ref = assertNotNull(received[0].content.attachment, - "the attachment did not survive the envelope") + val ref = + assertNotNull( + received[0].content.attachment, + "the attachment did not survive the envelope", + ) assertEquals(Attachment.FILE, ref.kind) assertEquals("Q3 Forecast.txt", ref.filename, "the document's own name was lost") // The assertion this test exists for. - assertEquals(3, ref.classification, - "the classification was lost in transit, the file would be filed Unclassified") - assertEquals(listOf(1, 2), ref.compartments.sorted(), - "the compartments were lost, the file would be filed outside its areas") + assertEquals( + 3, + ref.classification, + "the classification was lost in transit, the file would be filed Unclassified", + ) + assertEquals( + listOf(1, 2), + ref.compartments.sorted(), + "the compartments were lost, the file would be filed outside its areas", + ) // And the bytes come back off the relay. val fetched = bob.conversations.fetchAttachment(ref) assertContentEquals(bytes, fetched, "the bytes did not survive the round trip") - // Bob saves it, at the label it arrived with, never one his phone chose. - val saved = bob.files.add( - fetched, ref.filename, - Label(ref.classification.toShort(), ref.compartments.toSet()), - ) + // Bob saves it, at the label it arrived with, never one his phone + // chose. + val saved = + bob.files.add( + fetched, + ref.filename, + Label(ref.classification.toShort(), ref.compartments.toSet()), + ) val entry = bob.files.list(null).single() assertEquals("Q3 Forecast.txt", entry.name) - assertEquals(label, entry.label, - "the document was filed under a different label than it was sent at") + assertEquals( + label, + entry.label, + "the document was filed under a different label than it was sent at", + ) assertContentEquals(bytes, bob.files.contents(saved)) // Two vaults, two identities, two different keys for one label. - // The label key is derived from the identity, so the same - // classification does NOT mean the same key, which is what stops a - // stolen container from one phone opening anything on the other. assertTrue( !alice.files.labelKey(label).contentEquals(bob.files.labelKey(label)), "two people derived the same key for one label, a stolen vault would open both", @@ -189,11 +207,8 @@ class LiveFileTransferTest { } /** - * An Unclassified document must arrive as one, and be distinguishable - * from a label that went missing. Zero is the default for an absent field, - * so this is the case where a dropped label and a deliberate zero look - * identical at the far end, the filename and the kind are what say the - * envelope was understood. + * An Unclassified document must arrive as one, and be distinguishable from + * a label that went missing. */ @Test fun `an unclassified document is still recognisably a document`() { @@ -204,11 +219,22 @@ class LiveFileTransferTest { val bytes = "nothing secret here".toByteArray() alice.conversations.sendAttachment( - bob.userId, bytes, "application/octet-stream", Attachment.FILE, - filename = "notes.txt", classification = 0, compartments = emptyList(), + bob.userId, + bytes, + "application/octet-stream", + Attachment.FILE, + filename = "notes.txt", + classification = 0, + compartments = emptyList(), ) - val ref = assertNotNull(bob.conversations.readable().single().content.attachment) + val ref = + assertNotNull( + bob.conversations + .readable() + .single() + .content.attachment, + ) assertEquals(Attachment.FILE, ref.kind, "an unlabelled file stopped being a file") assertEquals("notes.txt", ref.filename) assertEquals(0, ref.classification) diff --git a/client/src/test/kotlin/io/flamenet/client/LiveKeyLogTest.kt b/client/src/test/kotlin/io/flamenet/client/LiveKeyLogTest.kt index 6e656b0..da9b008 100644 --- a/client/src/test/kotlin/io/flamenet/client/LiveKeyLogTest.kt +++ b/client/src/test/kotlin/io/flamenet/client/LiveKeyLogTest.kt @@ -1,8 +1,8 @@ package io.flamenet.client -import org.junit.jupiter.api.Assumptions.assumeTrue import io.flamenet.e2e.DeviceIdentity import io.flamenet.e2e.Vault +import org.junit.jupiter.api.Assumptions.assumeTrue import javax.crypto.KeyGenerator import kotlin.test.Test import kotlin.test.assertEquals @@ -12,37 +12,17 @@ import kotlin.test.assertTrue /** * §19 enforcement against a relay that is actually running. - * - * Opt-in via `FN_LIVE_FLEET`, and pointed at the disposable fleet rather - * than production. It creates accounts and publishes keys, which is what a - * throwaway relay is for and what `relay.flamenet.io` is not. - * - * FN_LIVE_FLEET=https://relay1.flamenet.io ./gradlew :client:test - * - * The unit tests drive a MockWebServer with responses this repository wrote, - * which proves the verifier agrees with our idea of the relay. This proves it - * agrees with the relay, a distinction that has already cost this protocol - * three silent wire-shape bugs, every one of them invisible to a fake. */ class LiveKeyLogTest { - private val base: String? = System.getenv("FN_LIVE_FLEET") - /** - * Skips are announced, not silent. `return` from an unmet - * precondition makes a test that did nothing report as PASSED, and a suite - * of those is indistinguishable from a suite that works. `assumeTrue` - * reports SKIPPED, which is the truth. `LiveShapesTest` has always done it - * this way; these files did not, so 7 tests here reported green on every - * pipeline while running nothing at all. + * Skips are announced, not silent. */ private fun needFnLiveFleet() { LiveTarget.refuseProduction(base) assumeTrue(base != null, "set FN_LIVE_FLEET to run this against a relay") - // No fallback. This suite carried a literal passphrase and used it - // to create REAL accounts, a published password, since anybody who can - // read this repository can read it. + // No fallback. assumeTrue( LiveTarget.passphrase().isNotEmpty(), "set FN_LIVE_PASSPHRASE, this suite creates real accounts and will " + @@ -52,25 +32,41 @@ class LiveKeyLogTest { private class MapStorage : Vault.Storage { val records = LinkedHashMap() + override fun read(name: String) = records[name] - override fun write(name: String, bytes: ByteArray) { records[name] = bytes } - override fun delete(name: String) { records.remove(name) } + + override fun write( + name: String, + bytes: ByteArray, + ) { + records[name] = bytes + } + + override fun delete(name: String) { + records.remove(name) + } + override fun names() = records.keys.toList() } /** A real account on the fleet, with a device registered and keys published. */ - private class Actor(base: String, tag: String) { + private class Actor( + base: String, + tag: String, + ) { val handle = "kt$tag${System.currentTimeMillis() % 100_000_000}" - val account = RelayAccounts(base).signUp( - username = handle, - email = "$handle@example.invalid", - password = LiveTarget.passphrase(), - ) + val account = + RelayAccounts(base).signUp( + username = handle, + email = "$handle@example.invalid", + password = LiveTarget.passphrase(), + ) val identity: DeviceIdentity = DeviceIdentity.create() - val vault = Vault( - MapStorage(), - KeyGenerator.getInstance("AES").apply { init(256) }.generateKey(), - ) + val vault = + Vault( + MapStorage(), + KeyGenerator.getInstance("AES").apply { init(256) }.generateKey(), + ) val relay = RelayClient(base, username = account.username, appPassword = account.appPassword) val userId: Long get() = account.userId @@ -90,10 +86,12 @@ class LiveKeyLogTest { val bob = Actor(url, "b") // Alice looks Bob up the way a send does, and enforces §19 on what she - // was served. Bob published this key a moment ago, so the - // relay can prove it, and if this throws, enforcement is refusing - // honest keys, which is worse than not enforcing at all. - val bundle = alice.relay.fetchKeys(bob.userId).bundles.single { it.deviceId == bob.identity.deviceId } + // was served. + val bundle = + alice.relay + .fetchKeys(bob.userId) + .bundles + .single { it.deviceId == bob.identity.deviceId } val trust = alice.trust() trust.enforceKeyLog(bundle, bob.userId) @@ -104,11 +102,6 @@ class LiveKeyLogTest { /** * The check has to refuse something, or it is decorative. - * - * A device id that exists with an identity key the relay never logged for - * it. Nothing in the tree hashes to the leaf this bundle implies, so no - * proof can be produced, which is exactly the position a relay handing - * out a substituted key is in. */ @Test fun `a key the relay never published is refused by the real log`() { @@ -117,22 +110,28 @@ class LiveKeyLogTest { val alice = Actor(url, "c") val bob = Actor(url, "d") - val real = alice.relay.fetchKeys(bob.userId).bundles.single { it.deviceId == bob.identity.deviceId } - val substituted = io.flamenet.e2e.PeerBundle( - deviceId = real.deviceId, - identityDH = real.identityDH, - identitySigning = DeviceIdentity.create().identitySigningPublic, // not Bob's - signedPreKeyId = real.signedPreKeyId, - signedPreKey = real.signedPreKey, - signedPreKeySignature = real.signedPreKeySignature, - oneTimePreKeyId = real.oneTimePreKeyId, - oneTimePreKey = real.oneTimePreKey, - proto = real.proto, - ) + val real = + alice.relay + .fetchKeys(bob.userId) + .bundles + .single { it.deviceId == bob.identity.deviceId } + val substituted = + io.flamenet.e2e.PeerBundle( + deviceId = real.deviceId, + identityDH = real.identityDH, + identitySigning = DeviceIdentity.create().identitySigningPublic, // not Bob's + signedPreKeyId = real.signedPreKeyId, + signedPreKey = real.signedPreKey, + signedPreKeySignature = real.signedPreKeySignature, + oneTimePreKeyId = real.oneTimePreKeyId, + oneTimePreKey = real.oneTimePreKey, + proto = real.proto, + ) - val e = assertFailsWith { - alice.trust().enforceKeyLog(substituted, bob.userId) - } + val e = + assertFailsWith { + alice.trust().enforceKeyLog(substituted, bob.userId) + } assertEquals(real.deviceId, e.deviceId) } @@ -149,16 +148,21 @@ class LiveKeyLogTest { val bob = Actor(url, "f") val trust = alice.trust() - val first = alice.relay.fetchKeys(bob.userId).bundles.single { it.deviceId == bob.identity.deviceId } + val first = + alice.relay + .fetchKeys(bob.userId) + .bundles + .single { it.deviceId == bob.identity.deviceId } trust.enforceKeyLog(first, bob.userId) val pinned = alice.vault.loadKeyLogPin(alice.relay.host)!! - // Grow the log by publishing another device, then check again. The - // second call must produce and verify a real consistency proof from the - // pinned size to the new one. + // Grow the log by publishing another device, then check again. val second = Actor(url, "g") - val third = alice.relay.fetchKeys(second.userId).bundles - .single { it.deviceId == second.identity.deviceId } + val third = + alice.relay + .fetchKeys(second.userId) + .bundles + .single { it.deviceId == second.identity.deviceId } trust.enforceKeyLog(third, second.userId) val after = alice.vault.loadKeyLogPin(alice.relay.host)!! @@ -167,9 +171,6 @@ class LiveKeyLogTest { /** * A pin the relay cannot reconcile must be a fork, against the real log. - * - * Same size, different root: a relay that served two trees. There is no - * proof that makes these agree and there never can be. */ @Test fun `a root we never saw at our pinned size is a fork`() { @@ -180,10 +181,15 @@ class LiveKeyLogTest { val head = alice.relay.logHead() alice.vault.saveKeyLogPin( - relay = alice.relay.host, pin = Vault.KeyLogPin(head.getInt("size"), Wire.b64(ByteArray(32) { 0x5A })) + relay = alice.relay.host, + pin = Vault.KeyLogPin(head.getInt("size"), Wire.b64(ByteArray(32) { 0x5A })), ) - val bundle = alice.relay.fetchKeys(bob.userId).bundles.single { it.deviceId == bob.identity.deviceId } + val bundle = + alice.relay + .fetchKeys(bob.userId) + .bundles + .single { it.deviceId == bob.identity.deviceId } assertFailsWith { alice.trust().enforceKeyLog(bundle, bob.userId) } @@ -191,17 +197,6 @@ class LiveKeyLogTest { /** * An account with a global identity, verified through the pinned address. - * - * The relay files a leaf under an account's fnid once it has one, and - * under `local:/` before that. The subject is inside the - * hashed bytes, so a verifier that does not know the address cannot - * recompute the leaf, and before contacts carried one, an honest key from - * such an account was refused. That is a false positive on a real - * conversation, which is how a security setting gets switched off. - * - * This asserts all three states, because only having all three makes the - * mechanism legible: refused with no address pinned, accepted once the - * contact carries the right one, and refused again if the address is wrong. */ @Test fun `a contact carrying the peer's address verifies a key logged under it`() { @@ -211,38 +206,46 @@ class LiveKeyLogTest { val bob = Actor(url, "m") // Bob claims a global identity, then publishes a device under it. - val accountKey = io.flamenet.e2e.Ed25519.generate() + val accountKey = + io.flamenet.e2e.Ed25519 + .generate() val accountKeyB64 = Wire.b64(accountKey.publicKey) val audience = java.net.URI(url).host - val sig = Wire.b64(io.flamenet.e2e.Ed25519.sign( - accountKey, - io.flamenet.e2e.FNID.claimCanonical(audience, bob.userId, accountKeyB64), - )) + val sig = + Wire.b64( + io.flamenet.e2e.Ed25519.sign( + accountKey, + io.flamenet.e2e.FNID + .claimCanonical(audience, bob.userId, accountKeyB64), + ), + ) bob.relay.claimAccountIdentity(accountKeyB64, sig) - val fnid = io.flamenet.e2e.FNID.address(accountKey.publicKey) + val fnid = + io.flamenet.e2e.FNID + .address(accountKey.publicKey) val second = DeviceIdentity.create() RelayClient(url, bob.account.username, bob.account.appPassword) .also { it.useRelayAuth(second) } .registerDevice(second, second.generateOneTimePreKeys(3)) - val bundle = alice.relay.fetchKeys(bob.userId).bundles.single { it.deviceId == second.deviceId } + val bundle = + alice.relay + .fetchKeys(bob.userId) + .bundles + .single { it.deviceId == second.deviceId } val trust = alice.trust() - // 1. Nothing pinned: the honest key is refused, and says why, there - // ARE publications for this device, just none we can recompute. + // 1. val refusal = assertFailsWith { trust.enforceKeyLog(bundle, bob.userId) } assertTrue(refusal.hadEntries, "an address miss must not read as 'never published'") - // 2. The contact learns Bob's address from his handle, the way the - // buddy list does, and the very same key now verifies, with no - // fnid passed at the call site, because it comes from the pin. + // 2. trust.rememberIdentity(bob.userId, bob.account.username) assertEquals(fnid, trust.pinnedFnid(bob.userId)) trust.enforceKeyLog(bundle, bob.userId) - // 3. And a WRONG address still refuses, so what makes this pass is the - // address being right rather than the check having been softened. + // 3. assertFailsWith { trust.enforceKeyLog(bundle, bob.userId, "fnid:definitelynotbob") } @@ -250,9 +253,6 @@ class LiveKeyLogTest { /** * A changed address is a changed identity, against the real relay. - * - * An fnid is derived from the account key, so a different one is a - * different account key, not a new device, whatever it looks like. */ @Test fun `a peer whose address changes is reported as an identity change`() { @@ -262,15 +262,21 @@ class LiveKeyLogTest { val bob = Actor(url, "o") val trust = alice.trust() - val bundle = alice.relay.fetchKeys(bob.userId).bundles.single { it.deviceId == bob.identity.deviceId } + val bundle = + alice.relay + .fetchKeys(bob.userId) + .bundles + .single { it.deviceId == bob.identity.deviceId } // Pin Bob under an address, with his real keys. alice.vault.saveTrust( bob.userId, Vault.Trust(listOf(Wire.b64(bundle.identitySigning)), verified = false, fnid = "fnid:first"), ) - assertEquals(IdentityTrust.PINNED, - trust.record(bob.userId, listOf(bundle.identitySigning), "fnid:first")) + assertEquals( + IdentityTrust.PINNED, + trust.record(bob.userId, listOf(bundle.identitySigning), "fnid:first"), + ) assertEquals( IdentityTrust.CHANGED, @@ -290,8 +296,9 @@ class LiveKeyLogTest { val alice = Actor(url, "j") val bob = Actor(url, "k") - val conversations = Conversations(alice.identity, alice.vault, alice.relay) - .also { it.myUserId = alice.userId } + val conversations = + Conversations(alice.identity, alice.vault, alice.relay) + .also { it.myUserId = alice.userId } conversations.trust.requireKeyLog = true // Honest first: enforcement on must NOT break an ordinary send. @@ -301,7 +308,8 @@ class LiveKeyLogTest { // Then pin a root the relay cannot reconcile, and watch the same send // path refuse rather than encrypting to a key it cannot vouch for. alice.vault.saveKeyLogPin( - relay = alice.relay.host, pin = Vault.KeyLogPin(alice.relay.logHead().getInt("size"), Wire.b64(ByteArray(32) { 0x11 })) + relay = alice.relay.host, + pin = Vault.KeyLogPin(alice.relay.logHead().getInt("size"), Wire.b64(ByteArray(32) { 0x11 })), ) assertFailsWith { conversations.send(bob.userId, "this one must not go out") diff --git a/client/src/test/kotlin/io/flamenet/client/LiveRelayTest.kt b/client/src/test/kotlin/io/flamenet/client/LiveRelayTest.kt index e08df02..17a6a73 100644 --- a/client/src/test/kotlin/io/flamenet/client/LiveRelayTest.kt +++ b/client/src/test/kotlin/io/flamenet/client/LiveRelayTest.kt @@ -7,31 +7,12 @@ import kotlin.test.assertTrue /** * The client against a real relay, not a MockWebServer. - * - * Opt-in, and silent when off. Set `FN_LIVE_RELAY` to a relay's base URL - * to run it. A test that reaches the network cannot be part of a hermetic suite - * it fails on an aeroplane and on a build machine with no egress, and a suite - * that is red for reasons unrelated to the code stops being read. - * - * FN_LIVE_RELAY=https://relay.flamenet.io ./gradlew :client:test - * - * What it checks is the one thing MockWebServer structurally cannot: that the - * URL this client builds is a URL the deployed relay actually serves. The - * transparency-log routes are unauthenticated by design, anyone may audit the - * log, including someone who is not a user, so this needs no credentials. */ class LiveRelayTest { - private val relay: String? = System.getenv("FN_LIVE_RELAY") - /** - * Skips are announced, not silent. `return` from an unmet - * precondition makes a test that did nothing report as PASSED, and a suite - * of those is indistinguishable from a suite that works. `assumeTrue` - * reports SKIPPED, which is the truth. `LiveShapesTest` has always done it - * this way; these files did not, so 2 tests here reported green on every - * pipeline while running nothing at all. + * Skips are announced, not silent. */ private fun needFnLiveRelay() { LiveTarget.refuseProduction(relay) @@ -60,7 +41,7 @@ class LiveRelayTest { val client = RelayClient(base, username = "", appPassword = "") val head = client.logHead() val size = head.getInt("size") - if (size == 0) return // a fresh relay has nothing to prove + if (size == 0) return // a fresh relay has nothing to prove val proof = client.logInclusion(index = 0, size = size.toLong()) val path = proof.getJSONArray("proof") @@ -68,15 +49,17 @@ class LiveRelayTest { // The point of the log is that a client can check it, so the client // checks it here rather than trusting the relay's own head. - assertTrue(io.flamenet.e2e.KeyLog.verifyInclusion( - leaf = Wire.unb64(proof.getString("leaf")), - index = 0, - size = size, - proof = audit, - // Checked against the head fetched separately, NOT against the - // `root` the proof response carries. A server that gets to supply - // both halves of the comparison has proven nothing. - root = Wire.unb64(head.getString("root")), - ), "the deployed relay's inclusion proof does not reconstruct its own root") + assertTrue( + io.flamenet.e2e.KeyLog.verifyInclusion( + leaf = Wire.unb64(proof.getString("leaf")), + index = 0, + size = size, + proof = audit, + // Checked against the head fetched separately, NOT against the + // `root` the proof response carries. + root = Wire.unb64(head.getString("root")), + ), + "the deployed relay's inclusion proof does not reconstruct its own root", + ) } } diff --git a/client/src/test/kotlin/io/flamenet/client/LiveSealedSenderTest.kt b/client/src/test/kotlin/io/flamenet/client/LiveSealedSenderTest.kt index 45ad0bb..020628f 100644 --- a/client/src/test/kotlin/io/flamenet/client/LiveSealedSenderTest.kt +++ b/client/src/test/kotlin/io/flamenet/client/LiveSealedSenderTest.kt @@ -1,9 +1,9 @@ package io.flamenet.client -import org.junit.jupiter.api.Assumptions.assumeTrue import io.flamenet.e2e.DeviceIdentity import io.flamenet.e2e.SealedSender import io.flamenet.e2e.Vault +import org.junit.jupiter.api.Assumptions.assumeTrue import javax.crypto.KeyGenerator import kotlin.test.Test import kotlin.test.assertEquals @@ -13,35 +13,17 @@ import kotlin.test.assertTrue /** * Sealed sender (§14) against a relay that is actually running. - * - * Opt-in via `FN_LIVE_FLEET`, pointed at the disposable fleet. - * - * FN_LIVE_FLEET=https://relay1.flamenet.io ./gradlew :client:test - * - * The engine tests prove the seal is sound. What only a real relay can show is - * that the relay ACCEPTS an unauthenticated sealed submission, that it stores - * no sender against it, and that the recipient still gets the message, which - * is the entire claim the feature makes. */ class LiveSealedSenderTest { - private val base: String? = System.getenv("FN_LIVE_FLEET") - /** - * Skips are announced, not silent. `return` from an unmet - * precondition makes a test that did nothing report as PASSED, and a suite - * of those is indistinguishable from a suite that works. `assumeTrue` - * reports SKIPPED, which is the truth. `LiveShapesTest` has always done it - * this way; these files did not, so 2 tests here reported green on every - * pipeline while running nothing at all. + * Skips are announced, not silent. */ private fun needFnLiveFleet() { LiveTarget.refuseProduction(base) assumeTrue(base != null, "set FN_LIVE_FLEET to run this against a relay") - // No fallback. This suite carried a literal passphrase and used it - // to create REAL accounts, a published password, since anybody who can - // read this repository can read it. + // No fallback. assumeTrue( LiveTarget.passphrase().isNotEmpty(), "set FN_LIVE_PASSPHRASE, this suite creates real accounts and will " + @@ -51,24 +33,40 @@ class LiveSealedSenderTest { private class MapStorage : Vault.Storage { val records = LinkedHashMap() + override fun read(name: String) = records[name] - override fun write(name: String, bytes: ByteArray) { records[name] = bytes } - override fun delete(name: String) { records.remove(name) } + + override fun write( + name: String, + bytes: ByteArray, + ) { + records[name] = bytes + } + + override fun delete(name: String) { + records.remove(name) + } + override fun names() = records.keys.toList() } - private class Actor(base: String, tag: String) { + private class Actor( + base: String, + tag: String, + ) { val handle = "sl$tag${System.currentTimeMillis() % 100_000_000}" - val account = RelayAccounts(base).signUp( - username = handle, - email = "$handle@example.invalid", - password = LiveTarget.passphrase(), - ) + val account = + RelayAccounts(base).signUp( + username = handle, + email = "$handle@example.invalid", + password = LiveTarget.passphrase(), + ) val identity: DeviceIdentity = DeviceIdentity.create() - val vault = Vault( - MapStorage(), - KeyGenerator.getInstance("AES").apply { init(256) }.generateKey(), - ) + val vault = + Vault( + MapStorage(), + KeyGenerator.getInstance("AES").apply { init(256) }.generateKey(), + ) val relay = RelayClient(base, username = account.username, appPassword = account.appPassword) val userId: Long get() = account.userId val conversations = Conversations(identity, vault, relay).also { it.myUserId = userId } @@ -80,8 +78,8 @@ class LiveSealedSenderTest { } /** - * The claim, end to end: the relay carries the message and does not - * learn who sent it. + * The claim, end to end: the relay carries the message and does not learn + * who sent it. */ @Test fun `a sealed message arrives and the relay records no sender`() { @@ -93,16 +91,12 @@ class LiveSealedSenderTest { bob.conversations.ensureDeliveryKey() alice.conversations.ensureDeliveryKey() - // First contact is necessarily UNSEALED. Alice does not hold Bob's - // delivery key yet, and the key arrives inside an E2E message. This is - // the honest shape of the feature, not a gap: sealing needs a prior - // exchange, and a client that claimed otherwise would be lying. + // First contact is necessarily UNSEALED. val first = alice.conversations.send(bob.userId, "hello, unsealed") assertTrue(first.reachedSomeone) assertFalse(first.sealed, "the first message to a new contact cannot be sealed") - // Bob reads it and, with it, learns nothing yet. Alice has not been - // told Bob's key. Bob replying is what hands Alice his key. + // Bob reads it and, with it, learns nothing yet. val bobRead = bob.conversations.poll() assertTrue(bobRead.any { it.content.text == "hello, unsealed" }) val reply = bob.conversations.send(alice.userId, "hello back") @@ -117,8 +111,7 @@ class LiveSealedSenderTest { assertTrue(sealed.sealed, "Alice holds Bob's delivery key and must seal") assertTrue(sealed.reachedSomeone) - // The relay stored no sender for it. `from_user` 0 is the relay - // saying it does not know, which is the whole feature. + // The relay stored no sender for it. val inbox = bob.relay.pollInbox(bob.identity.deviceId, 0, 100) val sealedRow = inbox.last() assertEquals(0L, sealedRow.fromUser, "the relay must not know who sent a sealed message") @@ -146,26 +139,37 @@ class LiveSealedSenderTest { bob.conversations.ensureDeliveryKey() val old = bob.vault.loadDelivery()!!.key - // Bob replaces it. The relay now holds a digest the old key misses. + // Bob replaces it. bob.conversations.rotateDeliveryKey() - assertFalse(bob.vault.loadDelivery()!!.key.contentEquals(old)) + assertFalse( + bob.vault + .loadDelivery()!! + .key + .contentEquals(old), + ) - // A submission presenting the OLD key must be refused outright. Sent - // through the client rather than by hand, so this exercises the same - // path a real sealed send takes. - val bundle = alice.relay.fetchKeys(bob.userId).bundles.first { it.deviceId == bob.identity.deviceId } - val envelope = io.flamenet.e2e.E2ESession.initiate(alice.identity, bundle).encrypt("stale") - val blob = SealedSender.seal( - envelope, alice.userId, alice.identity.deviceId, bundle.deviceId, bundle.identityDH) + // A submission presenting the OLD key must be refused outright. + val bundle = + alice.relay + .fetchKeys(bob.userId) + .bundles + .first { it.deviceId == bob.identity.deviceId } + val envelope = + io.flamenet.e2e.E2ESession + .initiate(alice.identity, bundle) + .encrypt("stale") + val blob = + SealedSender.seal(envelope, alice.userId, alice.identity.deviceId, bundle.deviceId, bundle.identityDH) - val refused = runCatching { - alice.relay.sendEnvelopes( - bob.userId, - listOf(RelayClient.ToDevice(bundle.deviceId, blob.toPayload())), - alice.identity.deviceId, - deliveryKey = Wire.b64(old), - ) - } + val refused = + runCatching { + alice.relay.sendEnvelopes( + bob.userId, + listOf(RelayClient.ToDevice(bundle.deviceId, blob.toPayload())), + alice.identity.deviceId, + deliveryKey = Wire.b64(old), + ) + } assertTrue(refused.isFailure, "the relay must refuse a retired delivery key") } } diff --git a/client/src/test/kotlin/io/flamenet/client/LiveShapesTest.kt b/client/src/test/kotlin/io/flamenet/client/LiveShapesTest.kt index 798acc4..f284b43 100644 --- a/client/src/test/kotlin/io/flamenet/client/LiveShapesTest.kt +++ b/client/src/test/kotlin/io/flamenet/client/LiveShapesTest.kt @@ -2,47 +2,19 @@ package io.flamenet.client import io.flamenet.e2e.DeviceIdentity import io.flamenet.e2e.Vault +import org.junit.jupiter.api.Assumptions.assumeTrue import javax.crypto.KeyGenerator import javax.crypto.SecretKey -import org.junit.jupiter.api.Assumptions.assumeTrue import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue /** * All three deployment shapes, three rounds each, repeatably. - * - * The shapes are not variations on a theme, they differ in where a - * passphrase-derived secret is sent, which is the one thing a messenger must - * not get wrong: - * - * - Managed (`relay.flamenet.io`), accounts in the shared FlameNet - * service. One credential across Messenger and Files. - * - Self-hosted, shared identity (`relay2`), somebody else's relay, still - * the shared account service. This shape was unreachable until the - * client started reading `/.well-known/flamenet/relay`: the old hardcoded - * hostname list could never contain a stranger's relay. - * - Self-hosted, local (`relay1`, `relay3`), the relay is its own - * identity provider. The default, and not a lesser mode. - * - * Every round uses fresh handles. Reusing one makes round two fail with - * "that name is taken", a relay answering correctly, which reads as a product - * bug. Rounds are therefore repeatable by construction rather than by cleanup. - * - * Managed rounds create real accounts on `accounts.flamenet.io`. Enabled - * explicitly by `FN_LIVE_MANAGED=1`, because that is not litter to leave by - * accident. - * - * FN_LIVE_FLEET=https://relay1.flamenet.io FN_LIVE_MANAGED=1 \ - * ./gradlew :client:test --tests '*LiveShapesTest*' */ class LiveShapesTest { - /** - * Skips are announced, not silent. `return` from an unmet - * precondition makes a test that did nothing report as PASSED, and a suite - * of those is indistinguishable from a suite that works. `assumeTrue` - * reports SKIPPED, which is the truth. + * Skips are announced, not silent. */ private fun needFleet() { LiveTarget.refuseProduction(System.getenv("FN_LIVE_FLEET")) @@ -53,11 +25,13 @@ class LiveShapesTest { private fun needManaged() { assumeTrue( System.getenv("FN_LIVE_MANAGED") == "1", - "set FN_LIVE_MANAGED=1, these create real accounts on accounts.flamenet.io") + "set FN_LIVE_MANAGED=1, these create real accounts on accounts.flamenet.io", + ) assumeTrue( PASSPHRASE.isNotEmpty(), "set FN_LIVE_PASSPHRASE, these create real accounts and will not use " + - "a passphrase written into the repository") + "a passphrase written into the repository", + ) } private val local = "https://relay1.flamenet.io" @@ -66,9 +40,20 @@ class LiveShapesTest { private class MapStorage : Vault.Storage { private val records = LinkedHashMap() + override fun read(name: String) = records[name] - override fun write(name: String, bytes: ByteArray) { records[name] = bytes } - override fun delete(name: String) { records.remove(name) } + + override fun write( + name: String, + bytes: ByteArray, + ) { + records[name] = bytes + } + + override fun delete(name: String) { + records.remove(name) + } + override fun names() = records.keys.toList() } @@ -78,15 +63,11 @@ class LiveShapesTest { /** * Register against the shared account service, pacing for its rate limit. - * - * The throttle is the service working correctly, not a client failure, - * three rounds across two shapes is a dozen registrations in a burst, and - * any sane account service refuses that. So this backs off and retries - * rather than reporting a product bug, and still FAILS if the service never - * lets us through: a rate limit that never clears is a real problem, and - * skipping on it would hide one. */ - private fun registerPaced(accounts: AccountsClient, name: String) { + private fun registerPaced( + accounts: AccountsClient, + name: String, + ) { var wait = 15_000L repeat(3) { attempt -> try { @@ -95,14 +76,12 @@ class LiveShapesTest { } catch (e: AccountsClient.Failure.RateLimited) { if (attempt == 2) { // Not a product failure, and not something to retry - // through. The service allows three registrations per - // address per HOUR, deliberately, a short backoff cannot - // outwait that, and pretending otherwise would turn a - // correct defence into a flaky test. Say what happened. + // through. throw AssertionError( "the account service's registration budget is spent: it allows 3 per " + - "address per hour and this run needed more. Not a defect, wait for " + - "the window to roll over, or run one shape at a time.") + "address per hour and this run needed more. Not a defect, wait for " + + "the window to roll over, or run one shape at a time.", + ) } Thread.sleep(wait) wait *= 2 @@ -110,7 +89,7 @@ class LiveShapesTest { } } - // -- What each shape claims about itself --------------------------------- + // -- What each shape claims about itself ---------------------------------. @Test fun `each relay reports the shape it actually is`() { @@ -124,56 +103,58 @@ class LiveShapesTest { val sharedDescriptor = RelayDiscovery.describe(sharedSelfHosted) assertTrue(sharedDescriptor != null, "relay2 served no descriptor") assertEquals(RelayDiscovery.Accounts.SHARED, sharedDescriptor!!.accounts) - assertTrue(sharedDescriptor.usesSharedAccounts, - "relay2 names ${sharedDescriptor.accountsUrl}, which is not trusted") + assertTrue( + sharedDescriptor.usesSharedAccounts, + "relay2 names ${sharedDescriptor.accountsUrl}, which is not trusted", + ) if (System.getenv("FN_LIVE_MANAGED") == "1") { val managedDescriptor = RelayDiscovery.describe(managed) - assertTrue(managedDescriptor?.usesSharedAccounts == true, - "the managed relay does not report shared accounts") + assertTrue( + managedDescriptor?.usesSharedAccounts == true, + "the managed relay does not report shared accounts", + ) } } /** - * Our relays cannot be made to redirect their users. Whatever - * `relay.flamenet.io` advertises, the client authenticates against the - * service pinned in the app, so compromising one of ours does not let it - * harvest passphrase-derived secrets. + * Our relays cannot be made to redirect their users. */ @Test fun `a pinned relay cannot redirect its users to another account service`() { - val hijacked = RelayDiscovery.Descriptor( - audience = "relay.flamenet.io", - accounts = RelayDiscovery.Accounts.SHARED, - accountsUrl = "https://accounts.attacker.example", - federation = "closed", - protocolVersions = listOf(1), + val hijacked = + RelayDiscovery.Descriptor( + audience = "relay.flamenet.io", + accounts = RelayDiscovery.Accounts.SHARED, + accountsUrl = "https://accounts.attacker.example", + federation = "closed", + protocolVersions = listOf(1), + ) + assertEquals( + RelayDiscovery.OUR_ACCOUNT_SERVICE, + hijacked.effectiveAccountsUrl, + "a pinned relay redirected its users' derived secrets", ) - assertEquals(RelayDiscovery.OUR_ACCOUNT_SERVICE, hijacked.effectiveAccountsUrl, - "a pinned relay redirected its users' derived secrets") } /** - * And a relay that is NOT ours IS believed, deliberately. A self-hoster - * running Messenger and Files together needs one account across both, held - * by their own service. Refusing here would make "run both" mean "run them - * with two separate logins". The user typed that hostname; it cannot - * redirect anybody else's users. + * And a relay that is NOT ours IS believed, deliberately. */ @Test fun `a self-hosted relay may name its own account service`() { - val theirs = RelayDiscovery.Descriptor( - audience = "relay.someone-else.example", - accounts = RelayDiscovery.Accounts.SHARED, - accountsUrl = "https://accounts.someone-else.example", - federation = "closed", - protocolVersions = listOf(1), - ) + val theirs = + RelayDiscovery.Descriptor( + audience = "relay.someone-else.example", + accounts = RelayDiscovery.Accounts.SHARED, + accountsUrl = "https://accounts.someone-else.example", + federation = "closed", + protocolVersions = listOf(1), + ) assertTrue(theirs.usesSharedAccounts) assertEquals("https://accounts.someone-else.example", theirs.effectiveAccountsUrl) } - // -- Sign-up, three rounds, per shape ------------------------------------ + // -- Sign-up, three rounds, per shape ------------------------------------. @Test fun `local self-hosted sign-up works three times running`() { @@ -183,21 +164,11 @@ class LiveShapesTest { /** * One registration, then three full rounds on it. - * - * The account service allows three registrations per address per hour, - * on purpose: forty sign-in attempts is a person with a password manager - * and a bad afternoon, forty accounts is a flood. Three sign-UPS per shape - * would consume an hour's budget for one shape and fail the next, so a - * test written that way measures the rate limiter, not the product. - * - * So sign-up is proven once, and the repeatable part is the flow a person - * actually repeats: sign in, register a device, be reachable. Fresh - * registrations are covered by the local shape, which has no such limit - * because a self-hosted relay is not a shared service. */ @Test fun `shared self-hosted works three rounds running`() { - needFleet(); needManaged() + needFleet() + needManaged() sharedRoundsOnOneAccount(sharedSelfHosted, "s") } @@ -207,7 +178,10 @@ class LiveShapesTest { sharedRoundsOnOneAccount(managed, "m") } - private fun sharedRoundsOnOneAccount(base: String, tag: String) { + private fun sharedRoundsOnOneAccount( + base: String, + tag: String, + ) { val name = handle(tag) val descriptor = RelayDiscovery.describe(base) assertTrue(descriptor?.usesSharedAccounts == true, "$base is not a shared-identity relay") @@ -222,28 +196,23 @@ class LiveShapesTest { val account = RelayAccounts(base).sharedSession(session.token, name) assertTrue(account.appPassword.isNotEmpty(), "round $round: no relay credential") - // Every round must land on the SAME relay account. The relay - // keys on the product-scoped pseudonym, not the handle; keying on - // the handle would make each round a new account and nobody would - // notice until messages stopped arriving. + // Every round must land on the SAME relay account. if (firstUserId == null) firstUserId = account.userId - assertEquals(firstUserId, account.userId, - "round $round produced a different relay account") + assertEquals( + firstUserId, + account.userId, + "round $round produced a different relay account", + ) registerAndProve(base, account) } } - // -- Messaging on each shape --------------------------------------------- + // -- Messaging on each shape ---------------------------------------------. /** * A conversation on every shape, so "it works" is not a claim about one * relay's configuration. - * - * Deliberately three separate conversations rather than one crossing - * relays. Cross-relay delivery is federation, which is a different - * mechanism with a different failure mode; conflating them would let a - * federation problem masquerade as a messaging problem. */ @Test fun `a conversation works on a local self-hosted relay`() { @@ -252,40 +221,41 @@ class LiveShapesTest { } /** - * Split per shape rather than one test covering all three. The shared - * shapes go through the account service, which rate-limits, correctly, - * and a single combined test lets one shape's throttle hide another - * shape's result. + * Split per shape rather than one test covering all three. */ + /** * A conversation on a shared-identity relay needs TWO accounts, and the - * account service allows three registrations an hour. Enabled separately - * from the round tests so a single run cannot silently consume the whole - * budget and leave the next test looking broken. + * account service allows three registrations an hour. */ @Test fun `a conversation works on a shared self-hosted relay`() { - needFleet(); needManaged(); needBudget() + needFleet() + needManaged() + needBudget() talk(sharedSelfHosted) { tag -> sharedAccount(sharedSelfHosted, tag) } } @Test fun `a conversation works on the managed relay`() { - needManaged(); needBudget() + needManaged() + needBudget() talk(managed) { tag -> sharedAccount(managed, tag) } } /** * The account service permits three registrations per address per hour. - * These tests each need two, so they are opt-in on top of `FN_LIVE_MANAGED` - * running everything at once cannot fit, and a suite that cannot fit its - * own limits reports failures that are really scheduling. */ - private fun needBudget() = assumeTrue( - System.getenv("FN_LIVE_SIGNUP_BUDGET") == "1", - "set FN_LIVE_SIGNUP_BUDGET=1, needs 2 of the 3 registrations allowed per hour") + private fun needBudget() = + assumeTrue( + System.getenv("FN_LIVE_SIGNUP_BUDGET") == "1", + "set FN_LIVE_SIGNUP_BUDGET=1, needs 2 of the 3 registrations allowed per hour", + ) - private fun talk(base: String, make: (String) -> RelayAccounts.Account) { + private fun talk( + base: String, + make: (String) -> RelayAccounts.Account, + ) { val alice = ready(base, make("ta")) val bob = ready(base, make("tb")) @@ -293,21 +263,17 @@ class LiveShapesTest { val sent = alice.second.send(bob.first, text) assertTrue(sent.reachedSomeone, "$base delivered nothing: ${sent.failed}") - // What a PERSON would see. `poll` also returns control traffic, and - // since key-log checking became automatic there is reliably some: the - // first send to a contact gossips this device's view of the - // transparency log (§19.4), which enforcement is what sets the pin for. - // The client consumes that envelope and never shows it, counting it as - // a message made this test fail on a change that made the product more - // correct. + // What a PERSON would see. val got = bob.second.poll().filter { it.content.isRenderable } assertEquals(1, got.size, "$base: expected one message, got ${got.size}") assertEquals(text, got.single().text) } /** An account plus a registered device and a ready conversation layer. */ - private fun ready(base: String, account: RelayAccounts.Account): - Pair { + private fun ready( + base: String, + account: RelayAccounts.Account, + ): Pair { val identity = DeviceIdentity.create() val vault = Vault(MapStorage(), key()) vault.saveIdentity(identity) @@ -319,12 +285,18 @@ class LiveShapesTest { Conversations(identity, vault, relay).also { it.myUserId = account.userId } } - private fun localAccount(base: String, tag: String): RelayAccounts.Account { + private fun localAccount( + base: String, + tag: String, + ): RelayAccounts.Account { val name = handle(tag) return RelayAccounts(base).signUp(name, "$name@example.invalid", PASSPHRASE) } - private fun sharedAccount(base: String, tag: String): RelayAccounts.Account { + private fun sharedAccount( + base: String, + tag: String, + ): RelayAccounts.Account { val name = handle(tag) val descriptor = RelayDiscovery.describe(base)!! val accounts = AccountsClient(descriptor.accountsUrl!!) @@ -332,10 +304,13 @@ class LiveShapesTest { return RelayAccounts(base).sharedSession(accounts.signIn(name, PASSPHRASE).token, name) } - // -- One round of each kind ---------------------------------------------- + // -- One round of each kind ----------------------------------------------. /** Relay-local: the relay is its own identity provider. */ - private fun localRound(base: String, tag: String) { + private fun localRound( + base: String, + tag: String, + ) { val name = handle(tag) val account = RelayAccounts(base).signUp(name, "$name@example.invalid", PASSPHRASE) assertEquals(name, account.username) @@ -352,7 +327,10 @@ class LiveShapesTest { * derived secret reaches the account service, and the token it returns is * exchanged with the relay for a relay credential. */ - private fun sharedRound(base: String, tag: String) { + private fun sharedRound( + base: String, + tag: String, + ) { val name = handle(tag) val descriptor = RelayDiscovery.describe(base) assertTrue(descriptor?.usesSharedAccounts == true, "$base is not a shared-identity relay") @@ -366,17 +344,22 @@ class LiveShapesTest { assertTrue(account.appPassword.isNotEmpty()) registerAndProve(base, account) - // A second sign-in must land on the SAME relay account. The relay - // keys on the product-scoped pseudonym, not the handle; if it keyed on - // the handle, signing in twice would either fail or make a duplicate. - val second = RelayAccounts(base) - .sharedSession(accounts.signIn(name, PASSPHRASE).token, name) - assertEquals(account.userId, second.userId, - "a second sign-in produced a different relay account") + // A second sign-in must land on the SAME relay account. + val second = + RelayAccounts(base) + .sharedSession(accounts.signIn(name, PASSPHRASE).token, name) + assertEquals( + account.userId, + second.userId, + "a second sign-in produced a different relay account", + ) } /** Register a device and prove the account is actually reachable. */ - private fun registerAndProve(base: String, account: RelayAccounts.Account) { + private fun registerAndProve( + base: String, + account: RelayAccounts.Account, + ) { val identity = DeviceIdentity.create() val vault = Vault(MapStorage(), key()) vault.saveIdentity(identity) @@ -386,8 +369,6 @@ class LiveShapesTest { relay.registerDevice(identity, identity.generateOneTimePreKeys(10)) // Reachable means a stranger can fetch a bundle WITH a one-time prekey. - // A device published without them can send and can never be started - // with, which looks identical from the account's own side. val bundles = relay.fetchKeys(account.userId).bundles val mine = bundles.firstOrNull { it.deviceId == identity.deviceId } assertTrue(mine != null, "$base served no bundle for the device just registered") @@ -396,8 +377,8 @@ class LiveShapesTest { private companion object { /** - * Was `"a-long-enough-passphrase"`, a literal in a source file used - * to create real accounts. See [LiveTarget.passphrase]. + * Was `"a-long-enough-passphrase"`, a literal in a source file used to + * create real accounts. */ val PASSPHRASE: String get() = LiveTarget.passphrase() } diff --git a/client/src/test/kotlin/io/flamenet/client/LiveSignUpTest.kt b/client/src/test/kotlin/io/flamenet/client/LiveSignUpTest.kt index 878b2d3..2a7f80d 100644 --- a/client/src/test/kotlin/io/flamenet/client/LiveSignUpTest.kt +++ b/client/src/test/kotlin/io/flamenet/client/LiveSignUpTest.kt @@ -1,42 +1,24 @@ package io.flamenet.client -import org.junit.jupiter.api.Assumptions.assumeTrue import io.flamenet.e2e.DeviceIdentity +import org.junit.jupiter.api.Assumptions.assumeTrue import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue /** * The whole sign-up-and-register path against a throwaway relay. - * - * Opt-in via `FN_LIVE_FLEET`, and pointed at the disposable fleet rather - * than production. It creates accounts, which is exactly what a disposable - * relay is for and exactly what `relay.flamenet.io` is not. - * - * FN_LIVE_FLEET=https://relay1.flamenet.io ./gradlew :client:test - * - * This exists because the same path through the emulator takes four minutes to - * tell you it failed, and tells you nothing about where. */ class LiveSignUpTest { - private val relay: String? = System.getenv("FN_LIVE_FLEET") - /** - * Skips are announced, not silent. `return` from an unmet - * precondition makes a test that did nothing report as PASSED, and a suite - * of those is indistinguishable from a suite that works. `assumeTrue` - * reports SKIPPED, which is the truth. `LiveShapesTest` has always done it - * this way; these files did not, so 1 tests here reported green on every - * pipeline while running nothing at all. + * Skips are announced, not silent. */ private fun needFnLiveFleet() { LiveTarget.refuseProduction(relay) assumeTrue(relay != null, "set FN_LIVE_FLEET to run this against a relay") - // No fallback. This suite carried a literal passphrase and used it - // to create REAL accounts, a published password, since anybody who can - // read this repository can read it. + // No fallback. assumeTrue( LiveTarget.passphrase().isNotEmpty(), "set FN_LIVE_PASSPHRASE, this suite creates real accounts and will " + @@ -50,11 +32,12 @@ class LiveSignUpTest { val base = relay!! val handle = "jvmt" + (System.currentTimeMillis() % 100_000_000) - val account = RelayAccounts(base).signUp( - username = handle, - email = "$handle@example.invalid", - password = LiveTarget.passphrase(), - ) + val account = + RelayAccounts(base).signUp( + username = handle, + email = "$handle@example.invalid", + password = LiveTarget.passphrase(), + ) assertEquals(handle, account.username) assertTrue(account.appPassword.isNotEmpty()) @@ -68,9 +51,13 @@ class LiveSignUpTest { // The device is only really registered if a stranger can fetch a bundle // for it, that is what makes this phone reachable. val bundles = client.fetchKeys(account.userId).bundles - assertTrue(bundles.any { it.deviceId == identity.deviceId }, - "the relay did not serve a bundle for the device we just registered") - assertTrue(bundles.first { it.deviceId == identity.deviceId }.oneTimePreKey != null, - "the relay served no one-time prekey, so the pool never landed") + assertTrue( + bundles.any { it.deviceId == identity.deviceId }, + "the relay did not serve a bundle for the device we just registered", + ) + assertTrue( + bundles.first { it.deviceId == identity.deviceId }.oneTimePreKey != null, + "the relay served no one-time prekey, so the pool never landed", + ) } } diff --git a/client/src/test/kotlin/io/flamenet/client/LiveTarget.kt b/client/src/test/kotlin/io/flamenet/client/LiveTarget.kt index 1859843..3f02fad 100644 --- a/client/src/test/kotlin/io/flamenet/client/LiveTarget.kt +++ b/client/src/test/kotlin/io/flamenet/client/LiveTarget.kt @@ -4,69 +4,39 @@ import java.net.URI /** * Where a live suite is allowed to point. - * - * On 2026-08-31 a thirty-minute test round created 39 accounts in the - * PRODUCTION database. They are still there: `jvmt…`, `kt…`, `sl…`, `live…`, - * 55 of 86 accounts on the relay, which made the launch signup metric unusable - * a real person signing up was invisible inside the burst. - * - * Every one of those suites is correctly written. They are opt-in, and their - * own headers say to point them at the disposable fleet and not at production. - * That was the whole protection, and documentation is not a protection: it - * cannot refuse anything. Something ran them against production regardless, and - * nothing at any layer objected. - * - * A deny list, not an allow list, and that is a deliberate trade. An - * allow list would be stricter, and would also refuse a developer's own - * throwaway relay on localhost or a self-hosted instance, which is a normal - * thing to test against and would push people to disable the check. Naming the - * hosts that must never receive test traffic keeps the guard narrow enough to - * survive. - * - * A refusal here is an ERROR, never a skip. "This run was pointed at - * production" is not a precondition that happens to be unmet; it is a mistake - * that has already done damage on one occasion, and a skip is how it would go - * unnoticed on the next. */ object LiveTarget { - /** - * The disposable fleet lives on the same domain as production, so it - * is named explicitly and checked FIRST. Without this, denying - * `flamenet.io` and its subdomains refuses `relay1.flamenet.io`, the exact - * place these suites are supposed to point, and the guard blocks every - * legitimate run while appearing to block only production. A test caught - * that before it shipped, which is the argument for testing a guard rather - * than reading it. + * The disposable fleet lives on the same domain as production, so it is + * named explicitly and checked FIRST. */ - private val DISPOSABLE = listOf( - "relay1.flamenet.io", - "relay2.flamenet.io", - "relay3.flamenet.io", - ) + private val DISPOSABLE = + listOf( + "relay1.flamenet.io", + "relay2.flamenet.io", + "relay3.flamenet.io", + ) /** * Hosts that serve real accounts and real people's messages. - * - * The bare domain is in here on purpose: a production host added later - * is denied by default rather than needing somebody to remember this file. - * That is only safe because [DISPOSABLE] names the exceptions above. */ - private val PRODUCTION = listOf( - "flamenetmessenger.com", - "relay.flamenet.io", - "accounts.flamenet.io", - "flamenet.io", - ) + private val PRODUCTION = + listOf( + "flamenetmessenger.com", + "relay.flamenet.io", + "accounts.flamenet.io", + "flamenet.io", + ) /** - * Throw if [url] names production. Null is fine, that is "not configured", - * which the caller turns into an announced skip. + * Throw if [url] names production. */ fun refuseProduction(url: String?) { - val host = url?.let { runCatching { URI(it).host }.getOrNull() } - ?.lowercase() - ?: return + val host = + url + ?.let { runCatching { URI(it).host }.getOrNull() } + ?.lowercase() + ?: return if (DISPOSABLE.any { host == it }) return // `endsWith(".$it")` so a subdomain cannot slip past a bare-host match. if (PRODUCTION.any { host == it || host.endsWith(".$it") }) { @@ -80,23 +50,6 @@ object LiveTarget { /** * The passphrase a live suite signs up with. - * - * From the environment, with no fallback, and that is the whole - * point. Both live suites carried - * `const val PASSPHRASE = "a-long-enough-passphrase"` and used it to create - * accounts on `accounts.flamenet.io` whenever somebody set - * `FN_LIVE_MANAGED=1`. A credential written into a source file is a - * published password: every account those runs ever made can be signed into - * by anybody who can read this repository, and nothing about the accounts - * being throwaway changes that, they are real accounts on a real service, - * and they persist. - * - * Empty rather than throwing, so the caller turns it into an announced - * skip. A live suite that cannot run must report SKIPPED and not fail: a - * red suite for a missing variable teaches people to ignore red. - * - * The iOS side had the same class of bug twice (ios#62), a literal - * fallback used against production. This is the Kotlin half of that fix. */ fun passphrase(): String = System.getenv("FN_LIVE_PASSPHRASE").orEmpty() } diff --git a/client/src/test/kotlin/io/flamenet/client/LiveTargetTest.kt b/client/src/test/kotlin/io/flamenet/client/LiveTargetTest.kt index 23907fd..f279a77 100644 --- a/client/src/test/kotlin/io/flamenet/client/LiveTargetTest.kt +++ b/client/src/test/kotlin/io/flamenet/client/LiveTargetTest.kt @@ -5,13 +5,8 @@ import kotlin.test.assertFailsWith /** * The guard that stops a live suite creating accounts on production. - * - * This is the test that runs in every pipeline, unlike the live suites it - * protects, those skip without `FN_LIVE_FLEET`, so if the rule itself were - * only exercised there it would never be checked at all. */ class LiveTargetTest { - @Test fun `production hosts are refused`() { for (url in listOf( @@ -19,8 +14,7 @@ class LiveTargetTest { "https://relay.flamenet.io", "https://accounts.flamenet.io", "https://flamenet.io", - // Subdomains too. A deny list matching only the bare host would - // wave through anything in front of it. + // Subdomains too. "https://api.flamenetmessenger.com", // Scheme and port must not matter, the host is the whole question. "http://relay.flamenet.io:8080/e2e", @@ -39,8 +33,7 @@ class LiveTargetTest { "https://relay3.flamenet.io", "http://localhost:8080", "http://127.0.0.1:18973", - // A self-hoster's own instance. Refusing this would push people to - // disable the check, which is worse than not having it. + // A self-hoster's own instance. "https://relay.example.org", )) { LiveTarget.refuseProduction(url) @@ -48,9 +41,7 @@ class LiveTargetTest { } /** - * Null is "not configured", which the caller turns into an announced - * skip. It must not throw, or an unconfigured machine cannot run the suite - * at all. + * Null is "not configured", which the caller turns into an announced skip. */ @Test fun `an unset target is not an error`() { diff --git a/client/src/test/kotlin/io/flamenet/client/LiveTwoProductsTest.kt b/client/src/test/kotlin/io/flamenet/client/LiveTwoProductsTest.kt index f6bec0a..1723eac 100644 --- a/client/src/test/kotlin/io/flamenet/client/LiveTwoProductsTest.kt +++ b/client/src/test/kotlin/io/flamenet/client/LiveTwoProductsTest.kt @@ -1,8 +1,8 @@ package io.flamenet.client import okhttp3.Request -import org.junit.jupiter.api.Assumptions.assumeTrue import org.json.JSONObject +import org.junit.jupiter.api.Assumptions.assumeTrue import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotEquals @@ -10,17 +10,8 @@ import kotlin.test.assertTrue /** * One credential, two products, and whether either is optional. - * - * The claim the product makes is narrow and worth testing exactly: hosting - * only Messenger leaves nothing missing. Flamenet Files is not a dependency, - * a fallback, or a hidden requirement. What the shared account service buys is - * one credential across both, and nothing else. - * - * Creates a real account on `accounts.flamenet.io`. Enabled by - * `FN_LIVE_MANAGED=1`. */ class LiveTwoProductsTest { - private val accountsUrl = "https://accounts.flamenet.io" private val messengerRelay = "https://relay.flamenet.io" private val filesHost = "https://files.flamenet.io" @@ -29,11 +20,13 @@ class LiveTwoProductsTest { private fun needManaged() { assumeTrue( System.getenv("FN_LIVE_MANAGED") == "1", - "set FN_LIVE_MANAGED=1, this creates a real account") + "set FN_LIVE_MANAGED=1, this creates a real account", + ) assumeTrue( PASSPHRASE.isNotEmpty(), "set FN_LIVE_PASSPHRASE, this creates a real account and will not " + - "use a passphrase written into the repository") + "use a passphrase written into the repository", + ) } private fun needFleet() { @@ -43,9 +36,7 @@ class LiveTwoProductsTest { } /** - * The Messenger-only case, which is the one that matters. A relay - * keeping its own accounts must work with no account service and no Files - * anywhere, no probe of either, no degraded mode, no missing feature. + * The Messenger-only case, which is the one that matters. */ @Test fun `a messenger-only self-hoster needs no account service and no files`() { @@ -53,14 +44,17 @@ class LiveTwoProductsTest { val descriptor = RelayDiscovery.describe(localRelay) assertTrue(descriptor != null, "the relay served no descriptor") assertEquals(RelayDiscovery.Accounts.LOCAL, descriptor!!.accounts) - assertTrue(descriptor.accountsUrl == null, - "a local relay named an account service: ${descriptor.accountsUrl}") + assertTrue( + descriptor.accountsUrl == null, + "a local relay named an account service: ${descriptor.accountsUrl}", + ) // And it can actually run an account, start to finish, with nothing // else involved. val handle = "solo" + (System.nanoTime() % 10_000_000) - val account = RelayAccounts(localRelay) - .signUp(handle, "$handle@example.invalid", PASSPHRASE) + val account = + RelayAccounts(localRelay) + .signUp(handle, "$handle@example.invalid", PASSPHRASE) assertEquals(handle, account.username) assertTrue(account.appPassword.isNotEmpty()) assertEquals(account.userId, RelayAccounts(localRelay).signIn(handle, PASSPHRASE).userId) @@ -71,22 +65,27 @@ class LiveTwoProductsTest { fun `files and the managed relay point at the same account service`() { needManaged() val http = RelayClient.defaultHttpClient() - val filesConfig = http.newCall( - Request.Builder().url("$filesHost/api/config").get().build() - ).execute().use { JSONObject(it.body?.string().orEmpty()) } + val filesConfig = + http + .newCall( + Request + .Builder() + .url("$filesHost/api/config") + .get() + .build(), + ).execute() + .use { JSONObject(it.body?.string().orEmpty()) } - assertEquals(accountsUrl, filesConfig.getString("accountsURL"), - "Files uses a different account service than Messenger") + assertEquals( + accountsUrl, + filesConfig.getString("accountsURL"), + "Files uses a different account service than Messenger", + ) assertEquals(accountsUrl, RelayDiscovery.describe(messengerRelay)?.accountsUrl) } /** * One handle and passphrase, signed in to both products. - * - * And the two tokens must carry different pseudonyms. That is the - * whole reason sessions are product-scoped: if Messenger and Files received - * the same opaque id, the two services could correlate their users, which - * is exactly what a shared account service must not enable. */ @Test fun `one account signs in to both products without letting them correlate`() { @@ -100,30 +99,37 @@ class LiveTwoProductsTest { assertTrue(messengerSession.token.isNotEmpty(), "no messenger session") assertTrue(filesSession.token.isNotEmpty(), "no files session") - assertNotEquals(messengerSession.pseudonym, filesSession.pseudonym, - "the two products got the SAME pseudonym, they could correlate their users") + assertNotEquals( + messengerSession.pseudonym, + filesSession.pseudonym, + "the two products got the SAME pseudonym, they could correlate their users", + ) // The messenger half is not merely issued, it is accepted: exchanged at // the relay for a working relay credential. - val relayAccount = RelayAccounts(messengerRelay) - .sharedSession(messengerSession.token, handle) - assertTrue(relayAccount.appPassword.isNotEmpty(), - "the relay refused a session the account service issued") + val relayAccount = + RelayAccounts(messengerRelay) + .sharedSession(messengerSession.token, handle) + assertTrue( + relayAccount.appPassword.isNotEmpty(), + "the relay refused a session the account service issued", + ) - // A files token must NOT be accepted by the messenger relay. The - // product scope is a boundary, not a label; if the relay took it, a - // token minted for one product would work on another. - val refused = runCatching { - RelayAccounts(messengerRelay).sharedSession(filesSession.token, handle) - } - assertTrue(refused.isFailure, - "the messenger relay accepted a token scoped to Files") + // A files token must NOT be accepted by the messenger relay. + val refused = + runCatching { + RelayAccounts(messengerRelay).sharedSession(filesSession.token, handle) + } + assertTrue( + refused.isFailure, + "the messenger relay accepted a token scoped to Files", + ) } private companion object { /** - * Was `"a-long-enough-passphrase"`, a literal in a source file used - * to create real accounts. See [LiveTarget.passphrase]. + * Was `"a-long-enough-passphrase"`, a literal in a source file used to + * create real accounts. */ val PASSPHRASE: String get() = LiveTarget.passphrase() } diff --git a/client/src/test/kotlin/io/flamenet/client/LocalVaultTest.kt b/client/src/test/kotlin/io/flamenet/client/LocalVaultTest.kt index 7019d57..f529f01 100644 --- a/client/src/test/kotlin/io/flamenet/client/LocalVaultTest.kt +++ b/client/src/test/kotlin/io/flamenet/client/LocalVaultTest.kt @@ -4,6 +4,7 @@ import io.flamenet.e2e.Aead import io.flamenet.e2e.FileContent import io.flamenet.e2e.FileKeys import io.flamenet.e2e.Label +import org.json.JSONObject import java.io.File import java.util.UUID import kotlin.test.AfterTest @@ -15,35 +16,28 @@ import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue -import org.json.JSONObject /** * The local vault. - * - * Against a real directory in a temporary folder, not an in-memory fake. Half - * of what this class does IS the filesystem, atomic replaces, blobs written - * before rows, blobs that must survive a row being removed, and a fake would - * agree with the implementation about all of it. */ class LocalVaultTest { - /** * Files somebody else owns, that we only point at. - * - * Records every call, because for the delete path "the file survived" is not - * a claim worth making on its own, see the test that uses [calls]. */ private class FakeReferences : LocalVault.ReferenceResolver { val files = mutableMapOf() val calls = mutableListOf() + override fun exists(bookmark: String): Boolean { calls += "exists($bookmark)" return files.containsKey(bookmark) } + override fun read(bookmark: String): ByteArray { calls += "read($bookmark)" return files[bookmark] ?: throw IllegalStateException("gone") } + override fun size(bookmark: String): Int { calls += "size($bookmark)" return files[bookmark]?.size ?: 0 @@ -62,19 +56,28 @@ class LocalVaultTest { @BeforeTest fun setUp() { - root = File.createTempFile("vault", "").let { it.delete(); it.mkdirs(); it } + root = + File.createTempFile("vault", "").let { + it.delete() + it.mkdirs() + it + } refs = FakeReferences() vault = LocalVault(root, identity, refs).also { it.load() } } @AfterTest - fun tearDown() { root.deleteRecursively() } + fun tearDown() { + root.deleteRecursively() + } private fun reopen(): LocalVault = LocalVault(root, identity, refs).also { it.load() } + private fun hex(b: ByteArray) = b.joinToString("") { "%02x".format(it) } + private fun blobsOnDisk() = File(root, "blobs").listFiles()?.map { it.name }?.toSet() ?: emptySet() - // ---- The round trip -------------------------------------------------- + // ---- The round trip --------------------------------------------------. @Test fun `a file goes in and comes back out`() { @@ -89,10 +92,7 @@ class LocalVaultTest { } /** - * The plaintext must not be on disk anywhere. The whole claim of a - * vault is that a stolen container yields ciphertext, and a search for the - * bytes is the only way to be sure, a round trip passes just as happily if - * `add` wrote the file out unencrypted and read it straight back. + * The plaintext must not be on disk anywhere. */ @Test fun `nothing on disk contains the plaintext`() { @@ -102,14 +102,17 @@ class LocalVaultTest { val needle = phrase.toByteArray() for (file in root.walkTopDown().filter { it.isFile }) { val haystack = file.readBytes() - val found = (0..(haystack.size - needle.size)).any { at -> - needle.indices.all { haystack[at + it] == needle[it] } - } + val found = + (0..(haystack.size - needle.size)).any { at -> + needle.indices.all { haystack[at + it] == needle[it] } + } assertFalse(found, "${file.name} holds the plaintext") } // ...and the name is not sitting in the index either. - assertFalse(File(root, "index.json").readText().contains("Payroll"), - "the filename is stored in the clear") + assertFalse( + File(root, "index.json").readText().contains("Payroll"), + "the filename is stored in the clear", + ) } @Test @@ -124,14 +127,14 @@ class LocalVaultTest { assertEquals(setOf("Reports", "big.bin"), second.list(null).map { it.name }.toSet()) } - // ---- The index ------------------------------------------------------- + // ---- The index -------------------------------------------------------. /** - * An index with no `storage` field must read as vaulted. iOS shipped - * this field with a Swift default and found out that a default does not make - * a `Codable` key optional: every index written before referencing existed + * An index with no `storage` field must read as vaulted. iOS shipped this + * field with a Swift default and found out that a default does not make a + * `Codable` key optional: every index written before referencing existed * failed to decode, which presents to a person as a vault that has lost - * everything. The field is stripped here on purpose, from a real index. + * everything. */ @Test fun `an index written before storage existed still loads, as vaulted`() { @@ -161,18 +164,7 @@ class LocalVaultTest { } /** - * The bug the JVM could not see. Android's bundled `org.json` - * coerces a JSON `null` to the four-character string `"null"`; the Maven - * `org.json` this suite runs against returns `""`. Every nullable field in - * the index therefore passed here and broke on a phone, a vault with any - * top-level file threw `Invalid UUID string: null` on load, so adding the - * first file worked and reopening the app lost everything. - * - * This test writes the literal string `"null"` on purpose. That is - * exactly what the decoder is handed on a device, so putting it in the JSON - * here is what makes the JVM run reproduce the device's behaviour. Feeding - * a real JSON `null`, which is what the vault actually writes, would pass - * with the broken code and prove nothing. + * The bug the JVM could not see. */ @Test fun `a nullable field decodes as absent however the platform hands it over`() { @@ -181,8 +173,9 @@ class LocalVaultTest { for (shape in listOf( reference.replace("\"parentID\":null", "\"parentID\":\"null\""), - reference.replace("\"parentID\":null", ""). // key missing entirely - replace(",,", ","), + reference + .replace("\"parentID\":null", "") // key missing entirely + .replace(",,", ","), reference, )) { File(root, "index.json").writeText(shape) @@ -194,25 +187,28 @@ class LocalVaultTest { } /** - * The one that would NOT have thrown. `"null"` is valid base64, so a - * content key decoded from it is a plausible three bytes, and the file - * simply refuses to decrypt, for ever, with nothing to explain why. + * The one that would NOT have thrown. */ @Test fun `a bookmark or content key of the literal string null is treated as absent`() { val node = vault.add("x".toByteArray(), "f.txt", secret) val index = File(root, "index.json") index.writeText( - index.readText() + index + .readText() .replace("\"bookmark\":null", "\"bookmark\":\"null\"") - .replace(Regex("\"wrappedContentKey\":\"[^\"]*\""), - "\"wrappedContentKey\":\"null\"") + .replace( + Regex("\"wrappedContentKey\":\"[^\"]*\""), + "\"wrappedContentKey\":\"null\"", + ), ) val reopened = reopen() val back = assertNotNull(reopened.node(node.id)) assertNull(back.bookmark) - assertNull(back.wrappedContentKey, - "a three-byte content key decoded from the string \"null\" was accepted") + assertNull( + back.wrappedContentKey, + "a three-byte content key decoded from the string \"null\" was accepted", + ) // And the failure is one somebody can act on, not a decryption error. assertFailsWith { reopened.contents(back) } } @@ -224,9 +220,7 @@ class LocalVaultTest { } /** - * The temporary file must not survive a save. One left behind is - * harmless today and is exactly what a future "load the newest index" - * shortcut would pick up. + * The temporary file must not survive a save. */ @Test fun `saving leaves no temporary index behind`() { @@ -235,28 +229,25 @@ class LocalVaultTest { assertTrue(File(root, "index.json").exists()) } - // ---- Labels ---------------------------------------------------------- + // ---- Labels ----------------------------------------------------------. /** - * The label key binds the WHOLE canonical encoding. Deriving from the - * classification alone would hand one key to every compartment at that - * level, which is the exact separation compartments exist for, and it would - * pass every test built from levels alone. + * The label key binds the WHOLE canonical encoding. */ @Test fun `two labels at one level but different compartments derive different keys`() { val a = vault.labelKey(Label(5, setOf(1))) val b = vault.labelKey(Label(5, setOf(2))) val bare = vault.labelKey(Label(5)) - assertEquals(3, setOf(hex(a), hex(b), hex(bare)).size, - "compartments are not reaching the key derivation") + assertEquals( + 3, + setOf(hex(a), hex(b), hex(bare)).size, + "compartments are not reaching the key derivation", + ) } /** - * A node key is wrapped to its OWN label, never its parent's. The tree - * deliberately allows a low-classification file inside a high one, so a - * folder-derived key would hand every holder of the folder's key content - * filed above them. + * A node key is wrapped to its OWN label, never its parent's. */ @Test fun `a file's key does not come from the folder above it`() { @@ -272,9 +263,8 @@ class LocalVaultTest { } /** - * A row filed by somebody else, under a label whose key was never granted: - * unreadable, but still LISTED, with its classification visible. A row that - * simply vanished would leave a person unable to see that anything is there. + * A row filed by somebody else, under a label whose key was never granted. + * unreadable, but still LISTED, with its classification visible. */ @Test fun `a row this device cannot read is listed without its name`() { @@ -300,7 +290,7 @@ class LocalVaultTest { assertTrue(stranger.list(null).all { it.name == null }) } - // ---- Sending --------------------------------------------------------- + // ---- Sending ---------------------------------------------------------. @Test fun `a sealed file opens with the key that was handed over`() { @@ -311,10 +301,7 @@ class LocalVaultTest { } /** - * A fresh content key every time. Reusing the stored one would mean two - * recipients of one file hold the same key, so revoking either is - * impossible, and the vault's own blob opens with a key that has left the - * device. + * A fresh content key every time. */ @Test fun `sealing twice uses two different keys`() { @@ -335,7 +322,7 @@ class LocalVaultTest { } } - // ---- References ------------------------------------------------------ + // ---- References ------------------------------------------------------. @Test fun `a referenced file is read through without being copied`() { @@ -349,9 +336,7 @@ class LocalVaultTest { } /** - * A file the person moved or deleted is not an error. It is their file. - * The row reports itself missing and they decide what to do, a row that - * silently does nothing when tapped is worse than one that says so. + * A file the person moved or deleted is not an error. */ @Test fun `a reference whose file has gone reports itself missing`() { @@ -361,16 +346,16 @@ class LocalVaultTest { refs.files.remove("doc://1") assertTrue(vault.list(null).single().isMissing) - assertEquals("Their doc.pdf", vault.list(null).single().name, - "the row lost its name as well as its file") + assertEquals( + "Their doc.pdf", + vault.list(null).single().name, + "the row lost its name as well as its file", + ) assertFailsWith { vault.contents(node) } } /** - * Promoting to the vault drops the bookmark. Keeping it would leave a - * vaulted row still able to read through to a plaintext original, so the - * encrypted copy would not be the only way in, which is the point of - * vaulting it. + * Promoting to the vault drops the bookmark. */ @Test fun `promoting a reference encrypts it and lets go of the original`() { @@ -404,22 +389,10 @@ class LocalVaultTest { assertFailsWith { vault.encrypt(node.id) } } - // ---- Change detection ------------------------------------------------ + // ---- Change detection ------------------------------------------------. /** * A changed row must not compare equal to what it replaced. - * - * `Node.equals` was `other.id == id`, written to dodge `ByteArray` - * identity comparison. Promoting a link keeps the node's id and changes its - * `storage`, so the new row compared equal to the old one, the state object - * holding it compared equal too, `StateFlow` conflated the update away, and - * the screen never changed. On a device the file vanished from Linked and - * did not reappear in the vault until the app was restarted, which reads as - * losing somebody's file. - * - * Found by watching it on an emulator, not by any test. The suite passed - * throughout, because everything here reads the vault rather than a - * `StateFlow`, so this asserts the property the UI actually depends on. */ @Test fun `a promoted row does not compare equal to the reference it replaced`() { @@ -429,9 +402,11 @@ class LocalVaultTest { val after = assertNotNull(vault.node(before.id)) assertEquals(before.id, after.id, "the promote should keep the same row") - assertTrue(before != after, + assertTrue( + before != after, "a row whose storage changed compares equal to its old self, any " + - "state holder that conflates on equality will drop the update") + "state holder that conflates on equality will drop the update", + ) assertTrue(listOf(before) != listOf(after), "the LIST compares equal too") } @@ -441,36 +416,39 @@ class LocalVaultTest { val node = vault.add("x".toByteArray(), "f.txt", secret) val a = assertNotNull(reopen().node(node.id)) val b = assertNotNull(reopen().node(node.id)) - assertEquals(a, b, "two reads of one unchanged row differ, every refresh " + - "would redraw and byte arrays are being compared by identity again") + assertEquals( + a, + b, + "two reads of one unchanged row differ, every refresh " + + "would redraw and byte arrays are being compared by identity again", + ) assertEquals(a.hashCode(), b.hashCode()) } /** - * Every mutable field, not just `storage`. A rename, a move, a - * re-classification and a re-wrap all have to be visible to a UI that - * conflates on equality. + * Every mutable field, not just `storage`. */ @Test fun `each field that can change is visible in equality`() { val node = vault.add("x".toByteArray(), "f.txt", secret) val base = assertNotNull(vault.node(node.id)) - val changed = listOf( - "parentId" to base.copy(parentId = UUID.randomUUID()), - "classification" to base.copy(classification = 9), - "compartments" to base.copy(compartments = listOf(4)), - "byteCount" to base.copy(byteCount = base.byteCount + 1), - "storage" to base.copy(storage = LocalVault.Storage.REFERENCE), - "bookmark" to base.copy(bookmark = "doc://x"), - "wrappedContentKey" to base.copy(wrappedContentKey = null), - "wrappedName" to base.copy(wrappedName = ByteArray(9)), - ) + val changed = + listOf( + "parentId" to base.copy(parentId = UUID.randomUUID()), + "classification" to base.copy(classification = 9), + "compartments" to base.copy(compartments = listOf(4)), + "byteCount" to base.copy(byteCount = base.byteCount + 1), + "storage" to base.copy(storage = LocalVault.Storage.REFERENCE), + "bookmark" to base.copy(bookmark = "doc://x"), + "wrappedContentKey" to base.copy(wrappedContentKey = null), + "wrappedName" to base.copy(wrappedName = ByteArray(9)), + ) for ((field, other) in changed) { assertTrue(base != other, "a change to $field is invisible to equality") } } - // ---- Deleting -------------------------------------------------------- + // ---- Deleting --------------------------------------------------------. @Test fun `deleting a file takes its bytes with it`() { @@ -483,16 +461,6 @@ class LocalVaultTest { /** * Deleting a REFERENCE row must not touch the person's document. - * Those bytes are their file, sitting where they put it. This is the one - * mistake in the whole class that cannot be undone. - * - * Asserting only that the file survived would be a test that cannot - * fail, because the delete path does not hold a resolver call to remove, - * the protection is structural: [LocalVault.ReferenceResolver] has no way to - * delete anything. So this asserts the property that actually constrains - * future code: removing a row reaches the person's storage not at all. - * Anybody who later routes the delete path through the resolver has to come - * here and say why. */ @Test fun `deleting a reference row does not reach the person's storage at all`() { @@ -502,16 +470,23 @@ class LocalVaultTest { refs.calls.clear() vault.remove(node.id) - assertEquals(emptyList(), refs.calls, - "the delete path is talking to the person's own storage") + assertEquals( + emptyList(), + refs.calls, + "the delete path is talking to the person's own storage", + ) assertTrue(refs.files.containsKey("doc://1"), "removing a row deleted somebody's document") - // Same again for the bulk path, which is the one a screen actually uses. + // Same again for the bulk path, which is the one a screen actually + // uses. val second = vault.reference("doc://1", "doc.pdf", open) refs.calls.clear() vault.remove(listOf(second.id)) - assertEquals(emptyList(), refs.calls, - "the bulk delete path is talking to the person's own storage") + assertEquals( + emptyList(), + refs.calls, + "the bulk delete path is talking to the person's own storage", + ) assertTrue(refs.files.containsKey("doc://1")) } @@ -524,9 +499,7 @@ class LocalVaultTest { } /** - * Deepest-first, so selecting a folder AND everything in it works. Doing - * it in the order given would refuse the folder for being non-empty and - * leave half the selection behind. + * Deepest-first, so selecting a folder AND everything in it works. */ @Test fun `selecting a folder and its contents removes all of it`() { @@ -543,9 +516,7 @@ class LocalVaultTest { } /** - * The report is the point. Selecting eleven things and having nine go - * is a data-loss-shaped event in the other direction: the two that survived - * are the ones somebody now believes are gone. + * The report is the point. */ @Test fun `a folder whose contents were not all selected is kept, and reported`() { @@ -562,8 +533,6 @@ class LocalVaultTest { /** * A row filed above what this device can read still counts as contents. - * Otherwise a bulk gesture would take rows the person could not have - * reviewed before tapping. */ @Test fun `an unreadable row inside a folder still protects it from a bulk delete`() { @@ -591,13 +560,13 @@ class LocalVaultTest { assertEquals(listOf("b.txt"), reopen().list(null).map { it.name }) } - // ---- The picker ------------------------------------------------------ + // ---- The picker ------------------------------------------------------. /** - * The whole tree, not the top of it. iOS's picker lists the root - * only, so a document filed into a folder cannot be sent from a - * conversation at all, and nothing says so: the file is simply absent, - * which reads as "it isn't there". Filed as ios#68. + * The whole tree, not the top of it. iOS's picker lists the root only, so a + * document filed into a folder cannot be sent from a conversation at all, + * and nothing says so: the file is simply absent, which reads as "it isn't + * there". */ @Test fun `everything sendable is offered, however deep it is filed`() { @@ -617,10 +586,7 @@ class LocalVaultTest { } /** - * The one place this class hides a row it cannot read. Everywhere - * else an unreadable row is shown, because hiding it would leave somebody - * unable to see that anything is there. A picker's only purpose is choosing - * something to send, and a row that can never be chosen is furniture. + * The one place this class hides a row it cannot read. */ @Test fun `rows that could never be sent are left out of the picker`() { @@ -629,15 +595,20 @@ class LocalVaultTest { vault.add("readable".toByteArray(), "fine.txt", open) refs.files.remove("doc://gone") - assertEquals(listOf("fine.txt"), vault.allFiles().map { it.name }, - "a file that has moved was offered as sendable") + assertEquals( + listOf("fine.txt"), + vault.allFiles().map { it.name }, + "a file that has moved was offered as sendable", + ) val stranger = LocalVault(root, ByteArray(32) { 99 }, refs).also { it.load() } - assertTrue(stranger.allFiles().isEmpty(), - "a row this device cannot decrypt was offered as sendable") + assertTrue( + stranger.allFiles().isEmpty(), + "a row this device cannot decrypt was offered as sendable", + ) } - // ---- The tree -------------------------------------------------------- + // ---- The tree --------------------------------------------------------. @Test fun `descendants walks the whole subtree`() { @@ -651,9 +622,8 @@ class LocalVaultTest { } /** - * A cycle cannot happen through this API, and the guard is here because - * an index is a file on disk that something else could write. Without it - * this is an infinite loop in a method a screen calls. + * A cycle cannot happen through this API, and the guard is here because an + * index is a file on disk that something else could write. */ @Test fun `a cycle in the index does not hang the tree walk`() { @@ -670,10 +640,7 @@ class LocalVaultTest { index.writeText(json.toString()) val cyclic = reopen() - // Not merely "it terminated": each node once. A step-counter guard - // would end the loop and hand a screen ten thousand copies of two ids, - // which is what "delete this folder and everything in it" would then - // offer to do. + // Not merely "it terminated": each node once. assertEquals(listOf(b.id), cyclic.descendants(a.id)) assertEquals(listOf(a.id), cyclic.descendants(b.id)) // And a bulk delete over it terminates rather than spinning in `depth`. diff --git a/client/src/test/kotlin/io/flamenet/client/MailboxHomingTest.kt b/client/src/test/kotlin/io/flamenet/client/MailboxHomingTest.kt index ed739bb..2e9c0dc 100644 --- a/client/src/test/kotlin/io/flamenet/client/MailboxHomingTest.kt +++ b/client/src/test/kotlin/io/flamenet/client/MailboxHomingTest.kt @@ -22,22 +22,24 @@ import kotlin.test.assertTrue /** * Adding and dropping a mailbox (spec §20.4). - * - * Your address survives a relay disappearing; your mailbox does not. So - * the order of operations here is the whole substance: nothing is written down - * locally until the relay has actually accepted a registration, and the record - * is published everywhere rather than only at the relay being added. A home - * that does not know it is one still accepts mail, but a sender asking IT for - * the record gets a stale list and skips the others, the redundancy would - * quietly depend on which relay the sender happened to ask. */ class MailboxHomingTest { - private class MapStorage : Vault.Storage { val records = LinkedHashMap() + override fun read(name: String) = records[name] - override fun write(name: String, bytes: ByteArray) { records[name] = bytes } - override fun delete(name: String) { records.remove(name) } + + override fun write( + name: String, + bytes: ByteArray, + ) { + records[name] = bytes + } + + override fun delete(name: String) { + records.remove(name) + } + override fun names() = records.keys.toList() } @@ -54,12 +56,6 @@ class MailboxHomingTest { /** * What each relay would serve back if asked. - * - * Separate from [published], which a test may clear to check who was - * told THIS time. A relay does not forget a record because the test stopped - * watching, and `nextSerial` reads exactly this, so sharing one map made - * every publish after a clear reuse serial 1, which a real relay would - * refuse as a rollback. */ private val served = LinkedHashMap() private var newHomeRejectsSignIn = false @@ -67,88 +63,132 @@ class MailboxHomingTest { @BeforeTest fun start() { identity = DeviceIdentity.create() - vault = Vault(MapStorage(), - KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()) + vault = + Vault( + MapStorage(), + KeyGenerator.getInstance("AES").apply { init(256) }.generateKey(), + ) vault.saveIdentity(identity) - home = MockWebServer().also { it.dispatcher = relay("home"); it.start() } - newHome = MockWebServer().also { it.dispatcher = relay("new"); it.start() } + home = + MockWebServer().also { + it.dispatcher = relay("home") + it.start() + } + newHome = + MockWebServer().also { + it.dispatcher = relay("new") + it.start() + } } - @AfterTest fun stop() { home.shutdown(); newHome.shutdown() } + @AfterTest fun stop() { + home.shutdown() + newHome.shutdown() + } - private fun json(body: String) = MockResponse() - .setResponseCode(200).setHeader("Content-Type", "application/json").setBody(body) + private fun json(body: String) = + MockResponse() + .setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody(body) - private fun relay(tag: String) = object : Dispatcher() { - override fun dispatch(request: RecordedRequest): MockResponse { - val path = request.path.orEmpty() - return when { - "account/signin" in path -> { - if (tag == "new" && newHomeRejectsSignIn) MockResponse().setResponseCode(401) - else json("""{"user_id":9,"username":"me","display_name":"Me","app_password":"pw"}""") - } - ".well-known/flamenet/relay" in path -> - json("""{"audience":"$tag.example.com","issuerPublicKey":""}""") - "/e2e/devices" in path -> json("""{"ok":true,"device_id":"d","opk_remaining":0}""") - "/e2e/account/identity" in path -> - json("""{"ok":true,"fnid":"$fnid","created":true}""") - "/e2e/account/homes" in path && request.method == "POST" -> { - if (tag == "new" && newHomeRejectsRecord) { - MockResponse().setResponseCode(400).setBody("{}") - } else { - val record = JSONObject(request.body.readUtf8()) - published.getOrPut(tag) { mutableListOf() }.add(record) - served[tag] = record - json("""{"ok":true}""") + private fun relay(tag: String) = + object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse { + val path = request.path.orEmpty() + return when { + "account/signin" in path -> { + if (tag == "new" && newHomeRejectsSignIn) { + MockResponse().setResponseCode(401) + } else { + json("""{"user_id":9,"username":"me","display_name":"Me","app_password":"pw"}""") + } + } + + ".well-known/flamenet/relay" in path -> { + json("""{"audience":"$tag.example.com","issuerPublicKey":""}""") + } + + "/e2e/devices" in path -> { + json("""{"ok":true,"device_id":"d","opk_remaining":0}""") + } + + "/e2e/account/identity" in path -> { + json("""{"ok":true,"fnid":"$fnid","created":true}""") + } + + "/e2e/account/homes" in path && request.method == "POST" -> { + if (tag == "new" && newHomeRejectsRecord) { + MockResponse().setResponseCode(400).setBody("{}") + } else { + val record = JSONObject(request.body.readUtf8()) + published.getOrPut(tag) { mutableListOf() }.add(record) + served[tag] = record + json("""{"ok":true}""") + } + } + + // Serves back what it was told. + "/e2e/account/homes/" in path -> { + served[tag]?.let { + json( + JSONObject() + .put("fnid", fnid) + .put("serial", it.getInt("serial")) + .put("relays", it.getJSONArray("relays")) + .put("sig", it.getString("sig")) + .toString(), + ) + } ?: MockResponse().setResponseCode(404) + } + + else -> { + MockResponse().setResponseCode(404) } } - // Serves back what it was told. `nextSerial` reads the - // published records to decide the next one, so a relay that - // stores a record and then 404s it would have every publish - // reuse serial 1, and the second would be refused as a - // rollback by the very check that stops an attacker replaying - // an old list. A mock that only accepts writes hides that. - "/e2e/account/homes/" in path -> - served[tag]?.let { - json(JSONObject() - .put("fnid", fnid) - .put("serial", it.getInt("serial")) - .put("relays", it.getJSONArray("relays")) - .put("sig", it.getString("sig")).toString()) - } ?: MockResponse().setResponseCode(404) - else -> MockResponse().setResponseCode(404) } } - } - private fun homing() = MailboxHoming( - vault, identity, "home.example.com", - RelayClient(home.url("/").toString(), "me", "pw")) + private fun homing() = + MailboxHoming( + vault, + identity, + "home.example.com", + RelayClient(home.url("/").toString(), "me", "pw"), + ) /** - * The record names `new.example.com`; the URL this device dials is the - * mock server. That split is the production shape too, the hostname is - * inside the signature and cannot carry a port. + * The record names `new.example.com`; the URL this device dials is the mock + * server. */ private fun add(existing: List = emptyList()) = - homing().add("new.example.com", handle = "me", passphrase = "pass", - accountKeySeed = seed, fnid = fnid, existingHomes = existing, - dialAs = newHome.url("/").toString()) + homing().add( + "new.example.com", + handle = "me", + passphrase = "pass", + accountKeySeed = seed, + fnid = fnid, + existingHomes = existing, + dialAs = newHome.url("/").toString(), + ) // MARK: - What a relay's name is allowed to be /** - * A relay is a hostname, never a URL. This is parsed from something a - * person typed or pasted, and accepting `https://x/y` would let it point - * the device at a path on a host of somebody else's choosing, and then - * send that host a signed record naming it as a mailbox. + * A relay is a hostname, never a URL. */ @Test fun `only a hostname is accepted as a relay`() { assertEquals("relay.example.com", MailboxHoming.hostname(" Relay.Example.COM ")) - for (bad in listOf("https://relay.example.com", "relay.example.com/path", - "relay.example.com:8443", "me@relay.example.com", - "localhost", "", "relay example com")) { + for (bad in listOf( + "https://relay.example.com", + "relay.example.com/path", + "relay.example.com:8443", + "me@relay.example.com", + "localhost", + "", + "relay example com", + )) { assertNull(MailboxHoming.hostname(bad), "accepted $bad") } } @@ -166,9 +206,9 @@ class MailboxHomingTest { // MARK: - Nothing is written down until the relay agrees /** - * A half-added home, stored locally, unknown to the relay, would be - * polled forever and would show on screen as a mailbox that never has - * anything in it. + * A half-added home, stored locally, unknown to the relay, would be polled + * forever and would show on screen as a mailbox that never has anything in + * it. */ @Test fun `a relay that refuses the sign-in leaves nothing behind`() { @@ -178,9 +218,7 @@ class MailboxHomingTest { } /** - * The NEW relay accepting is what decides. A record every old home - * took but the new one refused describes a mailbox that does not exist, and - * a sender told to deliver there gets nothing. + * The NEW relay accepting is what decides. */ @Test fun `a relay that refuses the record is not remembered as a mailbox`() { @@ -196,18 +234,26 @@ class MailboxHomingTest { val homes = add() assertEquals(listOf("home.example.com", "new.example.com"), homes) - assertEquals(setOf("home", "new"), published.keys, - "a home that was not told serves a stale list to senders") + assertEquals( + setOf("home", "new"), + published.keys, + "a home that was not told serves a stale list to senders", + ) for ((tag, records) in published) { val record = records.single() - val relays = record.getJSONArray("relays").let { a -> - (0 until a.length()).map { a.getString(it) } - } + val relays = + record.getJSONArray("relays").let { a -> + (0 until a.length()).map { a.getString(it) } + } assertEquals(homes, relays, "$tag was told a different list") assertTrue( - AccountIdentity.verifyHomes(fnid, record.getInt("serial"), relays, - record.getString("sig")), + AccountIdentity.verifyHomes( + fnid, + record.getInt("serial"), + relays, + record.getString("sig"), + ), "$tag was sent a record that does not verify", ) } @@ -226,23 +272,23 @@ class MailboxHomingTest { // MARK: - Dropping one /** - * The shorter list is published first and the credential goes only if - * that worked. Forgetting locally while senders are still told to deliver - * there leaves mail arriving at a mailbox this device no longer reads, - * which, from the sender's side, is indistinguishable from delivery. + * The shorter list is published first and the credential goes only if that + * worked. */ @Test fun `removing a mailbox publishes the shorter list and then forgets it`() { add() published.clear() - homing().remove("new.example.com", seed, fnid, - listOf("home.example.com", "new.example.com")) + homing().remove( + "new.example.com", + seed, + fnid, + listOf("home.example.com", "new.example.com"), + ) assertTrue(vault.extraHomes().isEmpty(), "the credential outlived the mailbox") - // The relay being dropped is NOT asked to accept the new record. It - // is the one party with a reason to refuse, and it is not in the list - // any more so it would refuse on those grounds regardless. + // The relay being dropped is NOT asked to accept the new record. assertEquals(setOf("home"), published.keys) val relays = published["home"]!!.single().getJSONArray("relays") assertEquals(1, relays.length()) @@ -271,10 +317,18 @@ class MailboxHomingTest { @Test fun `each published record carries a serial above the last`() { add() - val first = published.values.first().single().getInt("serial") + val first = + published.values + .first() + .single() + .getInt("serial") published.clear() - homing().remove("new.example.com", seed, fnid, - listOf("home.example.com", "new.example.com")) + homing().remove( + "new.example.com", + seed, + fnid, + listOf("home.example.com", "new.example.com"), + ) val second = published["home"]!!.single().getInt("serial") assertTrue(second > first, "serial went $first -> $second") } diff --git a/client/src/test/kotlin/io/flamenet/client/MessengerApiLookupTest.kt b/client/src/test/kotlin/io/flamenet/client/MessengerApiLookupTest.kt index 1513e00..4bec0f9 100644 --- a/client/src/test/kotlin/io/flamenet/client/MessengerApiLookupTest.kt +++ b/client/src/test/kotlin/io/flamenet/client/MessengerApiLookupTest.kt @@ -10,49 +10,37 @@ import kotlin.test.assertNotNull import kotlin.test.assertNull /** - * The relay answers a lookup with an ARRAY, and the client used to insist - * it was an object. - * - * `RoutesAccount.lookup` returns `[MeResponse]`, empty for a miss, one element - * for a hit, deliberately, so that a miss and a hit differ only in length and - * neither timing nor shape is an oracle. The client sent it through `send()`, - * which does `JSONObject(text)` and throws on an array, and `lookup()` wrapped - * the whole thing in `runCatching { }.getOrNull()`. - * - * Every lookup therefore reported "No account called X here" while the relay - * was returning the account. Adding a contact was impossible on Android, - * the first thing anyone does, and it shipped in an APK that way. - * - * Measured against relay.flamenet.io before the fix: the relay returned - * `[{"name":"appreview2demo","id":30,"slug":"appreview2demo"}]` and the app said - * no such account existed. - * - * Nothing caught it because all three layers were quiet: the relay was - * correct, the parse failure was swallowed, and a miss and a crash render - * identically. `CrudSurfaceTest` asserted the lookup CONTROL exists, which it - * did. The lesson is that a control being present says nothing about the - * feature working. + * The relay answers a lookup with an ARRAY, and the client used to insist it + * was an object. */ class MessengerApiLookupTest { - private lateinit var server: MockWebServer - @BeforeTest fun start() { server = MockWebServer().also { it.start() } } - @AfterTest fun stop() { server.shutdown() } + @BeforeTest fun start() { + server = MockWebServer().also { it.start() } + } + + @AfterTest fun stop() { + server.shutdown() + } private fun api() = MessengerApi(server.url("/").toString(), "alice", "app pass word") - private fun json(body: String) = MockResponse() - .setResponseCode(200) - .setHeader("Content-Type", "application/json") - .setBody(body) + private fun json(body: String) = + MockResponse() + .setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody(body) @Test fun `a hit comes back as a one element array and is parsed`() { server.enqueue(json("""[{"id":30,"name":"Bravo","slug":"appreview2demo"}]""")) val found = api().lookup("appreview2demo") - assertNotNull(found, "the relay returned the account and the client dropped it, " + - "this is the shape bug that made adding a contact impossible on Android") + assertNotNull( + found, + "the relay returned the account and the client dropped it, " + + "this is the shape bug that made adding a contact impossible on Android", + ) assertEquals(30L, found.userId) assertEquals("appreview2demo", found.handle) } diff --git a/client/src/test/kotlin/io/flamenet/client/MessengerApiShapeTest.kt b/client/src/test/kotlin/io/flamenet/client/MessengerApiShapeTest.kt index 11f75cb..7aa8d41 100644 --- a/client/src/test/kotlin/io/flamenet/client/MessengerApiShapeTest.kt +++ b/client/src/test/kotlin/io/flamenet/client/MessengerApiShapeTest.kt @@ -12,48 +12,39 @@ import kotlin.test.assertTrue /** * What the roster, notification and account routes actually decode. - * - * Three separate field-name bugs are recorded in comments in this one - * file, and all three shipped. The block list read `user_id` where the relay - * sends `id`, so the Block button did nothing and survived review because the - * list was always empty. Notifications read `kind`/`body`/`read`, names that - * exist nowhere, for months, so every alert decoded as a blank unread row and - * the screen looked permanently empty. The roster route does not use the - * `user_id` shape the §8 routes use, and guessing otherwise threw - * "No value for user_id" at the exact moment a contact was added. - * - * None of them are hard bugs. They are all the same bug: a decoder and a - * producer that disagree, where the disagreement produces empty rather than an - * error, and empty looks exactly like "nothing to show yet". A test that feeds - * the relay's real response shape catches every one of them in a second, and - * there was no such test. - * - * So each decode test below is paired with a wrong-shape test asserting that - * the historical mistake yields nothing, which is what makes the pairing - * meaningful rather than decorative. */ class MessengerApiShapeTest { - private lateinit var server: MockWebServer - @BeforeTest fun start() { server = MockWebServer().also { it.start() } } - @AfterTest fun stop() { server.shutdown() } + @BeforeTest fun start() { + server = MockWebServer().also { it.start() } + } - private fun api() = MessengerApi( - server.url("/").toString().trimEnd('/'), "someone", "app-password") + @AfterTest fun stop() { + server.shutdown() + } - private fun json(body: String) = MockResponse() - .setResponseCode(200).setHeader("Content-Type", "application/json").setBody(body) + private fun api() = MessengerApi(server.url("/").toString().trimEnd('/'), "someone", "app-password") - // -- Roster -------------------------------------------------------------- + private fun json(body: String) = + MockResponse() + .setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody(body) + + // -- Roster --------------------------------------------------------------. @Test fun `the roster decodes the relay's actual field names`() { - server.enqueue(json(""" + server.enqueue( + json( + """ {"buddies":[ {"id":7,"display_name":"Ada L","screen_name":"ada","presence":"away","accepted":true}, {"id":8,"display_name":"Bob","screen_name":"bob","presence":"online","accepted":false} - ]}""")) + ]}""", + ), + ) val list = api().buddies() assertEquals(2, list.size) @@ -66,10 +57,7 @@ class MessengerApiShapeTest { } /** - * Defaults to true, and that default is the compatibility story. A - * relay predating the field says nothing about acceptance, and the only - * safe reading of silence is "these are your contacts", the alternative - * labels every contact on every older relay a stranger. + * Defaults to true, and that default is the compatibility story. */ @Test fun `a relay that predates acceptance does not turn every contact into a stranger`() { @@ -106,12 +94,10 @@ class MessengerApiShapeTest { assertEquals("/api/flamenet/v1/messenger/buddies", server.takeRequest().path) } - // -- Blocks -------------------------------------------------------------- + // -- Blocks --------------------------------------------------------------. /** - * The bug: this parsed `user_id`, the relay sends `id`. The Block button - * did nothing, and it survived because the list it produced was always - * empty, so nothing ever disagreed with it. + * The bug: this parsed `user_id`, the relay sends `id`. */ @Test fun `the block list decodes the id field the relay actually sends`() { @@ -134,20 +120,22 @@ class MessengerApiShapeTest { assertEquals("/api/flamenet/v1/messenger/blocks/11", unblockReq.path) } - // -- Notifications ------------------------------------------------------- + // -- Notifications -------------------------------------------------------. /** - * This read `kind`/`body`/`read` for months. Those names exist nowhere, - * so every alert decoded as an empty unread row and the screen looked - * permanently blank, the failure mode of a decode mismatch is silence. + * This read `kind`/`body`/`read` for months. */ @Test fun `notifications decode type message and is_read`() { - server.enqueue(json(""" + server.enqueue( + json( + """ {"notifications":[ {"id":1,"type":"buddy_request","message":"Ada added you","is_read":false}, {"id":2,"type":"message","message":"New message","is_read":true} - ]}""")) + ]}""", + ), + ) val list = api().notifications() assertEquals(2, list.size) @@ -160,8 +148,12 @@ class MessengerApiShapeTest { /** The exact historical mistake, asserted to produce nothing useful. */ @Test fun `the old field names decode to a blank unread row`() { - server.enqueue(json(""" - {"notifications":[{"id":1,"kind":"buddy_request","body":"Ada added you","read":true}]}""")) + server.enqueue( + json( + """ + {"notifications":[{"id":1,"kind":"buddy_request","body":"Ada added you","read":true}]}""", + ), + ) val n = api().notifications().single() assertEquals("", n.kind) assertEquals("", n.body) @@ -174,13 +166,10 @@ class MessengerApiShapeTest { assertEquals(0, api().notifications().size) } - // -- Lookup by id -------------------------------------------------------- + // -- Lookup by id --------------------------------------------------------. /** - * This is how a message from a stranger becomes visible. An E2E envelope - * names its sender by id only, so without this a first contact from someone - * who added US arrived, decrypted, was filed and counted as unread, and - * never appeared anywhere. + * This is how a message from a stranger becomes visible. */ @Test fun `a user lookup names the sender`() { @@ -203,7 +192,7 @@ class MessengerApiShapeTest { assertNull(api().userInfo(7)) } - // -- Contact management -------------------------------------------------- + // -- Contact management --------------------------------------------------. @Test fun `adding a contact posts the username and the auto flag`() { @@ -219,14 +208,19 @@ class MessengerApiShapeTest { /** * The auto flag is what separates "the user typed this name" from "they - * wrote to us first". Defaulting it wrong would file strangers as chosen - * contacts. + * wrote to us first". */ @Test fun `adding a contact is not automatic unless asked`() { server.enqueue(json("""{"ok":true}""")) api().addBuddy("ada") - assertTrue(server.takeRequest().body.readUtf8().contains("\"auto\":false")) + assertTrue( + server + .takeRequest() + .body + .readUtf8() + .contains("\"auto\":false"), + ) } @Test @@ -259,13 +253,10 @@ class MessengerApiShapeTest { assertEquals("/api/flamenet/v1/messenger/buddies", req.path) } - // -- Presence ------------------------------------------------------------ + // -- Presence ------------------------------------------------------------. /** - * Return the server's answer, never the value we asked for. The - * relay used to accept an unknown status with 200 and store nothing, so a - * caller that assumed success showed a state the server had discarded, - * "offline" on this screen while every contact still saw us online. + * Return the server's answer, never the value we asked for. */ @Test fun `setting a status reports what the relay stored, not what we asked for`() { @@ -291,7 +282,7 @@ class MessengerApiShapeTest { assertEquals("away", api().currentStatus()) } - // -- The account itself -------------------------------------------------- + // -- The account itself --------------------------------------------------. @Test fun `me decodes the account route's own field names`() { diff --git a/client/src/test/kotlin/io/flamenet/client/MultiMailboxPollTest.kt b/client/src/test/kotlin/io/flamenet/client/MultiMailboxPollTest.kt index a524471..97c6bb3 100644 --- a/client/src/test/kotlin/io/flamenet/client/MultiMailboxPollTest.kt +++ b/client/src/test/kotlin/io/flamenet/client/MultiMailboxPollTest.kt @@ -18,24 +18,24 @@ import kotlin.test.assertTrue /** * Reading mail from more than one relay (spec §20.4). - * - * Publishing a home record is only half of it. The record tells senders - * to deliver a copy everywhere and the relay fans out, but a copy sitting in a - * mailbox nothing reads is storage, not redundancy. These tests are about the - * half that reads, and about the three ways it goes wrong quietly: a copy read - * twice, one relay's cursor applied to another's ids, and a backup mailbox's - * bad day taking the signed-in relay down with it. - * - * Real ciphertext throughout: a real peer, a real ratchet, real envelopes. Only - * the HTTP is a mock. */ class MultiMailboxPollTest { - private class MapStorage : Vault.Storage { val records = LinkedHashMap() + override fun read(name: String) = records[name] - override fun write(name: String, bytes: ByteArray) { records[name] = bytes } - override fun delete(name: String) { records.remove(name) } + + override fun write( + name: String, + bytes: ByteArray, + ) { + records[name] = bytes + } + + override fun delete(name: String) { + records.remove(name) + } + override fun names() = records.keys.toList() } @@ -47,6 +47,7 @@ class MultiMailboxPollTest { /** What each mailbox is holding: id to payload, plus an envelope id. */ private val homeQueue = mutableListOf>() private val backupQueue = mutableListOf>() + /** Set to make the backup answer 410, a device revoked at a BACKUP home. */ private var backupGone = false private var homeDown = false @@ -55,20 +56,33 @@ class MultiMailboxPollTest { private val backupHost get() = "backup.example.com" @BeforeTest fun start() { - home = MockWebServer().also { it.dispatcher = queueOf({ homeQueue }, { homeDown }); it.start() } - backup = MockWebServer().also { it.dispatcher = queueOf({ backupQueue }, { backupGone }); it.start() } - vault = Vault(MapStorage(), - KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()) + home = + MockWebServer().also { + it.dispatcher = queueOf({ homeQueue }, { homeDown }) + it.start() + } + backup = + MockWebServer().also { + it.dispatcher = queueOf({ backupQueue }, { backupGone }) + it.start() + } + vault = + Vault( + MapStorage(), + KeyGenerator.getInstance("AES").apply { init(256) }.generateKey(), + ) me = DeviceIdentity.create() } - @AfterTest fun stop() { home.shutdown(); backup.shutdown() } + @AfterTest fun stop() { + home.shutdown() + backup.shutdown() + } /** - * A relay that serves one mailbox. - * - * 404s the transparency-log routes: these fixtures are pre-§19 - * deployments, so the tests are about mailboxes and nothing else. + * A relay that serves one mailbox. 404s the transparency-log routes: these + * fixtures are pre-§19 deployments, so the tests are about mailboxes and + * nothing else. */ private fun queueOf( rows: () -> List>, @@ -79,47 +93,70 @@ class MultiMailboxPollTest { if ("/e2e/log/" in path) return MockResponse().setResponseCode(404) if ("/e2e/messages" in path) { if (gone()) return MockResponse().setResponseCode(410) - val after = Regex("after=(\\d+)").find(path)?.groupValues?.get(1)?.toLong() ?: 0 - val body = rows().filter { it.first > after }.joinToString(",") { (id, payload, env) -> - """{"id":$id,"from_user":7,"from_device":"","payload":"$payload","envelope_id":"$env"}""" - } + val after = + Regex("after=(\\d+)") + .find(path) + ?.groupValues + ?.get(1) + ?.toLong() ?: 0 + val body = + rows().filter { it.first > after }.joinToString(",") { (id, payload, env) -> + """{"id":$id,"from_user":7,"from_device":"","payload":"$payload","envelope_id":"$env"}""" + } return json("""{"messages":[$body],"cursor":0}""") } return MockResponse().setResponseCode(404) } } - private fun json(body: String) = MockResponse() - .setResponseCode(200).setHeader("Content-Type", "application/json").setBody(body) + private fun json(body: String) = + MockResponse() + .setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody(body) private fun conversations(): Conversations { - // Written into the vault rather than passed in: the client rebuilds - // its list of homes from the vault on every poll, which is what lets a + // Written into the vault rather than passed in: the client rebuilds its + // list of homes from the vault on every poll, which is what lets a // mailbox added or dropped mid-session take effect without a relaunch. - vault.saveExtraHomes(listOf(Vault.ExtraHome( - relay = backupHost, username = "me", appPassword = "pw", - // The record names a hostname and cannot carry a port; how this - // device DIALS that host is a separate field, which is what lets a - // test, or a self-hoster on a nonstandard port, work without the - // record lying about its own name. - baseUrl = backup.url("/").toString()))) + vault.saveExtraHomes( + listOf( + Vault.ExtraHome( + relay = backupHost, + username = "me", + appPassword = "pw", + // The record names a hostname and cannot carry a port; how this + // device DIALS that host is a separate field, which is what lets a + // test, or a self-hoster on a nonstandard port, work without the + // record lying about its own name. + baseUrl = backup.url("/").toString(), + ), + ), + ) return Conversations(me, vault, RelayClient(home.url("/").toString(), "me", "pw")) } private fun myBundle(): PeerBundle { val opk = me.generateOneTimePreKeys(1).first() - return PeerBundle.fromWire(me.registrationWire().put("opk", - JSONObject().put("id", opk.id).put("pub", Wire.b64(opk.publicKey)))) + return PeerBundle.fromWire( + me.registrationWire().put( + "opk", + JSONObject().put("id", opk.id).put("pub", Wire.b64(opk.publicKey)), + ), + ) } /** One real envelope from a peer, ready to be put in a mailbox. */ - private fun envelope(session: E2ESession, text: String) = session.encrypt(text).toPayload() + private fun envelope( + session: E2ESession, + text: String, + ) = session.encrypt(text).toPayload() // MARK: - Reading /** - *A relay this device is not signed in to is holding - * mail, and without this the message is simply never read. + * A relay this device is not signed in to is holding mail, and without this + * the message is simply never read. */ @Test fun `a message waiting at another relay is read`() { @@ -134,10 +171,7 @@ class MultiMailboxPollTest { } /** - * A copy at every home is the design, so a copy read twice is the - * failure. And it would not present as a duplicate: the ratchet deletes a - * message key once used, so the second copy fails to open and is logged as - * a dropped envelope, a message that looks lost rather than repeated. + * A copy at every home is the design, so a copy read twice is the failure. */ @Test fun `the same message at two relays is read once`() { @@ -151,14 +185,16 @@ class MultiMailboxPollTest { val received = conversations().poll() - assertEquals(1, received.count { it.text == "delivered twice" }, - "one message, however many mailboxes carried it") + assertEquals( + 1, + received.count { it.text == "delivered twice" }, + "one message, however many mailboxes carried it", + ) } /** * Envelope ids come from each relay's own database, so id 900 there says - * nothing about id 900 here. One shared cursor would let the first - * mailbox's progress silently skip the second's mail. + * nothing about id 900 here. */ @Test fun `each mailbox keeps its own cursor`() { @@ -170,8 +206,11 @@ class MultiMailboxPollTest { assertEquals(1, conversations.poll().size) assertEquals(900L, vault.watermarkFor(backupHost), "the backup's cursor has to move") - assertEquals(0L, vault.inboxWatermark, - "one relay's ids must not advance another relay's cursor") + assertEquals( + 0L, + vault.inboxWatermark, + "one relay's ids must not advance another relay's cursor", + ) // And a second poll does not re-read what the backup already gave us. assertTrue(conversations.poll().isEmpty(), "a second poll re-read the same mailbox") @@ -180,9 +219,7 @@ class MultiMailboxPollTest { // MARK: - When something goes wrong /** - * A 410 from a BACKUP home is that mailbox ending, not this account - * ending. Treating it like the signed-in relay's 410 would purge - * somebody's messages because one spare relay dropped a registration. + * A 410 from a BACKUP home is that mailbox ending, not this account ending. */ @Test fun `a revoked backup mailbox does not stop the signed-in one`() { @@ -195,10 +232,7 @@ class MultiMailboxPollTest { } /** - * The failure this whole feature exists to survive. A home relay that - * is down must not take every other mailbox down with it, which is exactly - * what happens if the poll returns on the first error instead of carrying - * on to the others. + * The failure this whole feature exists to survive. */ @Test fun `a dead home relay does not hide the backup mailbox`() { @@ -211,17 +245,18 @@ class MultiMailboxPollTest { } /** - * A failed poll is still not an empty inbox. "No new messages" and - * "could not reach anything" are different, and the app says different - * words for them, so when nothing at all could be read, the failure is - * raised rather than swallowed into an empty list. + * A failed poll is still not an empty inbox. */ @Test fun `when no mailbox can be read at all the failure is raised`() { homeDown = true backupGone = true var threw = false - try { conversations().poll() } catch (_: Exception) { threw = true } + try { + conversations().poll() + } catch (_: Exception) { + threw = true + } assertTrue(threw, "every mailbox failed and it was reported as an empty inbox") } diff --git a/client/src/test/kotlin/io/flamenet/client/Obfs4RoutingTest.kt b/client/src/test/kotlin/io/flamenet/client/Obfs4RoutingTest.kt index 5dd098f..a2d5b4a 100644 --- a/client/src/test/kotlin/io/flamenet/client/Obfs4RoutingTest.kt +++ b/client/src/test/kotlin/io/flamenet/client/Obfs4RoutingTest.kt @@ -12,18 +12,12 @@ import kotlin.test.assertTrue /** * The seam that carries the setting to the ten places that build HTTP clients. - * - * The bug this is for: obfs4 was reachable from nothing. Every client in - * the app took `defaultHttpClient()`'s null default, so the transport that had - * been built, tested and proven against lyrebird was not on any code path a - * user could reach. Threading a bridge through each construction site would - * have worked until the eleventh was added, and the eleventh would connect - * directly, successfully, with nothing to show for it. */ class Obfs4RoutingTest { - - private fun bridge(host: String = "198.51.100.7", port: Int = 4444) = - Obfs4Bridge(host, port, ByteArray(20) { 1 }, ByteArray(32) { 2 }) + private fun bridge( + host: String = "198.51.100.7", + port: Int = 4444, + ) = Obfs4Bridge(host, port, ByteArray(20) { 1 }, ByteArray(32) { 2 }) @AfterTest fun clearRouting() = Obfs4Routing.use(null) @@ -44,11 +38,8 @@ class Obfs4RoutingTest { } /** - * The factory is installed whether or not a bridge is set, so the - * no-bridge path has to be an ordinary socket. If it were an - * `Obfs4Socket` pointed at nothing, installing the factory would break the - * app for the overwhelming majority who never configure a bridge, and it - * would break it at connect time, on the network, not at build time. + * The factory is installed whether or not a bridge is set, so the no-bridge + * path has to be an ordinary socket. */ @Test fun `with no bridge the factory hands out a plain socket`() { @@ -71,9 +62,6 @@ class Obfs4RoutingTest { /** * The factory reads the setting per socket, not once at construction. - * A client is built long before the user opens settings; a factory that - * captured the bridge at build time would keep using whatever was in force - * then, including nothing at all, for the life of the process. */ @Test fun `a factory built before the bridge was set still picks it up`() { @@ -89,16 +77,18 @@ class Obfs4RoutingTest { } /** - * The default client must carry the routing factory, or none of the - * above reaches anything the app actually uses. + * The default client must carry the routing factory, or none of the above + * reaches anything the app actually uses. */ @Test fun `the default http client routes through the holder`() { Obfs4Routing.use(bridge()) val client = RelayClient.defaultHttpClient() val socket = client.socketFactory.createSocket() - assertTrue(socket is Obfs4Socket, - "the app's default client ignored the configured bridge") + assertTrue( + socket is Obfs4Socket, + "the app's default client ignored the configured bridge", + ) socket.close() } @@ -112,17 +102,17 @@ class Obfs4RoutingTest { } /** - * Pooled connections outlive the setting that created them. OkHttp - * keeps a connection alive across requests, so one opened through the old - * bridge would go on serving requests for minutes after the user switched, - * or after they turned a bridge OFF, which is the dangerous direction. + * Pooled connections outlive the setting that created them. */ @Test fun `changing the bridge empties the connection pool`() { Obfs4Routing.use(bridge(host = "198.51.100.7")) Obfs4Routing.use(bridge(host = "203.0.113.9")) - assertEquals(0, Obfs4Routing.pool.connectionCount(), - "connections committed to the previous transport were kept") + assertEquals( + 0, + Obfs4Routing.pool.connectionCount(), + "connections committed to the previous transport were kept", + ) } /** Setting the same bridge twice must not tear down live connections. */ diff --git a/client/src/test/kotlin/io/flamenet/client/RelayAccountsTest.kt b/client/src/test/kotlin/io/flamenet/client/RelayAccountsTest.kt index d5c5041..38c753c 100644 --- a/client/src/test/kotlin/io/flamenet/client/RelayAccountsTest.kt +++ b/client/src/test/kotlin/io/flamenet/client/RelayAccountsTest.kt @@ -12,31 +12,29 @@ import kotlin.test.assertTrue /** * Sign-up, sign-in and password reset against a relay that is its own identity * provider. - * - * This whole class was at 0%, every route a person meets before they - * can send a single message, including the error mapping that decides what they - * are told when it goes wrong. Nothing here had ever been executed by a test. - * - * The error mapping deserves the attention as much as the happy path does. - * These are the only failures a person sees before they have an account, and a - * 409 shown as "something went wrong" instead of "that name is taken" is the - * difference between fixing it in five seconds and giving up. */ class RelayAccountsTest { - private lateinit var server: MockWebServer - @BeforeTest fun start() { server = MockWebServer().also { it.start() } } - @AfterTest fun stop() { server.shutdown() } + @BeforeTest fun start() { + server = MockWebServer().also { it.start() } + } + + @AfterTest fun stop() { + server.shutdown() + } private fun accounts() = RelayAccounts(server.url("/").toString().trimEnd('/')) - private fun json(body: String) = MockResponse() - .setResponseCode(200).setHeader("Content-Type", "application/json").setBody(body) + private fun json(body: String) = + MockResponse() + .setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody(body) private val ok = """{"user_id":7,"username":"ada","display_name":"Ada L","app_password":"abcd efgh"}""" - // -- Sign-up ------------------------------------------------------------- + // -- Sign-up -------------------------------------------------------------. @Test fun `signing up returns the credential the relay minted`() { @@ -67,7 +65,13 @@ class RelayAccountsTest { fun `the display name falls back to the username`() { server.enqueue(json(ok)) accounts().signUp("ada", "ada@example.com", "hunter2hunter2") - assertTrue(server.takeRequest().body.readUtf8().contains("\"display_name\":\"ada\"")) + assertTrue( + server + .takeRequest() + .body + .readUtf8() + .contains("\"display_name\":\"ada\""), + ) } /** @@ -80,12 +84,10 @@ class RelayAccountsTest { assertEquals("ada", accounts().signIn("ada", "pw").displayName) } - // -- Sign-in ------------------------------------------------------------- + // -- Sign-in -------------------------------------------------------------. /** - * The field is "username or email" and always has been. A client - * that only sends usernames rejects half the people who typed exactly what - * they were asked for. + * The field is "username or email" and always has been. */ @Test fun `an email address is sent in the username field`() { @@ -102,12 +104,12 @@ class RelayAccountsTest { assertEquals("abcd efgh", a.appPassword) } - // -- Shared session ------------------------------------------------------ + // -- Shared session ------------------------------------------------------. /** - * The handle travels BESIDE the token, not inside it: the account - * service will not put a handle in a token, so a relay cannot correlate its - * users with another product's. + * The handle travels BESIDE the token, not inside it: the account service + * will not put a handle in a token, so a relay cannot correlate its users + * with another product's. */ @Test fun `a shared session sends the handle alongside the token`() { @@ -125,13 +127,18 @@ class RelayAccountsTest { fun `a shared session omits an empty display name rather than sending blank`() { server.enqueue(json(ok)) accounts().sharedSession("tok-123", "ada") - assertTrue(!server.takeRequest().body.readUtf8().contains("display_name")) + assertTrue( + !server + .takeRequest() + .body + .readUtf8() + .contains("display_name"), + ) } /** * A self-hosted relay answers 404 here, and that is the CORRECT answer - * rather than a misconfiguration, it is its own identity provider. The - * caller has to be able to tell that apart from a broken relay. + * rather than a misconfiguration, it is its own identity provider. */ @Test fun `a relay that is its own identity provider reports not-found distinctly`() { @@ -139,17 +146,16 @@ class RelayAccountsTest { assertFailsWith { accounts().sharedSession("tok", "ada") } } - // -- Password reset ------------------------------------------------------ + // -- Password reset ------------------------------------------------------. /** - * The relay answers identically whatever the truth is, unknown handle, - * no address on file, an account that authenticates elsewhere. So this - * cannot report whether it worked, and a UI that claims it can is lying. + * The relay answers identically whatever the truth is, unknown handle, no + * address on file, an account that authenticates elsewhere. */ @Test fun `requesting a reset says nothing about whether the account exists`() { server.enqueue(json("""{"ok":true}""")) - accounts().requestPasswordReset("ada") // returns Unit, by design + accounts().requestPasswordReset("ada") // returns Unit, by design val req = server.takeRequest() assertEquals("/account/reset/request", req.path) assertTrue(req.body.readUtf8().contains("\"handle\":\"ada\"")) @@ -166,72 +172,77 @@ class RelayAccountsTest { assertTrue(body.contains("\"password\":\"newpassword1\"")) } - // -- What people are told when it fails ---------------------------------- + // -- What people are told when it fails ----------------------------------. /** - * The relay puts a human-readable reason in the body, and it is better - * than anything this layer could invent from a status code. Losing it means - * "that name is taken" becomes "something went wrong". + * The relay puts a human-readable reason in the body, and it is better than + * anything this layer could invent from a status code. */ @Test fun `a taken username keeps the relay's own explanation`() { - server.enqueue(MockResponse().setResponseCode(409) - .setBody("""{"message":"That name is already taken."}""")) - val e = assertFailsWith { - accounts().signUp("ada", "ada@example.com", "hunter2hunter2") - } + server.enqueue( + MockResponse() + .setResponseCode(409) + .setBody("""{"message":"That name is already taken."}"""), + ) + val e = + assertFailsWith { + accounts().signUp("ada", "ada@example.com", "hunter2hunter2") + } assertEquals(409, e.status) assertTrue(e.message!!.contains("already taken")) } @Test fun `a bad password is unauthorized rather than a generic failure`() { - server.enqueue(MockResponse().setResponseCode(401) - .setBody("""{"message":"Wrong password."}""")) - val e = assertFailsWith { - accounts().signIn("ada", "wrong") - } - // The relay's text lives in `body`; `message` is fixed. The type is - // a DECISION. Unauthorized triggers exactly one retry, and a caller - // matching on a string to decide that eventually matches wrong. + server.enqueue( + MockResponse() + .setResponseCode(401) + .setBody("""{"message":"Wrong password."}"""), + ) + val e = + assertFailsWith { + accounts().signIn("ada", "wrong") + } + // The relay's text lives in `body`; `message` is fixed. assertTrue(e.body.contains("Wrong password")) } @Test fun `throttling carries the retry-after the relay gave`() { server.enqueue(MockResponse().setResponseCode(429).setHeader("Retry-After", "90")) - val e = assertFailsWith { - accounts().signIn("ada", "hunter2hunter2") - } + val e = + assertFailsWith { + accounts().signIn("ada", "hunter2hunter2") + } assertEquals(90, e.retryAfterSeconds) } @Test fun `throttling without a retry-after is still throttling`() { server.enqueue(MockResponse().setResponseCode(429)) - val e = assertFailsWith { - accounts().signIn("ada", "hunter2hunter2") - } + val e = + assertFailsWith { + accounts().signIn("ada", "hunter2hunter2") + } assertEquals(null, e.retryAfterSeconds) } /** - * The geo-gate, not the relay. It needs a sentence, not a number, a - * person blocked by region who is shown "error 451" has no idea what - * happened or that nothing is wrong with their account. + * The geo-gate, not the relay. */ @Test fun `a region block is reported as unavailable with its explanation`() { - server.enqueue(MockResponse().setResponseCode(451) - .setBody("""{"message":"Not available in your region."}""")) - val e = assertFailsWith { - accounts().signUp("ada", "ada@example.com", "hunter2hunter2") - } - // The message is deliberately OURS, not the relay's. The download - // site is not geo-gated, so somebody can read the page, install the - // app, pick a password, tap sign up and be told "relay returned 451", - // which mentions nothing about their country and cannot be retried - // past, so the only sensible reading is that the app is broken. + server.enqueue( + MockResponse() + .setResponseCode(451) + .setBody("""{"message":"Not available in your region."}"""), + ) + val e = + assertFailsWith { + accounts().signUp("ada", "ada@example.com", "hunter2hunter2") + } + // The message is deliberately OURS, not the relay's. assertTrue(e.message!!.contains("isn't available in your country")) assertTrue(e.message!!.contains("isn't a problem with your account")) assertTrue(e.body.contains("region")) @@ -241,9 +252,10 @@ class RelayAccountsTest { @Test fun `a non-JSON error body is passed through as the message`() { server.enqueue(MockResponse().setResponseCode(500).setBody("upstream exploded")) - val e = assertFailsWith { - accounts().signIn("ada", "hunter2hunter2") - } + val e = + assertFailsWith { + accounts().signIn("ada", "hunter2hunter2") + } assertEquals(500, e.status) assertTrue(e.message!!.contains("upstream exploded")) } diff --git a/client/src/test/kotlin/io/flamenet/client/RelayClientTest.kt b/client/src/test/kotlin/io/flamenet/client/RelayClientTest.kt index 7f87a9c..0e0dc8f 100644 --- a/client/src/test/kotlin/io/flamenet/client/RelayClientTest.kt +++ b/client/src/test/kotlin/io/flamenet/client/RelayClientTest.kt @@ -20,44 +20,33 @@ import kotlin.test.fail /** * Against a real HTTP server, not a hand-written fake. - * - * A fake written alongside the client agrees with it about everything, - * including the field name it got wrong, which is exactly how this protocol's - * one shipped bug survived until a live round trip. MockWebServer at least - * makes the client produce bytes and parse responses. */ class RelayClientTest { - private lateinit var server: MockWebServer - @BeforeTest fun start() { server = MockWebServer().also { it.start() } } - @AfterTest fun stop() { server.shutdown() } + @BeforeTest fun start() { + server = MockWebServer().also { it.start() } + } - private fun client(): RelayClient = - RelayClient(server.url("/").toString(), "alice", "app pass word") + @AfterTest fun stop() { + server.shutdown() + } - private fun json(body: String) = MockResponse() - .setResponseCode(200) - .setHeader("Content-Type", "application/json") - .setBody(body) + private fun client(): RelayClient = RelayClient(server.url("/").toString(), "alice", "app pass word") + + private fun json(body: String) = + MockResponse() + .setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody(body) /** * The next request the client made. - * - * Bounded on purpose. `takeRequest()` with no timeout blocks forever, and - * because token minting is lazy, `useRelayAuth` does not mint, the - * first authenticated call does, it is easy to wait for a request that has - * not happened yet. That reads as a hung build, not a failing test. */ - private fun next(): RecordedRequest = - server.takeRequest(5, TimeUnit.SECONDS) ?: fail("no request arrived") + private fun next(): RecordedRequest = server.takeRequest(5, TimeUnit.SECONDS) ?: fail("no request arrived") /** * A client with a token response already queued. - * - * The mint has NOT happened when this returns, it happens on the first - * authenticated call, so `/account/token` is the first request to read - * back, after whatever the test then does. */ private fun authedClient(): Pair { val identity = DeviceIdentity.create() @@ -67,7 +56,7 @@ class RelayClientTest { return client to identity } - // -- Field names --------------------------------------------------------- + // -- Field names ---------------------------------------------------------. @Test fun `registering a device sends the names the relay expects`() { @@ -86,29 +75,33 @@ class RelayClientTest { assertEquals(Wire.b64(identity.identitySigningPublic), body.getString("ik_sig")) assertEquals(identity.signedPreKeyId, body.getInt("spk_id")) assertTrue(body.has("spk") && body.has("spk_sig")) - // `prekeys`, not `opks`. The relay accepts a registration carrying - // the wrong name with a 200 and stores no prekeys, so the device is - // published and cannot be started with, a failure with no error. + // `prekeys`, not `opks`. assertEquals(2, body.getJSONArray("prekeys").length()) assertEquals(preKeys[0].id, body.getJSONArray("prekeys").getJSONObject(0).getInt("id")) // The published prekey signature must verify under the published // identity key, or every peer refuses the bundle. - assertTrue(Ed25519.verify( - Wire.unb64(body.getString("ik_sig")), - Wire.unb64(body.getString("spk_sig")), - Wire.unb64(body.getString("spk")), - )) + assertTrue( + Ed25519.verify( + Wire.unb64(body.getString("ik_sig")), + Wire.unb64(body.getString("spk_sig")), + Wire.unb64(body.getString("spk")), + ), + ) } @Test fun `polling the inbox asks the right question and reads the answer`() { - server.enqueue(json(""" - {"messages":[ - {"id":7,"from_user":3,"from_device":"phone","payload":"cGF5"}, - {"id":9,"from_user":4,"from_device":"tablet","payload":"bG9hZA=="} - ]} - """.trimIndent())) + server.enqueue( + json( + """ + {"messages":[ + {"id":7,"from_user":3,"from_device":"phone","payload":"cGF5"}, + {"id":9,"from_user":4,"from_device":"tablet","payload":"bG9hZA=="} + ]} + """.trimIndent(), + ), + ) val messages = client().pollInbox("my-device", after = 6, limit = 50) @@ -124,16 +117,17 @@ class RelayClientTest { @Test fun `an application password pasted with spaces still authenticates`() { // The account service prints these in readable groups and users paste - // what they were shown. Sending the spaces is a 401 the user cannot - // diagnose, because the password on their screen looks correct. + // what they were shown. server.enqueue(json("{}")) client().logHead() val header = next().getHeader("Authorization")!! - assertEquals("alice:apppassword", - String(Wire.unb64(header.removePrefix("Basic ")), Charsets.UTF_8)) + assertEquals( + "alice:apppassword", + String(Wire.unb64(header.removePrefix("Basic ")), Charsets.UTF_8), + ) } - // -- Authentication ------------------------------------------------------ + // -- Authentication ------------------------------------------------------. @Test fun `a token is minted once and reused`() { @@ -151,10 +145,7 @@ class RelayClientTest { } /** - * The signature covers the LOGICAL §8 path, not the URL's path. The - * relay mounts these routes twice and signs against `/e2e/…`; signing the - * REST-prefixed path produces a valid signature over the wrong string, and - * the relay rejects it with no hint as to why. + * The signature covers the LOGICAL §8 path, not the URL's path. */ @Test fun `the proof signature covers the logical path, not the url path`() { @@ -162,7 +153,7 @@ class RelayClientTest { server.enqueue(json("{}")) client.logHead() - next() // the mint + next() // the mint val request = next() assertEquals("/api/flamenet/v1/e2e/log/head", request.path) assertTrue(verifyProof(request, identity, logicalPath = "/e2e/log/head", body = ByteArray(0))) @@ -175,7 +166,7 @@ class RelayClientTest { server.enqueue(json("""{"ok":true}""")) client.setDeliveryKey(ByteArray(32) { 3 }) - next() // the mint + next() // the mint val request = next() val body = request.body.readByteArray() assertTrue(verifyProof(request, identity, "/e2e/delivery-key", body)) @@ -197,7 +188,7 @@ class RelayClientTest { client.logHead() client.logHead() - next() // the mint + next() // the mint val a = next() val b = next() assertNotEquals(a.getHeader("X-FN-Nonce"), b.getHeader("X-FN-Nonce")) @@ -213,7 +204,7 @@ class RelayClientTest { assertEquals(4, client.logHead().getInt("size")) - next() // the first mint + next() // the first mint val rejected = next() val minted = next() val retried = next() @@ -234,8 +225,7 @@ class RelayClientTest { /** * A deployment with no token route is a supported configuration, not an - * error, and it must be asked exactly once. Asking on every request costs - * a round trip per call, forever, to learn the same thing. + * error, and it must be asked exactly once. */ @Test fun `a relay with no token endpoint falls back to Basic and is asked once`() { @@ -258,7 +248,7 @@ class RelayClientTest { assertEquals(3, server.requestCount) } - // -- Errors -------------------------------------------------------------- + // -- Errors --------------------------------------------------------------. @Test fun `status codes that mean something become the thing they mean`() { @@ -283,14 +273,16 @@ class RelayClientTest { assertFailsWith { client().listGroups() } } - // -- Bundles ------------------------------------------------------------- + // -- Bundles -------------------------------------------------------------. @Test fun `fetched bundles parse into something a session can use`() { val peer = DeviceIdentity.create() val oneTime = peer.generateOneTimePreKeys(1).first() - val wire = peer.registrationWire() - .put("opk", JSONObject().put("id", oneTime.id).put("pub", Wire.b64(oneTime.publicKey))) + val wire = + peer + .registrationWire() + .put("opk", JSONObject().put("id", oneTime.id).put("pub", Wire.b64(oneTime.publicKey))) server.enqueue(json("""{"devices":[$wire]}""")) // `fetchKeys` answers with the fnid alongside the bundles now, the @@ -303,13 +295,19 @@ class RelayClientTest { assertEquals(oneTime.id, bundles[0].oneTimePreKeyId) // The real proof that the parse is right: a session actually starts. - val session = io.flamenet.e2e.E2ESession.initiate(DeviceIdentity.create(), bundles[0]) + val session = + io.flamenet.e2e.E2ESession + .initiate(DeviceIdentity.create(), bundles[0]) val envelope = session.encrypt("through a fetched bundle") - assertEquals("through a fetched bundle", - io.flamenet.e2e.E2ESession.respond(peer, envelope).decrypt(envelope)) + assertEquals( + "through a fetched bundle", + io.flamenet.e2e.E2ESession + .respond(peer, envelope) + .decrypt(envelope), + ) } - // -- Helpers ------------------------------------------------------------- + // -- Helpers -------------------------------------------------------------. private fun verifyProof( request: RecordedRequest, @@ -317,15 +315,19 @@ class RelayClientTest { logicalPath: String, body: ByteArray, ): Boolean { - val digest = MessageDigest.getInstance("SHA-256").digest(body) - .joinToString("") { "%02x".format(it) } - val canonical = listOf( - request.method!!, - logicalPath, - request.getHeader("X-FN-Timestamp")!!, - request.getHeader("X-FN-Nonce")!!, - digest, - ).joinToString("\n").toByteArray(Charsets.UTF_8) + val digest = + MessageDigest + .getInstance("SHA-256") + .digest(body) + .joinToString("") { "%02x".format(it) } + val canonical = + listOf( + request.method!!, + logicalPath, + request.getHeader("X-FN-Timestamp")!!, + request.getHeader("X-FN-Nonce")!!, + digest, + ).joinToString("\n").toByteArray(Charsets.UTF_8) return Ed25519.verify( identity.identitySigningPublic, Wire.unb64(request.getHeader("X-FN-Signature")!!), diff --git a/client/src/test/kotlin/io/flamenet/client/RelayIdentityPinTest.kt b/client/src/test/kotlin/io/flamenet/client/RelayIdentityPinTest.kt index adbb19b..6ba57d6 100644 --- a/client/src/test/kotlin/io/flamenet/client/RelayIdentityPinTest.kt +++ b/client/src/test/kotlin/io/flamenet/client/RelayIdentityPinTest.kt @@ -8,27 +8,13 @@ import kotlin.test.assertTrue /** * Pinning the relay's identity (ios#19, Android half). - * - * The finding this closes is not a missing feature, it is a wrong - * conclusion. Android already pinned `issuerPublicKey`, inside - * `Trust.verifyHeadSignature`. When the key later changed, the head signature - * stopped verifying and the only conclusion available at that point was - * `KeyLogForkException`, so a rotation somebody performed on purpose was - * reported to the user as the relay showing two different histories. - * - * Those need different responses. A fork means nothing the relay says can be - * trusted. A rotation means somebody ran a command, or somebody else is - * answering on that address, and a person has to decide which. Reporting the - * second as the first is alarming and unactionable, and an alarm people cannot - * act on is one they learn to dismiss, which costs the real fork detection its - * credibility too. */ class RelayIdentityPinTest { - private fun key(byte: Int) = ByteArray(32) { byte.toByte() } + private fun b64(bytes: ByteArray): String = Base64.getEncoder().encodeToString(bytes) - // ---- The four verdicts ---------------------------------------------- + // ---- The four verdicts ----------------------------------------------. @Test fun `a published key with nothing pinned is first sight`() { @@ -46,8 +32,8 @@ class RelayIdentityPinTest { } /** - * The case the whole file exists for, and the one that used to be - * reported as a forked log. + * The case the whole file exists for, and the one that used to be reported + * as a forked log. */ @Test fun `a different key is a change, carrying both halves`() { @@ -58,10 +44,7 @@ class RelayIdentityPinTest { } /** - * A relay that publishes no key is a weaker deployment, not an - * attack. Somebody self-hosting an older build has nothing to pin, and - * refusing to talk to them would be a worse failure than the one this - * prevents. + * A relay that publishes no key is a weaker deployment, not an attack. */ @Test fun `a deployment that publishes nothing is not an attack`() { @@ -76,10 +59,7 @@ class RelayIdentityPinTest { } /** - * Broken is not hostile. A relay answering with rubbish counts as - * "not published" rather than as a change, treating a malformed field as - * an attack would raise an alarm nobody can act on, on a deployment that is - * simply misconfigured. + * Broken is not hostile. */ @Test fun `an unparseable key reads as not published, not as a change`() { @@ -104,12 +84,10 @@ class RelayIdentityPinTest { ) } - // ---- What somebody is told ------------------------------------------ + // ---- What somebody is told ------------------------------------------. /** - * The warning names BOTH possibilities and asserts neither. An alarm that - * claims an attack when a rotation is equally likely trains people to - * dismiss alarms, and the next one may be real. + * The warning names BOTH possibilities and asserts neither. */ @Test fun `the warning does not pretend to know which it is`() { @@ -121,17 +99,12 @@ class RelayIdentityPinTest { } /** - * The exception is not a fork exception, and that separation is the - * point of this change. A caller catching `KeyLogForkException` to decide - * "the log is untrustworthy" must not be handed a rotation. + * The exception is not a fork exception, and that separation is the point + * of this change. */ @Test fun `an identity change is not reported as a key-log fork`() { - // Held as the sealed base, deliberately. Typed as the concrete class - // the compiler folds `!is KeyLogForkException` to a constant and warns, - // which is the compiler proving the point statically, and also means the - // assertion would test nothing. Through the base type it is the runtime - // check a caller's `when` actually performs. + // Held as the sealed base, deliberately. val thrown: RelayException = RelayIdentityChangedException("relay.example.com") assertTrue( thrown !is KeyLogForkException, @@ -142,12 +115,11 @@ class RelayIdentityPinTest { assertTrue(identity.message!!.contains("rotated on purpose")) } - // ---- Shape ----------------------------------------------------------- + // ---- Shape -----------------------------------------------------------. /** * `ByteArray` has reference equality by default, so the verdicts carry - * hand-written `equals`. Without them every comparison in this suite would - * be false and the tests would have been written around it. + * hand-written `equals`. */ @Test fun `verdicts compare by content, not by reference`() { diff --git a/client/src/test/kotlin/io/flamenet/client/SendToAddressTest.kt b/client/src/test/kotlin/io/flamenet/client/SendToAddressTest.kt index 8f73bac..4e588b4 100644 --- a/client/src/test/kotlin/io/flamenet/client/SendToAddressTest.kt +++ b/client/src/test/kotlin/io/flamenet/client/SendToAddressTest.kt @@ -20,25 +20,24 @@ import kotlin.test.assertTrue /** * Sending to somebody on another relay (spec §20.5). - * - * Always sealed, and here that is not a preference. A relay that accepts - * a federated envelope stores it with no sender, it was not told and must not - * invent one, so an unsealed envelope arrives unattributable and is dropped. - * Locally, sealing is an improvement; across a boundary it is the only thing - * that makes the message deliverable at all. - * - * The other half is the return address. Their relay records none, and our user - * id means nothing on it, so a message without one decrypts on their phone and - * has nowhere to reply to, which reads to the recipient as the app being - * broken rather than as a message being incomplete. */ class SendToAddressTest { - private class MapStorage : Vault.Storage { val records = LinkedHashMap() + override fun read(name: String) = records[name] - override fun write(name: String, bytes: ByteArray) { records[name] = bytes } - override fun delete(name: String) { records.remove(name) } + + override fun write( + name: String, + bytes: ByteArray, + ) { + records[name] = bytes + } + + override fun delete(name: String) { + records.remove(name) + } + override fun names() = records.keys.toList() } @@ -52,45 +51,69 @@ class SendToAddressTest { private var accepted = 1 private var unknown = listOf() private var unreachable = listOf() + /** Devices the far relay reports for the address, empty for "nobody there". */ private var remoteDevices: () -> String = { "" } - private val theirCard = ContactCard( - "fnid:0123456789abcdefghjkmnpqrstvwxyz0123456789abcdefghjk", "peer.example.com") + private val theirCard = + ContactCard("fnid:0123456789abcdefghjkmnpqrstvwxyz0123456789abcdefghjk", "peer.example.com") @BeforeTest fun start() { me = DeviceIdentity.create() peer = DeviceIdentity.create() remoteDevices = { peer.registrationWire().toString() } - server = MockWebServer().also { it.dispatcher = relay(); it.start() } - vault = Vault(MapStorage(), - KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()) + server = + MockWebServer().also { + it.dispatcher = relay() + it.start() + } + vault = + Vault( + MapStorage(), + KeyGenerator.getInstance("AES").apply { init(256) }.generateKey(), + ) vault.saveIdentity(me) } @AfterTest fun stop() = server.shutdown() - private fun relay() = object : Dispatcher() { - override fun dispatch(request: RecordedRequest): MockResponse { - val path = request.path.orEmpty() - return when { - "/e2e/log/" in path -> MockResponse().setResponseCode(404) - "/e2e/send/remote" in path -> { - sentBody = JSONObject(request.body.readUtf8()) - json(JSONObject() - .put("ok", true).put("accepted", accepted) - .put("unknown", org.json.JSONArray(unknown)) - .put("unreachable", org.json.JSONArray(unreachable)).toString()) + private fun relay() = + object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse { + val path = request.path.orEmpty() + return when { + "/e2e/log/" in path -> { + MockResponse().setResponseCode(404) + } + + "/e2e/send/remote" in path -> { + sentBody = JSONObject(request.body.readUtf8()) + json( + JSONObject() + .put("ok", true) + .put("accepted", accepted) + .put("unknown", org.json.JSONArray(unknown)) + .put("unreachable", org.json.JSONArray(unreachable)) + .toString(), + ) + } + + "/e2e/remote/" in path -> { + json("""{"devices":[${remoteDevices()}]}""") + } + + else -> { + MockResponse().setResponseCode(404) + } } - "/e2e/remote/" in path -> - json("""{"devices":[${remoteDevices()}]}""") - else -> MockResponse().setResponseCode(404) } } - } - private fun json(body: String) = MockResponse() - .setResponseCode(200).setHeader("Content-Type", "application/json").setBody(body) + private fun json(body: String) = + MockResponse() + .setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody(body) private fun conversations(withAddress: Boolean = true): Conversations = Conversations(me, vault, RelayClient(server.url("/").toString(), "me", "pw")).also { @@ -116,18 +139,16 @@ class SendToAddressTest { val payload = messages.getJSONObject(0).getString("payload") assertTrue(SealedSender.isSealed(payload), "the envelope reached the wire unsealed") - // The return address is inside the seal, where the relay cannot read - // it and cannot strip it. - val opened = SealedSender.open( - SealedSender.blobFromPayload(payload), peer.identityDH.private, peer.deviceId) + // The return address is inside the seal, where the relay cannot read it + // and cannot strip it. + val opened = + SealedSender.open(SealedSender.blobFromPayload(payload), peer.identityDH.private, peer.deviceId) assertEquals("fnid:mine@home.example.com", opened.claimedFromAddress) assertEquals(me.deviceId, opened.claimedFromDevice) } /** - * One id per MESSAGE, not per device or per mailbox. A recipient polling - * several homes collapses the copies by it; a fresh id per copy would show - * them the same message once per mailbox. + * One id per MESSAGE, not per device or per mailbox. */ @Test fun `every copy of one message carries the same envelope id`() { @@ -140,8 +161,10 @@ class SendToAddressTest { val messages = sentBody!!.getJSONArray("messages") assertEquals(2, messages.length(), "both of their devices must be addressed") - assertEquals(messages.getJSONObject(0).getString("envelope_id"), - messages.getJSONObject(1).getString("envelope_id")) + assertEquals( + messages.getJSONObject(0).getString("envelope_id"), + messages.getJSONObject(1).getString("envelope_id"), + ) } /** The address is remembered, or the conversation is write-only. */ @@ -156,16 +179,18 @@ class SendToAddressTest { // MARK: - Refusals /** - * Refused up front rather than sent into a dead end. Their relay - * records no sender, so without our address the message decrypts on their - * phone and has nowhere to reply to. + * Refused up front rather than sent into a dead end. */ @Test fun `a device with no address of its own will not send`() { val sent = conversations(withAddress = false).sendToAddress(theirCard, "hello") assertFalse(sent.reachedSomeone) - assertTrue(sent.failed.values.single().contains("address"), - "the refusal has to say why: ${sent.failed}") + assertTrue( + sent.failed.values + .single() + .contains("address"), + "the refusal has to say why: ${sent.failed}", + ) } @Test @@ -176,9 +201,7 @@ class SendToAddressTest { } /** - * `accepted` first. Checking only the failure fields reported a send - * as successful when the far relay had taken nothing at all: the app said - * sent, the peer's inbox stayed empty, and no error anywhere explained it. + * `accepted` first. */ @Test fun `taking no copies is a failed send whatever else the reply says`() { @@ -193,30 +216,37 @@ class SendToAddressTest { accepted = 0 unknown = listOf(theirCard.fnid) val sent = conversations().sendToAddress(theirCard, "hello") - assertTrue(sent.failed.values.single().contains("does not know"), - "got: ${sent.failed}") + assertTrue( + sent.failed.values + .single() + .contains("does not know"), + "got: ${sent.failed}", + ) } /** - * Partial delivery is a real outcome once an account has several - * mailboxes, and silence about it is how somebody believes a message landed - * when a copy did not. + * Partial delivery is a real outcome once an account has several mailboxes, + * and silence about it is how somebody believes a message landed when a + * copy did not. */ @Test fun `a mailbox that did not take the copy is reported`() { unreachable = listOf("backup.example.com") val sent = conversations().sendToAddress(theirCard, "hello") assertTrue(sent.reachedSomeone, "the message did reach somewhere") - assertTrue(sent.failed.values.single().contains("backup.example.com"), - "got: ${sent.failed}") + assertTrue( + sent.failed.values + .single() + .contains("backup.example.com"), + "got: ${sent.failed}", + ) } // MARK: - Trust in the address keyspace /** * A key never seen before is either a new device of theirs or one their - * relay minted, and nothing on the wire tells those apart. The pin hangs - * off the address, because a user id on their relay means nothing on ours. + * relay minted, and nothing on the wire tells those apart. */ @Test fun `a changed identity key at an address refuses the send`() { diff --git a/client/src/test/kotlin/io/flamenet/client/SendToNobodyTest.kt b/client/src/test/kotlin/io/flamenet/client/SendToNobodyTest.kt index c9b3689..ccf690d 100644 --- a/client/src/test/kotlin/io/flamenet/client/SendToNobodyTest.kt +++ b/client/src/test/kotlin/io/flamenet/client/SendToNobodyTest.kt @@ -15,44 +15,38 @@ import kotlin.test.assertTrue /** * A message nobody can receive must not report success. - * - * This shipped, and it lost real messages. Signing out deregisters the - * device, so a signed-out recipient has no devices. Envelopes are - * addressed per device, so `sendPlaintext` looped over an empty bundle list, - * made no request at all, and returned `Sent(delivered = [], failed = {})`. - * Every caller reads an empty `failed` as "it went", the message was written - * into the sender's own thread and existed nowhere else. It was reported as - * "I wasn't signed in on the other end and the message got lost", and the same - * path is why a group text vanished: the group senders call this once per - * member. - * - * iOS never had this bug, `transmit` throws on an empty device list. - * The two platforms disagreed about a delivery guarantee for as long as both - * have existed, and nothing failed, because the only tests that covered - * sending had both ends signed in. That is the actual lesson here: a test that - * only exercises the happy path cannot tell a delivery from a no-op, since - * both of them are silent. - * - * So this asserts the *unhappy* path directly, on the seam where the two - * platforms drifted. */ class SendToNobodyTest { - private lateinit var server: MockWebServer - @BeforeTest fun start() { server = MockWebServer().also { it.start() } } - @AfterTest fun stop() { server.shutdown() } + @BeforeTest fun start() { + server = MockWebServer().also { it.start() } + } + + @AfterTest fun stop() { + server.shutdown() + } private class MapStorage : Vault.Storage { private val records = mutableMapOf() + override fun read(name: String) = records[name] - override fun write(name: String, bytes: ByteArray) { records[name] = bytes } - override fun delete(name: String) { records.remove(name) } + + override fun write( + name: String, + bytes: ByteArray, + ) { + records[name] = bytes + } + + override fun delete(name: String) { + records.remove(name) + } + override fun names() = records.keys.toList() } - private fun key(): SecretKey = - KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() + private fun key(): SecretKey = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() private fun conversations(): Conversations { val identity = DeviceIdentity.create() @@ -61,10 +55,11 @@ class SendToNobodyTest { return Conversations(identity, vault, relay) } - private fun json(body: String) = MockResponse() - .setResponseCode(200) - .setHeader("Content-Type", "application/json") - .setBody(body) + private fun json(body: String) = + MockResponse() + .setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody(body) @Test fun `sending to somebody with no registered devices is reported as failed`() { @@ -82,10 +77,7 @@ class SendToNobodyTest { } /** - * And it must not quietly post the envelope anyway. The bug was not only - * in the return value: with no devices there is nothing to address, so a - * request here would mean the relay was being handed an envelope for a - * device that does not exist. + * And it must not quietly post the envelope anyway. */ @Test fun `no envelope is posted when there is nobody to address`() { @@ -93,8 +85,11 @@ class SendToNobodyTest { conversations().send(toUserId = 99, text = "are you there?") - // Exactly one request: the device lookup. Nothing after it. - assertEquals(1, server.requestCount, - "a send to nobody should stop after the lookup") + // Exactly one request: the device lookup. + assertEquals( + 1, + server.requestCount, + "a send to nobody should stop after the lookup", + ) } } diff --git a/client/src/test/kotlin/io/flamenet/client/SignupFailureMappingTest.kt b/client/src/test/kotlin/io/flamenet/client/SignupFailureMappingTest.kt index 16847fa..a163481 100644 --- a/client/src/test/kotlin/io/flamenet/client/SignupFailureMappingTest.kt +++ b/client/src/test/kotlin/io/flamenet/client/SignupFailureMappingTest.kt @@ -10,21 +10,8 @@ import kotlin.test.assertTrue /** * What a refused sign-up is reported as. - * - * A 409 is not always a taken handle, and this used to say it was. The - * relay answers 409 for a taken handle, an already-registered email, and a - * handle too close to an existing one, three problems with three different - * fixes. Collapsing them told somebody whose EMAIL was registered to pick a - * different handle, which cannot work and leaves them no way to find out why. - * - * 400 was worse: every format refusal became "the account service returned - * 400", which is not a sentence anybody can act on. - * - * The status is the transport's opinion. The `code` in the body is the relay's, - * and it is the one that means something. */ class SignupFailureMappingTest { - private lateinit var server: MockWebServer @BeforeTest fun start() { @@ -39,11 +26,15 @@ class SignupFailureMappingTest { private fun client() = AccountsClient(server.url("/").toString()) /** Ask the server to refuse with a given code and status, and see what comes back. */ - private fun refusalFor(code: String, status: Int): AccountsClient.Failure { + private fun refusalFor( + code: String, + status: Int, + ): AccountsClient.Failure { server.enqueue( - MockResponse().setResponseCode(status) + MockResponse() + .setResponseCode(status) .setHeader("Content-Type", "application/json") - .setBody("""{"code":"$code","message":"refused"}""") + .setBody("""{"code":"$code","message":"refused"}"""), ) return try { client().register("someone", "passphrase-long-enough") @@ -55,24 +46,23 @@ class SignupFailureMappingTest { @Test fun `each refusal code maps to its own failure`() { - val cases = listOf( - Triple("username_taken", 409, AccountsClient.Failure.HandleTaken), - Triple("email_taken", 409, AccountsClient.Failure.EmailTaken), - Triple("username_confusable", 409, AccountsClient.Failure.HandleConfusable), - Triple("disposable_email", 400, AccountsClient.Failure.DisposableEmail), - Triple("bad_username", 400, AccountsClient.Failure.BadHandle), - Triple("bad_email", 400, AccountsClient.Failure.BadEmail), - Triple("weak_password", 400, AccountsClient.Failure.WeakPassword), - ) + val cases = + listOf( + Triple("username_taken", 409, AccountsClient.Failure.HandleTaken), + Triple("email_taken", 409, AccountsClient.Failure.EmailTaken), + Triple("username_confusable", 409, AccountsClient.Failure.HandleConfusable), + Triple("disposable_email", 400, AccountsClient.Failure.DisposableEmail), + Triple("bad_username", 400, AccountsClient.Failure.BadHandle), + Triple("bad_email", 400, AccountsClient.Failure.BadEmail), + Triple("weak_password", 400, AccountsClient.Failure.WeakPassword), + ) for ((code, status, expected) in cases) { assertEquals(expected, refusalFor(code, status), "$code should map to $expected") } } /** - * The regression this whole change exists for. An already-registered - * email must NOT be reported as a taken handle, the person would change - * their handle, be refused again, and have no way to work out why. + * The regression this whole change exists for. */ @Test fun `an already registered email is not reported as a taken handle`() { @@ -80,14 +70,12 @@ class SignupFailureMappingTest { assertEquals(AccountsClient.Failure.EmailTaken, failure) assertTrue( failure.message!!.contains("email"), - "the message should mention the email, not the handle: ${failure.message}" + "the message should mention the email, not the handle: ${failure.message}", ) } /** - * A relay newer than the app is the normal case, not the exception. An - * unknown code must still refuse, falling through to success would create - * an account the server never made. + * A relay newer than the app is the normal case, not the exception. */ @Test fun `an unknown refusal code still refuses`() { @@ -95,7 +83,7 @@ class SignupFailureMappingTest { val fourHundred = refusalFor("invented_next_year", 400) assertTrue( fourHundred is AccountsClient.Failure.Service, - "an unknown 400 should keep its status: $fourHundred" + "an unknown 400 should keep its status: $fourHundred", ) } @@ -103,9 +91,10 @@ class SignupFailureMappingTest { @Test fun `an unparseable body falls back to the status`() { server.enqueue( - MockResponse().setResponseCode(409) + MockResponse() + .setResponseCode(409) .setHeader("Content-Type", "text/html") - .setBody("a proxy wrote this") + .setBody("a proxy wrote this"), ) try { client().register("someone", "passphrase-long-enough") diff --git a/client/src/test/kotlin/io/flamenet/client/SignupProofOfWorkTest.kt b/client/src/test/kotlin/io/flamenet/client/SignupProofOfWorkTest.kt index ab521c3..5432aeb 100644 --- a/client/src/test/kotlin/io/flamenet/client/SignupProofOfWorkTest.kt +++ b/client/src/test/kotlin/io/flamenet/client/SignupProofOfWorkTest.kt @@ -3,6 +3,7 @@ package io.flamenet.client import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer import org.json.JSONObject +import java.security.MessageDigest import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test @@ -11,25 +12,11 @@ import kotlin.test.assertFailsWith import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue -import java.security.MessageDigest /** * The client half of the sign-up puzzle. - * - * The bit counting has to agree with the relay exactly. Both sides count - * leading zero *bits* of `SHA256("challenge.nonce")`. If this side counted - * bytes, every solution this phone produced would still be accepted, it would - * simply do hundreds of times the work, and no assertion about "at least N - * leading zeros" could see it, because overshooting also satisfies N. That is - * why the vectors below are pinned to exact values. - * - * The other half of the contract is that nothing here may prevent a - * sign-up. A relay that predates the feature, a request that times out, a - * proxy returning HTML, all of those mean "no work wanted", not "no account - * for you". */ class SignupProofOfWorkTest { - private lateinit var server: MockWebServer @BeforeTest fun start() { @@ -43,32 +30,35 @@ class SignupProofOfWorkTest { private fun url() = server.url("/").toString() - // ---------------------------------------------------------------- the arithmetic + // ---------------------------------------------------------------- the + // arithmetic. /** * The input is chosen so the answer is NOT a multiple of eight. - * `SHA256("pin754")` starts `000f d989…`, twelve zero bits, which a - * byte-counting implementation reports as eight. Almost every digest has no - * leading zeros at all and cannot tell the two apart. */ @Test fun `leading zeros are counted in bits, not bytes`() { - val digest = MessageDigest.getInstance("SHA-256") - .digest("pin754".toByteArray(Charsets.UTF_8)) - assertEquals(12, SignupProofOfWork.leadingZeroBits(digest), - "counted to a byte boundary instead of a bit") + val digest = + MessageDigest + .getInstance("SHA-256") + .digest("pin754".toByteArray(Charsets.UTF_8)) + assertEquals( + 12, + SignupProofOfWork.leadingZeroBits(digest), + "counted to a byte boundary instead of a bit", + ) } /** - * The exact nonce, not merely "a nonce that is good enough". A - * byte-rounding implementation still satisfies a 9-bit difficulty, it - * overshoots to 16 bits and returns 4416 instead of 19, which is 230x the - * work for the person signing up. + * The exact nonce, not merely "a nonce that is good enough". */ @Test fun `the first satisfying nonce is the one returned`() { - assertEquals("19", SignupProofOfWork.nonce("1700000000.c2FsdA.bWFj", bits = 9), - "solved a harder puzzle than the relay asked for") + assertEquals( + "19", + SignupProofOfWork.nonce("1700000000.c2FsdA.bWFj", bits = 9), + "solved a harder puzzle than the relay asked for", + ) } /** The same string the relay hashes: `challenge` then a dot then `nonce`. */ @@ -76,26 +66,35 @@ class SignupProofOfWorkTest { fun `a solved nonce satisfies the relay's own check`() { val challenge = "1700000000.c2FsdA.bWFj" val nonce = assertNotNull(SignupProofOfWork.nonce(challenge, bits = 12)) - val digest = MessageDigest.getInstance("SHA-256") - .digest("$challenge.$nonce".toByteArray(Charsets.UTF_8)) - assertTrue(SignupProofOfWork.leadingZeroBits(digest) >= 12, - "the relay would refuse this as unsolved") + val digest = + MessageDigest + .getInstance("SHA-256") + .digest("$challenge.$nonce".toByteArray(Charsets.UTF_8)) + assertTrue( + SignupProofOfWork.leadingZeroBits(digest) >= 12, + "the relay would refuse this as unsolved", + ) } /** - * A nonce solved for one challenge must not satisfy another, or the - * puzzle is solved once and reused for every registration in a farm. + * A nonce solved for one challenge must not satisfy another, or the puzzle + * is solved once and reused for every registration in a farm. */ @Test fun `a nonce does not transfer between challenges`() { val nonce = assertNotNull(SignupProofOfWork.nonce("challenge-one", bits = 12)) - val digest = MessageDigest.getInstance("SHA-256") - .digest("challenge-two.$nonce".toByteArray(Charsets.UTF_8)) - assertTrue(SignupProofOfWork.leadingZeroBits(digest) < 12, - "a nonce solved elsewhere satisfied this challenge") + val digest = + MessageDigest + .getInstance("SHA-256") + .digest("challenge-two.$nonce".toByteArray(Charsets.UTF_8)) + assertTrue( + SignupProofOfWork.leadingZeroBits(digest) < 12, + "a nonce solved elsewhere satisfied this challenge", + ) } - // ---------------------------------------------------------------- refusing to grind + // ---------------------------------------------------------------- refusing + // to grind. /** The ceiling is a real bound. A search that cannot finish must return. */ @Test @@ -104,14 +103,15 @@ class SignupProofOfWorkTest { } /** - * A difficulty above the cap is not attempted at all. Trusting a number - * from the network with a long loop is how an app comes to look hung. + * A difficulty above the cap is not attempted at all. */ @Test fun `a difficulty above the cap is not attempted`() { val absurd = SignupProofOfWork.Challenge("x", SignupProofOfWork.MAXIMUM_BITS + 1, 600) - assertNull(SignupProofOfWork.solve(absurd), - "ground on a difficulty the relay is not even allowed to ask for") + assertNull( + SignupProofOfWork.solve(absurd), + "ground on a difficulty the relay is not even allowed to ask for", + ) } /** `bits: 0` is how a relay says it wants none. That must cost nothing. */ @@ -120,38 +120,47 @@ class SignupProofOfWorkTest { assertNull(SignupProofOfWork.solve(SignupProofOfWork.Challenge("x", 0, 600))) } - // ---------------------------------------------------------------- never in the way + // ---------------------------------------------------------------- never in + // the way. /** Every one of these servers must still be able to create an account. */ @Test fun `every server that cannot offer a challenge still allows sign-up`() { - val cases = listOf( - "an older relay with no such route" to MockResponse().setResponseCode(404), - "a relay that is unwell" to MockResponse().setResponseCode(500), - "a proxy returning HTML with a 200" to - MockResponse().setResponseCode(200).setBody("not json"), - "an empty body" to MockResponse().setResponseCode(200).setBody(""), - "JSON with no challenge field" to - MockResponse().setResponseCode(200).setBody("""{"bits":10}"""), - ) + val cases = + listOf( + "an older relay with no such route" to MockResponse().setResponseCode(404), + "a relay that is unwell" to MockResponse().setResponseCode(500), + "a proxy returning HTML with a 200" to + MockResponse().setResponseCode(200).setBody("not json"), + "an empty body" to MockResponse().setResponseCode(200).setBody(""), + "JSON with no challenge field" to + MockResponse().setResponseCode(200).setBody("""{"bits":10}"""), + ) for ((name, response) in cases) { server.enqueue(response) - assertNull(SignupProofOfWork.satisfy(url()), - "$name should mean no work, not no account") + assertNull( + SignupProofOfWork.satisfy(url()), + "$name should mean no work, not no account", + ) } } /** And the ordinary case: an offer of real work is fetched, parsed and met. */ @Test fun `an offered challenge is fetched and solved`() { - server.enqueue(MockResponse().setResponseCode(200) - .setHeader("Content-Type", "application/json") - .setBody("""{"challenge":"1700000000.c2FsdA.bWFj","bits":10,"expires_in":600}""")) + server.enqueue( + MockResponse() + .setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody("""{"challenge":"1700000000.c2FsdA.bWFj","bits":10,"expires_in":600}"""), + ) val solution = assertNotNull(SignupProofOfWork.satisfy(url())) assertEquals("1700000000.c2FsdA.bWFj", solution.challenge) - val digest = MessageDigest.getInstance("SHA-256") - .digest("${solution.challenge}.${solution.nonce}".toByteArray(Charsets.UTF_8)) + val digest = + MessageDigest + .getInstance("SHA-256") + .digest("${solution.challenge}.${solution.nonce}".toByteArray(Charsets.UTF_8)) assertTrue(SignupProofOfWork.leadingZeroBits(digest) >= 10) } @@ -163,84 +172,86 @@ class SignupProofOfWorkTest { assertEquals("/account/challenge", server.takeRequest().path) } - // ---------------------------------------------------------------- what people read + // ---------------------------------------------------------------- what + // people read. private fun refuse(code: String) { - server.enqueue(MockResponse().setResponseCode(400) - .setHeader("Content-Type", "application/json") - .setBody("""{"code":"$code","message":"the relay said something"}""")) + server.enqueue( + MockResponse() + .setResponseCode(400) + .setHeader("Content-Type", "application/json") + .setBody("""{"code":"$code","message":"the relay said something"}"""), + ) } /** - * The whole reactive flow. Refused for a missing challenge, we go - * and get one, solve it, and send the same fields again with the answer. - * - * Three exchanges, in this order. The first sign-up carries no challenge - * at all: a relay with this feature off, every relay - * running today, sees exactly one request and nobody waits twice. + * The whole reactive flow. */ @Test fun `a relay that asks for work is answered and the sign-up goes through`() { refuse("challenge_required") - server.enqueue(MockResponse().setResponseCode(200) - .setHeader("Content-Type", "application/json") - .setBody("""{"challenge":"1700000000.c2FsdA.bWFj","bits":8,"expires_in":600}""")) - server.enqueue(MockResponse().setResponseCode(200) - .setHeader("Content-Type", "application/json") - .setBody("""{"user_id":7,"username":"ada","app_password":"credential"}""")) + server.enqueue( + MockResponse() + .setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody("""{"challenge":"1700000000.c2FsdA.bWFj","bits":8,"expires_in":600}"""), + ) + server.enqueue( + MockResponse() + .setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody("""{"user_id":7,"username":"ada","app_password":"credential"}"""), + ) val account = RelayAccounts(url()).signUp("ada", "ada@example.com", "passphrase") assertEquals("ada", account.username) val first = server.takeRequest() assertEquals("/account/signup", first.path) - assertTrue(!first.body.readUtf8().contains("challenge"), - "the first attempt should carry no challenge, that round trip is the cost") + assertTrue( + !first.body.readUtf8().contains("challenge"), + "the first attempt should carry no challenge, that round trip is the cost", + ) assertEquals("/account/challenge", server.takeRequest().path) val retry = JSONObject(server.takeRequest().body.readUtf8()) assertEquals("1700000000.c2FsdA.bWFj", retry.getString("challenge")) - // Every original field survives the retry. Dropping one here would look - // like a validation failure on a sign-up that was fine. + // Every original field survives the retry. assertEquals("ada", retry.getString("username")) assertEquals("ada@example.com", retry.getString("email")) assertEquals("passphrase", retry.getString("password")) } /** - * Rethrown, not swallowed. When this app cannot answer, a - * difficulty above the cap, or a relay that will not hand out a challenge, - * "update the app" is the true sentence, and failing with anything else - * sends somebody looking in the wrong place. + * Rethrown, not swallowed. */ @Test fun `an app that cannot answer is told to update`() { refuse("challenge_required") server.enqueue(MockResponse().setResponseCode(404)) - val failure = assertFailsWith { - RelayAccounts(url()).signUp("ada", "ada@example.com", "passphrase") - } + val failure = + assertFailsWith { + RelayAccounts(url()).signUp("ada", "ada@example.com", "passphrase") + } assertTrue(failure.message!!.contains("Update"), "no remedy offered: ${failure.message}") } /** * A different sentence from the one above, and it must stay different. - * This one is nearly always a challenge that expired while somebody was - * typing; the remedy is another tap, not a trip to the Play Store. */ @Test fun `an expired check is told to try again`() { refuse("challenge_failed") - val failure = assertFailsWith { - RelayAccounts(url()).signUp("ada", "ada@example.com", "passphrase") - } + val failure = + assertFailsWith { + RelayAccounts(url()).signUp("ada", "ada@example.com", "passphrase") + } assertTrue(failure.message!!.contains("again"), "no remedy offered: ${failure.message}") } /** - * The distinction that matters. A 400 is also a bad email, and telling - * somebody to update the app to fix a typo is worse than saying nothing. + * The distinction that matters. */ @Test fun `another 400 is not mistaken for a challenge problem`() { @@ -253,10 +264,11 @@ class SignupProofOfWorkTest { /** Neither sentence blames the person or their password. */ @Test fun `neither refusal reads as a credential problem`() { - val sentences = listOf( - RelayException.ChallengeRequired("").message!!, - RelayException.ChallengeFailed("").message!!, - ) + val sentences = + listOf( + RelayException.ChallengeRequired("").message!!, + RelayException.ChallengeFailed("").message!!, + ) for (text in sentences.map { it.lowercase() }) { for (misleading in listOf("password", "passphrase", "incorrect", "invalid", "wrong")) { assertTrue(!text.contains(misleading), "reads as a credential problem: $text") diff --git a/client/src/test/kotlin/io/flamenet/client/TestSafetyTest.kt b/client/src/test/kotlin/io/flamenet/client/TestSafetyTest.kt index 346384f..50425fe 100644 --- a/client/src/test/kotlin/io/flamenet/client/TestSafetyTest.kt +++ b/client/src/test/kotlin/io/flamenet/client/TestSafetyTest.kt @@ -6,34 +6,17 @@ import kotlin.test.assertTrue /** * A test run must not publish a credential (the Kotlin half of ios#62). - * - * The iOS side had this bug twice, and Android had a quieter version of - * it. Every live suite here is properly gated behind `assumeTrue`, better - * than iOS managed, but two of them carried - * `const val PASSPHRASE = "a-long-enough-passphrase"` and used it to create - * accounts on `accounts.flamenet.io` whenever somebody set `FN_LIVE_MANAGED=1`. - * - * The gate stops it running by accident. It does not stop the password being - * written down. Every account those runs ever created can be signed into by - * anybody who can read this repository, and "they were throwaway accounts" - * does not change it, they are real accounts on a real service and they - * persist until somebody deletes them. - * - * A source scan, and coarse on purpose. It cannot prove a suite is safe; - * it proves the specific thing that went wrong cannot come back silently. - * That is worth having even though it is not a proof, and it costs a few - * milliseconds in the JVM suite rather than a device. */ class TestSafetyTest { - private fun testSources(): List { // From `client/build/classes/...` back to the repository root, then to - // every Kotlin test source under it. Walking from the working directory - // rather than a compiled resource, so this sees the tree as written. - val root = generateSequence(File(".").absoluteFile) { it.parentFile } - .firstOrNull { File(it, "settings.gradle.kts").exists() || File(it, "PARITY.md").exists() } - ?: File(".").absoluteFile - return root.walkTopDown() + // every Kotlin test source under it. + val root = + generateSequence(File(".").absoluteFile) { it.parentFile } + .firstOrNull { File(it, "settings.gradle.kts").exists() || File(it, "PARITY.md").exists() } + ?: File(".").absoluteFile + return root + .walkTopDown() .onEnter { it.name != "build" && it.name != ".git" } .filter { it.isFile && it.extension == "kt" } .filter { it.path.contains("/test/") || it.path.contains("/androidTest/") } @@ -42,19 +25,14 @@ class TestSafetyTest { /** * Lines that are code rather than prose. - * - * Trailing comments go too. The Swift version of this guard flagged an - * *email* default because the word "password" appeared in a comment at the - * end of the line, and a guard that cries wolf is a guard somebody - * switches off. */ private fun codeLines(body: String): List = - body.lineSequence() + body + .lineSequence() .filterNot { val t = it.trimStart() t.startsWith("//") || t.startsWith("*") || t.startsWith("/*") - } - .map { line -> + }.map { line -> var i = 0 while (true) { val at = line.indexOf("//", i) @@ -62,22 +40,12 @@ class TestSafetyTest { if (at == 0 || line[at - 1] != ':') return@map line.substring(0, at) i = at + 2 } - @Suppress("UNREACHABLE_CODE") line - } - .toList() + @Suppress("UNREACHABLE_CODE") + line + }.toList() /** * Whether this file can actually reach a relay somebody else runs. - * - * `MockWebServer` is the discriminator, and getting this wrong made - * the guard useless twice. Keying on the `Live` name prefix flagged the - * guard helper and its own unit test; keying on the string "http" flagged - * four suites that sign up against a local mock, which is exactly the - * correct way to test sign-up. A file that stands up a mock server cannot - * reach anybody, whatever else it contains. - * - * What is left is: it signs up or signs in, it does NOT mock the server, - * and it names a real host or takes one from the environment. */ private fun reachesALiveRelay(code: List): Boolean { val reaching = listOf("signUp(", "signIn(", "register(") @@ -88,10 +56,6 @@ class TestSafetyTest { /** * No credential may be a literal in a suite that reaches a relay. - * - * The rule is scoped to files that actually sign up or sign in, because a - * purely local unit test using `passphrase = "pass"` against an in-memory - * store publishes nothing and is not the problem. */ @Test fun `no live suite carries a credential literal`() { @@ -99,18 +63,13 @@ class TestSafetyTest { /** * A name that puts whatever follows it in a credential position. - * - * Deliberately NOT "secret". Test prose is full of it, a marker - * string called `secret` that the test then greps for in a container is - * a fixture, not a credential, and a check that cries wolf is a check - * somebody deletes. */ - fun isCredentialName(name: String) = listOf("password", "passphrase", "credential") - .any { name.contains(it, ignoreCase = true) } + fun isCredentialName(name: String) = + listOf("password", "passphrase", "credential") + .any { name.contains(it, ignoreCase = true) } /** A literal that is plainly something else. */ - fun isNotACredential(value: String) = - value.startsWith("http") || value.contains("@") || value.contains("/") + fun isNotACredential(value: String) = value.startsWith("http") || value.contains("@") || value.contains("/") for (file in testSources()) { val code = codeLines(file.readText()) @@ -122,30 +81,14 @@ class TestSafetyTest { if (isNotACredential(value)) continue val before = line.substring(0, literal.range.first) - // 1. Anything assigned to a credential-shaped name, with or - // without `val`, `const val PASSPHRASE = "…"`, - // `password = "…"`, `passphrase = "…"`. - // - // The `val` was once required, and `password = "…"` on - // its own line inside a multi-line argument list, the most - // ordinary Kotlin formatting there is, walked past it in - // three suites. + // 1. val assignedTo = Regex("""(\w+)\s*=\s*$""").find(before)?.groupValues?.get(1) if (assignedTo != null && isCredentialName(assignedTo)) { offenders += "${file.name}: $value (assigned to $assignedTo)" continue } - // 2. A positional literal handed to sign-up on the SAME - // line. Nothing else it could be. - // - // Same line only, and that is a deliberate limit rather - // than an oversight. Widening it to a few lines around the - // call flagged test tags, assertion messages and message - // bodies as credentials, forty of them, which is how a - // check stops being read. The multi-line case is covered by - // rule 1 instead, which sees `password = "…"` wherever it - // sits, and that is the formatting that actually occurs. + // 2. if (assignedTo != null) continue if (Regex("""\bsign(Up|In)\s*\(""").containsMatchIn(before)) { offenders += "${file.name}: $value (passed to sign-up)" @@ -176,20 +119,16 @@ class TestSafetyTest { /** * Every suite that reaches a live relay must be able to decline. - * - * `assumeTrue` reports SKIPPED, which is the truth. A bare `return` makes a - * test that did nothing report PASSED, and a suite of those is - * indistinguishable from a suite that works, the point `LiveShapesTest` - * already makes in prose and this makes checkable. */ @Test fun `every live suite can announce a skip`() { // Scoped to what a file DOES, not to what it is called, see // `reachesALiveRelay`. - val offenders = testSources() - .filter { reachesALiveRelay(codeLines(it.readText())) } - .filterNot { codeLines(it.readText()).any { line -> line.contains("assumeTrue") } } - .map { it.name } + val offenders = + testSources() + .filter { reachesALiveRelay(codeLines(it.readText())) } + .filterNot { codeLines(it.readText()).any { line -> line.contains("assumeTrue") } } + .map { it.name } assertTrue( offenders.isEmpty(), @@ -199,9 +138,7 @@ class TestSafetyTest { } /** - * A scan that finds nothing passes. This proves it is reading the real - * tree, a moved directory would otherwise turn both checks above into - * no-ops reporting success. + * A scan that finds nothing passes. */ @Test fun `the scan is looking at the real test sources`() { @@ -212,9 +149,7 @@ class TestSafetyTest { found.any { codeLines(it.readText()).any { line -> line.contains("assumeTrue") } }, "the scan cannot see into the files it is reading", ) - // And the live-relay predicate must actually match something. If it - // matched nothing, both checks above would pass by looking at an empty - // set, the exact way a guard reports success while guarding nothing. + // And the live-relay predicate must actually match something. val live = found.filter { reachesALiveRelay(codeLines(it.readText())) } assertTrue( live.size >= 5, diff --git a/client/src/test/kotlin/io/flamenet/client/TrustLogConsistencyTest.kt b/client/src/test/kotlin/io/flamenet/client/TrustLogConsistencyTest.kt index 9b23288..ffd1502 100644 --- a/client/src/test/kotlin/io/flamenet/client/TrustLogConsistencyTest.kt +++ b/client/src/test/kotlin/io/flamenet/client/TrustLogConsistencyTest.kt @@ -16,26 +16,24 @@ import kotlin.test.assertTrue /** * The scheduled half of key transparency (§19.3, §19.4, audit S4/M1). - * - * `verifyLogConsistencyIfDue` and `consistent` were at 0%, and - * `verifyHeadSignature` at 7%. These are what make transparency *act* rather - * than merely record. Checking the log only while establishing a session - * leaves an established conversation unchecked indefinitely, which is exactly - * the conversation worth attacking, so a relay that rewound or forked between - * two bundle fetches would go unseen, possibly for ever. - * - * Two failure directions matter equally here and pull against each other. A - * check that never fires misses a real fork. A check that treats every network - * blip as a fork cries wolf until the alarm means nothing, which is worse than - * no alarm, so half of these tests assert that something is NOT reported. */ class TrustLogConsistencyTest { - private class MapStorage : Vault.Storage { val records = LinkedHashMap() + override fun read(name: String) = records[name] - override fun write(name: String, bytes: ByteArray) { records[name] = bytes } - override fun delete(name: String) { records.remove(name) } + + override fun write( + name: String, + bytes: ByteArray, + ) { + records[name] = bytes + } + + override fun delete(name: String) { + records.remove(name) + } + override fun names() = records.keys.toList() } @@ -43,11 +41,6 @@ class TrustLogConsistencyTest { /** * The relay the object under test is pointed at. - * - * The key-log pin is stored per relay, because a single pin meant that - * signing in to a second relay checked ITS head against the first relay's - * root, which cannot reconcile, so an honest relay was reported as forked. - * A test reading the pin has to name the same relay that wrote it. */ private val relayHost: String get() = server.url("/").host private lateinit var vault: Vault @@ -55,59 +48,84 @@ class TrustLogConsistencyTest { @BeforeTest fun start() { server = MockWebServer().also { it.start() } - vault = Vault(MapStorage(), - KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()) + vault = + Vault( + MapStorage(), + KeyGenerator.getInstance("AES").apply { init(256) }.generateKey(), + ) identity = DeviceIdentity.create() } @AfterTest fun stop() = server.shutdown() - private fun trust() = - Trust(identity, vault, RelayClient(server.url("/").toString(), "a", "b")) + private fun trust() = Trust(identity, vault, RelayClient(server.url("/").toString(), "a", "b")) - private fun json(body: String) = MockResponse() - .setResponseCode(200).setHeader("Content-Type", "application/json").setBody(body) + private fun json(body: String) = + MockResponse() + .setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody(body) - // -- A real tree, so proofs are rather than stubbed -------------- + // -- A real tree, so proofs are rather than stubbed --------------. private val leaves = (0 until 8).map { KeyLog.leafHash(byteArrayOf(it.toByte())) } - private fun root(ls: List): ByteArray = when { - ls.size == 1 -> ls[0] - else -> { - val k = KeyLog.split(ls.size) - KeyLog.nodeHash(root(ls.take(k)), root(ls.drop(k))) + private fun root(ls: List): ByteArray = + when { + ls.size == 1 -> { + ls[0] + } + + else -> { + val k = KeyLog.split(ls.size) + KeyLog.nodeHash(root(ls.take(k)), root(ls.drop(k))) + } } - } - private fun consistencyProof(ls: List, m: Int, n: Int): List = - if (m == n) emptyList() else sub(ls, m, n, true) + private fun consistencyProof( + ls: List, + m: Int, + n: Int, + ): List = if (m == n) emptyList() else sub(ls, m, n, true) - private fun sub(ls: List, m: Int, n: Int, complete: Boolean): List { + private fun sub( + ls: List, + m: Int, + n: Int, + complete: Boolean, + ): List { if (m == n) return if (complete) emptyList() else listOf(root(ls.take(n))) val k = KeyLog.split(n) - return if (m <= k) sub(ls.take(k), m, k, complete) + root(ls.subList(k, n)) - else sub(ls.subList(k, n), m - k, n - k, false) + root(ls.take(k)) + return if (m <= k) { + sub(ls.take(k), m, k, complete) + root(ls.subList(k, n)) + } else { + sub(ls.subList(k, n), m - k, n - k, false) + root(ls.take(k)) + } } private fun rootB64(n: Int) = Wire.b64(root(leaves.take(n))) private fun pin(size: Int) = vault.saveKeyLogPin(Vault.KeyLogPin(size, rootB64(size))) - private fun headBody(size: Int, root: String = rootB64(size), sig: String = "") = - """{"size":$size,"root":"$root","audience":"r"${if (sig.isEmpty()) "" else ",\"signature\":\"$sig\""}}""" + private fun headBody( + size: Int, + root: String = rootB64(size), + sig: String = "", + ) = """{"size":$size,"root":"$root","audience":"r"${if (sig.isEmpty()) "" else ",\"signature\":\"$sig\""}}""" - private fun proofBody(m: Int, n: Int) = - """{"proof":[${consistencyProof(leaves, m, n).joinToString(",") { "\"${Wire.b64(it)}\"" }}]}""" + private fun proofBody( + m: Int, + n: Int, + ) = """{"proof":[${consistencyProof(leaves, m, n).joinToString(",") { "\"${Wire.b64(it)}\"" }}]}""" - // -- Scheduled consistency ---------------------------------------------- + // -- Scheduled consistency ----------------------------------------------. @Test fun `with nothing pinned there is nothing to check`() { val t = trust() t.verifyLogConsistencyIfDue() assertFalse(t.forkDetected) - assertEquals(0, server.requestCount) // and it did not go to the network + assertEquals(0, server.requestCount) // and it did not go to the network } @Test @@ -125,9 +143,7 @@ class TrustLogConsistencyTest { } /** - * A log that got SMALLER is a rewind, and no proof can excuse it. This - * is the cheapest attack to attempt and the one an unscheduled check would - * never see. + * A log that got SMALLER is a rewind, and no proof can excuse it. */ @Test fun `a log that shrank is reported as a fork`() { @@ -138,7 +154,7 @@ class TrustLogConsistencyTest { t.verifyLogConsistencyIfDue() assertTrue(t.forkDetected) - // the pin is NOT advanced onto the smaller log + // the pin is NOT advanced onto the smaller log. assertEquals(8, vault.loadKeyLogPin(relayHost)!!.size) } @@ -163,8 +179,8 @@ class TrustLogConsistencyTest { } /** - * A head that does not extend our pin, with a proof that does not close - * the gap, is the actual split-view attack. + * A head that does not extend our pin, with a proof that does not close the + * gap, is the actual split-view attack. */ @Test fun `growth whose consistency proof does not verify is a fork`() { @@ -180,8 +196,7 @@ class TrustLogConsistencyTest { } /** - * The network is allowed to be down. Reporting a fork because a fetch - * failed would raise a standing, unclearable alarm on a working relay. + * The network is allowed to be down. */ @Test fun `an unreachable relay is not a fork`() { @@ -207,9 +222,7 @@ class TrustLogConsistencyTest { } /** - * It self-throttles because it is called from the poll loop. Without that - * it would fetch the head on every poll, which is a request per few - * seconds per device for a check that only needs to be hourly. + * It self-throttles because it is called from the poll loop. */ @Test fun `the sweep throttles itself between runs`() { @@ -221,7 +234,7 @@ class TrustLogConsistencyTest { t.verifyLogConsistencyIfDue(now) assertEquals(1, server.requestCount) - t.verifyLogConsistencyIfDue(now + 60_000) // a minute later + t.verifyLogConsistencyIfDue(now + 60_000) // a minute later assertEquals(1, server.requestCount, "should not have re-fetched") } @@ -238,9 +251,12 @@ class TrustLogConsistencyTest { assertEquals(2, server.requestCount) } - // -- Head signatures (§19.3, audit M1) ---------------------------------- + // -- Head signatures (§19.3, audit M1) ----------------------------------. - private fun signedHead(size: Int, signer: Ed25519.KeyPair): String { + private fun signedHead( + size: Int, + signer: Ed25519.KeyPair, + ): String { val sig = Ed25519.sign(signer, "fnkt-v1|$size|${rootB64(size)}".toByteArray()) return headBody(size, sig = Wire.b64(sig)) } @@ -260,8 +276,7 @@ class TrustLogConsistencyTest { } /** - * Pinned, not fetched-and-trusted. Once an issuer key is pinned, a head - * signed by anyone else is a fork, that is the whole value of pinning it. + * Pinned, not fetched-and-trusted. */ @Test fun `a head signed by a different key than the pinned one is a fork`() { @@ -277,9 +292,7 @@ class TrustLogConsistencyTest { } /** - * Silent when nothing is signed. A deployment that cannot sign is - * weaker, not hostile, and refusing it outright is a worse failure than the - * one prevented, self-hosters would simply be locked out. + * Silent when nothing is signed. */ @Test fun `an unsigned head is accepted rather than refused`() { @@ -306,7 +319,7 @@ class TrustLogConsistencyTest { val issuer = Ed25519.generate() pin(4) server.enqueue(json(signedHead(4, issuer))) - server.enqueue(json("""{}""")) // descriptor publishes nothing + server.enqueue(json("""{}""")) // descriptor publishes nothing val t = trust() t.verifyLogConsistencyIfDue() @@ -314,7 +327,7 @@ class TrustLogConsistencyTest { assertEquals(null, vault.keyLogIssuerPin()) } - // -- Gossip (§19.4) ------------------------------------------------------ + // -- Gossip (§19.4) ------------------------------------------------------. @Test fun `a peer reporting a different root at the same size proves a fork`() { @@ -334,7 +347,7 @@ class TrustLogConsistencyTest { /** * Different sizes are not evidence by themselves, one of us is simply - * behind. Treating that as a fork would fire constantly in normal use. + * behind. */ @Test fun `a peer merely further ahead is not a fork`() { @@ -373,7 +386,7 @@ class TrustLogConsistencyTest { /** * A relay that forked once does not become trustworthy because the next - * request looked fine. The alarm is standing, and nothing here clears it. + * request looked fine. */ @Test fun `a detected fork is not cleared by later good news`() { @@ -386,7 +399,7 @@ class TrustLogConsistencyTest { assertTrue(t.forkDetected, "the alarm must stand") } - // -- headPayload --------------------------------------------------------- + // -- headPayload ---------------------------------------------------------. @Test fun `there is nothing to gossip before anything is pinned`() { @@ -402,7 +415,7 @@ class TrustLogConsistencyTest { assertTrue(payload.contains(rootB64(4))) } - // -- forget -------------------------------------------------------------- + // -- forget --------------------------------------------------------------. @Test fun `forgetting a contact drops its pinned identities`() { diff --git a/client/src/test/kotlin/io/flamenet/client/TrustTest.kt b/client/src/test/kotlin/io/flamenet/client/TrustTest.kt index e3285ad..2e6fb3f 100644 --- a/client/src/test/kotlin/io/flamenet/client/TrustTest.kt +++ b/client/src/test/kotlin/io/flamenet/client/TrustTest.kt @@ -18,18 +18,24 @@ import kotlin.test.assertTrue /** * Pinning and key-log enforcement (§19), against a real HTTP server. - * - * The cases that matter here are the refusals. A trust check that accepts - * everything passes every happy-path test ever written, and the only way to - * know it does anything is to hand it something it must reject. */ class TrustTest { - private class MapStorage : Vault.Storage { val records = LinkedHashMap() + override fun read(name: String) = records[name] - override fun write(name: String, bytes: ByteArray) { records[name] = bytes } - override fun delete(name: String) { records.remove(name) } + + override fun write( + name: String, + bytes: ByteArray, + ) { + records[name] = bytes + } + + override fun delete(name: String) { + records.remove(name) + } + override fun names() = records.keys.toList() } @@ -37,11 +43,6 @@ class TrustTest { /** * The relay the object under test is pointed at. - * - * The key-log pin is stored per relay, because a single pin meant that - * signing in to a second relay checked ITS head against the first relay's - * root, which cannot reconcile, so an honest relay was reported as forked. - * A test reading the pin has to name the same relay that wrote it. */ private val relayHost: String get() = server.url("/").host private lateinit var vault: Vault @@ -57,21 +58,20 @@ class TrustTest { private fun trust() = Trust(identity, vault, RelayClient(server.url("/").toString(), "a", "b")) - private fun json(body: String) = MockResponse() - .setResponseCode(200).setHeader("Content-Type", "application/json").setBody(body) + private fun json(body: String) = + MockResponse() + .setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody(body) private fun key(seed: Byte) = ByteArray(32) { seed } - // -- A detected change has to SURVIVE ------------------------------------ + // -- A detected change has to SURVIVE ------------------------------------. /** * The bug these guard, found by driving two real apps: `record()` worked * out CHANGED during a send and then threw it away, because nothing stored - * it. `state()` could only answer UNKNOWN, VERIFIED or PINNED, so the chat - * header went on saying "keys unchanged since you met" while every send was - * refused with "identity keys changed", and the "Accept new keys" button, - * which only renders for CHANGED, could never appear. A contact who - * reinstalled was unmessageable for ever with no way out. + * it. */ @Test fun `a detected key change is still CHANGED when asked again later`() { @@ -80,10 +80,12 @@ class TrustTest { assertEquals(IdentityTrust.PINNED, t.state(7)) assertEquals(IdentityTrust.CHANGED, t.record(7, listOf(key(2)))) - // The point: ASK AGAIN. This is what the UI does, and it used to be - // told everything was fine. - assertEquals(IdentityTrust.CHANGED, t.state(7), - "the change was detected and then forgotten") + // The point: ASK AGAIN. + assertEquals( + IdentityTrust.CHANGED, + t.state(7), + "the change was detected and then forgotten", + ) } @Test @@ -91,19 +93,13 @@ class TrustTest { val t = trust() t.record(7, listOf(key(1))) t.record(7, listOf(key(2))) - // Still the ORIGINAL key. Detecting a substitution must not adopt it. + // Still the ORIGINAL key. assertEquals(listOf(Wire.b64(key(1))), vault.loadTrust(7)?.identities) } /** * The doc comment on `rememberFnid` said this and the code did the - * opposite. It overwrote a different address in place, the one thing an - * address pin exists to prevent, since an fnid is derived from the account - * key, so a new address is not a new device but a different account. - * - * It went unnoticed while only the bundle route carried an address: - * that route is reached once per conversation, when a session is first - * established. The directory carries it on every send. + * opposite. */ @Test fun `a different address is a change, not a quiet replacement`() { @@ -111,8 +107,11 @@ class TrustTest { t.record(7, listOf(key(1)), fnid = "fnid:first") t.rememberFnid(7, "fnid:second") - assertEquals("fnid:first", vault.loadTrust(7)?.fnid, - "the pinned address was replaced instead of challenged") + assertEquals( + "fnid:first", + vault.loadTrust(7)?.fnid, + "the pinned address was replaced instead of challenged", + ) assertEquals(IdentityTrust.CHANGED, t.state(7)) } @@ -153,7 +152,7 @@ class TrustTest { assertEquals(IdentityTrust.PINNED, t.state(7)) } - // -- Pinning ------------------------------------------------------------- + // -- Pinning -------------------------------------------------------------. @Test fun `a first sighting is pinned rather than doubted`() { @@ -174,17 +173,12 @@ class TrustTest { fun `a subset is still the same person, devices go away`() { val t = trust() t.record(7, listOf(key(1), key(2))) - // One device removed is not an identity change. Treating it as one - // would fire the alarm every time somebody signs out on their tablet. + // One device removed is not an identity change. assertEquals(IdentityTrust.PINNED, t.record(7, listOf(key(1)))) } /** - * The attack. A relay adds ONE key of its own alongside the real ones. - * - * Every device still works, so nothing looks wrong to either - * person, while a device the attacker controls receives a copy of - * everything. A check written as "any offered key is pinned" accepts this. + * The attack. */ @Test fun `an extra unseen key alongside the real ones is a change`() { @@ -202,7 +196,10 @@ class TrustTest { @Test fun `verifying survives a restart, because it is in the vault`() { - trust().also { it.record(7, listOf(key(1))); it.setVerified(7, true) } + trust().also { + it.record(7, listOf(key(1))) + it.setVerified(7, true) + } assertEquals(IdentityTrust.VERIFIED, trust().state(7)) } @@ -231,7 +228,7 @@ class TrustTest { assertEquals(60, t.safetyNumber(7)?.length) } - // -- Global address (fnid) ----------------------------------------------- + // -- Global address (fnid) -----------------------------------------------. @Test fun `an address is resolved once and pinned`() { @@ -241,8 +238,7 @@ class TrustTest { assertEquals("fnid:abc", t.pinnedFnid(7)) // Idempotent: no second request, so the queue staying empty is the - // assertion. A lookup per check is a lookup a relay can answer - // differently each time. + // assertion. t.rememberIdentity(7, "bob") assertEquals("fnid:abc", t.pinnedFnid(7)) assertEquals(1, server.requestCount) @@ -258,10 +254,6 @@ class TrustTest { /** * An unreachable relay must not pin "no address". - * - * Baking that in would be permanent, `rememberIdentity` does nothing once - * an address is pinned, and the next successful lookup would then look - * like the address changing, which is the alarm. */ @Test fun `a failed lookup pins nothing rather than pinning absence`() { @@ -277,9 +269,6 @@ class TrustTest { /** * A different address is a different account key, so a different person. - * - * Not a new device, whatever the relay presents it as: the fnid is derived - * from the account key. */ @Test fun `a changed address is an identity change`() { @@ -309,10 +298,18 @@ class TrustTest { val b = bundle("d1", key(1)) // The leaf the relay would have written for an account WITH this fnid. - val leaf = Wire.b64(KeyLog.leafHash(KeyLog.canonicalLeaf( - kind = "publish", subject = "fnid:abc", deviceId = b.deviceId, - ikSigB64 = Wire.b64(key(1)), ikDhB64 = Wire.b64(key(2)), - ))) + val leaf = + Wire.b64( + KeyLog.leafHash( + KeyLog.canonicalLeaf( + kind = "publish", + subject = "fnid:abc", + deviceId = b.deviceId, + ikSigB64 = Wire.b64(key(1)), + ikDhB64 = Wire.b64(key(2)), + ), + ), + ) server.enqueue(json("""{"size":1,"root":"$leaf","audience":"r"}""")) server.enqueue(json("""{"device_id":"d1","size":1,"entries":[{"index":0,"kind":"publish","leaf":"$leaf"}]}""")) server.enqueue(json("""{"index":0,"size":1,"leaf":"$leaf","root":"$leaf","proof":[]}""")) @@ -322,7 +319,7 @@ class TrustTest { assertEquals(leaf, vault.loadKeyLogPin(relayHost)?.root) } - // -- Key-log enforcement ------------------------------------------------- + // -- Key-log enforcement -------------------------------------------------. @Test fun `a head that goes backwards is a fork`() { @@ -344,21 +341,18 @@ class TrustTest { /** * A key the relay serves but never logged must be refused. - * - * This is the point of the mechanism: the relay is free to hand out - * whatever key it likes, and the client's job is to notice that no such - * publication exists in the log the relay itself publishes. */ @Test fun `a served key that is not in the log is refused`() { server.enqueue(json("""{"size":1,"root":"${Wire.b64(key(3))}","audience":"r"}""")) server.enqueue(json("""{"device_id":"d1","size":1,"entries":[]}""")) - val e = assertFailsWith { - trust().enforceKeyLog(bundle("d1", key(1)), 7) - } + val e = + assertFailsWith { + trust().enforceKeyLog(bundle("d1", key(1)), 7) + } assertEquals("d1", e.deviceId) - // Nothing logged at all, this is the shape a substitution takes, and - // it must not be confused with a subject we could not compute. + // Nothing logged at all, this is the shape a substitution takes, and it + // must not be confused with a subject we could not compute. assertFalse(e.hadEntries) } @@ -381,9 +375,6 @@ class TrustTest { /** * The relay must not be able to pass by echoing its own bytes back. - * - * Here the log lists an entry and the inclusion response is internally - * consistent, but for a leaf that is not the one this bundle implies. */ @Test fun `an inclusion proof for someone else's leaf is refused`() { @@ -395,7 +386,7 @@ class TrustTest { } } - // -- Gossip -------------------------------------------------------------- + // -- Gossip --------------------------------------------------------------. @Test fun `no pin means nothing to gossip and nothing to conclude`() { @@ -423,9 +414,6 @@ class TrustTest { /** * An unreachable relay is not evidence of anything. - * - * Crying fork whenever the network is down trains everyone to ignore the - * warning, which is worse than not having it. */ @Test fun `a failed consistency fetch is not treated as a fork`() { @@ -445,29 +433,43 @@ class TrustTest { assertEquals("ROOTB64", payload.getString("root")) } - // -- Helpers ------------------------------------------------------------- + // -- Helpers -------------------------------------------------------------. - private fun bundle(deviceId: String, ikSig: ByteArray) = - io.flamenet.e2e.PeerBundle.fromWire(JSONObject(deviceWire(deviceId, ikSig))) + private fun bundle( + deviceId: String, + ikSig: ByteArray, + ) = io.flamenet.e2e.PeerBundle + .fromWire(JSONObject(deviceWire(deviceId, ikSig))) - private fun deviceWire(deviceId: String, ikSig: ByteArray): String = JSONObject() - .put("device_id", deviceId) - .put("ik_sig", Wire.b64(ikSig)) - .put("ik_dh", Wire.b64(key(2))) - .put("spk_id", 1) - .put("spk", Wire.b64(key(4))) - .put("spk_sig", Wire.b64(ByteArray(64) { 5 })) - .put("opk", JSONObject().put("id", 1).put("pub", Wire.b64(key(6)))) - .put("proto", 1) - .toString() - - private fun expectedLeaf(deviceId: String, ikSig: ByteArray, audience: String, userId: Long) = - Wire.b64(KeyLog.leafHash(KeyLog.canonicalLeaf( - kind = "publish", - subject = KeyLog.logSubject(null, audience, userId), - deviceId = deviceId, - ikSigB64 = Wire.b64(ikSig), - ikDhB64 = Wire.b64(key(2)), - ))) + private fun deviceWire( + deviceId: String, + ikSig: ByteArray, + ): String = + JSONObject() + .put("device_id", deviceId) + .put("ik_sig", Wire.b64(ikSig)) + .put("ik_dh", Wire.b64(key(2))) + .put("spk_id", 1) + .put("spk", Wire.b64(key(4))) + .put("spk_sig", Wire.b64(ByteArray(64) { 5 })) + .put("opk", JSONObject().put("id", 1).put("pub", Wire.b64(key(6)))) + .put("proto", 1) + .toString() + private fun expectedLeaf( + deviceId: String, + ikSig: ByteArray, + audience: String, + userId: Long, + ) = Wire.b64( + KeyLog.leafHash( + KeyLog.canonicalLeaf( + kind = "publish", + subject = KeyLog.logSubject(null, audience, userId), + deviceId = deviceId, + ikSigB64 = Wire.b64(ikSig), + ikDhB64 = Wire.b64(key(2)), + ), + ), + ) } diff --git a/client/src/test/kotlin/io/flamenet/client/UpdateCheckTest.kt b/client/src/test/kotlin/io/flamenet/client/UpdateCheckTest.kt index 888f2d1..aa46966 100644 --- a/client/src/test/kotlin/io/flamenet/client/UpdateCheckTest.kt +++ b/client/src/test/kotlin/io/flamenet/client/UpdateCheckTest.kt @@ -11,22 +11,24 @@ import kotlin.test.assertTrue /** * The update check for an app nothing else updates. - * - * The case worth pinning hardest is the one that is not about versions at - * all: the download URL must come from the relay's own origin, whatever the - * manifest says. A relay that could name the APK host could hand its users a - * different app. */ class UpdateCheckTest { - private lateinit var server: MockWebServer - @BeforeTest fun start() { server = MockWebServer().also { it.start() } } - @AfterTest fun stop() { server.shutdown() } + @BeforeTest fun start() { + server = MockWebServer().also { it.start() } + } + + @AfterTest fun stop() { + server.shutdown() + } private fun base() = server.url("/").toString().trimEnd('/') - private fun serve(body: String, code: Int = 200) { + private fun serve( + body: String, + code: Int = 200, + ) { server.enqueue(MockResponse().setResponseCode(code).setBody(body)) } @@ -53,7 +55,7 @@ class UpdateCheckTest { @Test fun `the download url is built from the relay, never taken from the manifest`() { - // A hostile manifest naming somebody else's APK. The field is ignored. + // A hostile manifest naming somebody else's APK. serve( """{"versionCode":9,"versionName":"9","url":"https://evil.example/pwn.apk", "downloadUrl":"https://evil.example/pwn.apk"}""", diff --git a/engine/src/main/kotlin/io/flamenet/e2e/AccountKdf.kt b/engine/src/main/kotlin/io/flamenet/e2e/AccountKdf.kt index 348c9f7..099166c 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/AccountKdf.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/AccountKdf.kt @@ -32,24 +32,25 @@ import javax.crypto.spec.PBEKeySpec * settle the second. */ object AccountKdf { - /** * The registry the account service's `/params` route selects from by id. - * - * The iteration count is not a local tuning knob: the salt and count come - * back from the server per account, so raising this constant on one client - * only breaks accounts created under the other value. */ - data class Params(val id: Int, val iterations: Int, val saltBytes: Int) { + data class Params( + val id: Int, + val iterations: Int, + val saltBytes: Int, + ) { companion object { val PBKDF2_V1 = Params(id = 1, iterations = 650_000, saltBytes = 16) - fun withId(id: Int): Params = when (id) { - 1 -> PBKDF2_V1 - else -> throw IllegalArgumentException( - "unsupported KDF id $id, this client cannot derive keys that way", - ) - } + fun withId(id: Int): Params = + when (id) { + 1 -> PBKDF2_V1 + + else -> throw IllegalArgumentException( + "unsupported KDF id $id, this client cannot derive keys that way", + ) + } } } @@ -57,21 +58,6 @@ object AccountKdf { object Context { /** * Authentication is account-wide, not per product. - * - * The service stores one verifier per account under this one context; - * `product` on a session request scopes the pseudonym it returns, not - * the secret it checks. Every FlameNet product must therefore - * authenticate with the same string, or a passphrase registered by one - * cannot sign in from another, which is the entire premise of a shared - * account. - * - * It says `files` for historical reasons: the enclave client defaults - * to it and every existing account was registered with it. - * - * `flamenet/messenger/auth/v1` also exists and is a trap. It reads - * like the obvious constant for a messenger client, derives cleanly, - * and produces a secret the service answers with "handle or passphrase - * is incorrect". */ const val ACCOUNT_AUTH = "flamenet/files/auth/v1" @@ -80,15 +66,6 @@ object AccountKdf { /** * The account key, the one identity that is not per product. - * - * Everything else here is scoped: the auth secret to the service, the - * identity key to Messenger. This one is neither, because it answers a - * question no scoped key can: who are you, to a relay that has never - * heard of you? Its public half, Crockford base32, is the `fnid`. - * - * Changing this string strands every address ever issued, the - * address IS the public key, so a different derivation is a different - * person. */ const val ACCOUNT_IDENTITY = "flamenet/account/identity/v1" } @@ -96,12 +73,6 @@ object AccountKdf { /** * Stage one: the expensive stretch, shared across products and never used * directly. - * - * `PBEKeySpec` takes a `CharArray`, and the JCA encodes it as UTF-8 for - * `PBKDF2WithHmacSHA256`. A passphrase may legitimately contain astral - * characters, which are two chars in a Kotlin `CharArray` and four UTF-8 - * bytes, the vector suite carries a non-ASCII case and an emoji case for - * exactly this reason. */ fun master( passphrase: String, @@ -110,7 +81,8 @@ object AccountKdf { ): ByteArray { val spec = PBEKeySpec(passphrase.toCharArray(), salt, params.iterations, 256) try { - return SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256") + return SecretKeyFactory + .getInstance("PBKDF2WithHmacSHA256") .generateSecret(spec) .encoded } finally { @@ -122,10 +94,6 @@ object AccountKdf { /** * Raw bytes for one context. - * - * The HKDF salt is deliberately empty: the per-account salt has already - * done its work in the stretch above, and the context string is what - * separates one derived secret from another. */ fun derive( passphrase: String, @@ -133,87 +101,70 @@ object AccountKdf { context: String, length: Int = 32, params: Params = Params.PBKDF2_V1, - ): ByteArray = SecureBytes.withSecret(master(passphrase, salt, params)) { ikm -> - // ios#39, the stretched master is an intermediate nobody needs after - // the expansion, and it is the most valuable thing in this file: every - // other secret is derived from it. Wiped however this leaves. - Hkdf.derive( - ikm = ikm, - salt = ByteArray(0), - info = context.toByteArray(Charsets.UTF_8), - length = length, - ) - } + ): ByteArray = + SecureBytes.withSecret(master(passphrase, salt, params)) { ikm -> + // ios#39, the stretched master is an intermediate nobody needs after + // the expansion, and it is the most valuable thing in this file: every + // other secret is derived from it. + Hkdf.derive( + ikm = ikm, + salt = ByteArray(0), + info = context.toByteArray(Charsets.UTF_8), + length = length, + ) + } /** What gets sent to the account service. */ - fun authSecret(passphrase: String, salt: ByteArray, params: Params = Params.PBKDF2_V1): ByteArray = - derive(passphrase, salt, Context.ACCOUNT_AUTH, 32, params) + fun authSecret( + passphrase: String, + salt: ByteArray, + params: Params = Params.PBKDF2_V1, + ): ByteArray = derive(passphrase, salt, Context.ACCOUNT_AUTH, 32, params) /** What never leaves the device. */ - fun identityKey(passphrase: String, salt: ByteArray, params: Params = Params.PBKDF2_V1): ByteArray = - derive(passphrase, salt, Context.MESSENGER_IDENTITY, 32, params) + fun identityKey( + passphrase: String, + salt: ByteArray, + params: Params = Params.PBKDF2_V1, + ): ByteArray = derive(passphrase, salt, Context.MESSENGER_IDENTITY, 32, params) /** * The 32-byte Ed25519 seed whose public half is this account's address. - * - * Returns the seed rather than a key object on purpose. Turning a raw seed - * into a usable Ed25519 keypair through the JCA needs `EdECPrivateKeySpec`, - * which Android exposes only from API 33, the same platform floor that - * X25519 already imposes on this engine, and the same shape of problem: - * available on the JDK, absent on the devices that matter. - * - * So this engine can derive an address and cannot yet sign a claim for one. - * A caller on a new enough device can build the keypair itself; the signing - * helper belongs with the rest of the Conscrypt-specific code, beside - * X25519, not here. */ - fun accountKeySeed(passphrase: String, salt: ByteArray, params: Params = Params.PBKDF2_V1): ByteArray = - derive(passphrase, salt, Context.ACCOUNT_IDENTITY, 32, params) + fun accountKeySeed( + passphrase: String, + salt: ByteArray, + params: Params = Params.PBKDF2_V1, + ): ByteArray = derive(passphrase, salt, Context.ACCOUNT_IDENTITY, 32, params) /** * Both secrets a sign-in needs, from a single stretch. - * - * Ask for them together or pay twice. `derive` re-runs PBKDF2 on - * every call, so asking for the auth secret and then the account key is - * 1.3 million iterations, measured at several seconds on a mid-range - * phone, for a value that was already in hand. The stretch is the whole - * cost; the two HKDF expansions are free. */ fun signInSecrets( passphrase: String, salt: ByteArray, params: Params = Params.PBKDF2_V1, - ): Pair { - return SecureBytes.withSecret(master(passphrase, salt, params)) { ikm -> - fun expand(context: String) = Hkdf.derive( - ikm = ikm, - salt = ByteArray(0), - info = context.toByteArray(Charsets.UTF_8), - length = 32, - ) - // BOTH expansions happen inside the block. Returning the pair and - // wiping afterwards would be the same thing; expanding after the - // wipe would derive two keys from thirty-two zero bytes, which is - // the failure mode this shape makes impossible to write. + ): Pair = + SecureBytes.withSecret(master(passphrase, salt, params)) { ikm -> + fun expand(context: String) = + Hkdf.derive( + ikm = ikm, + salt = ByteArray(0), + info = context.toByteArray(Charsets.UTF_8), + length = 32, + ) + // BOTH expansions happen inside the block. expand(Context.ACCOUNT_AUTH) to expand(Context.ACCOUNT_IDENTITY) } - } /** * The salt for an account that lives on the relay itself, where no service * stores one. - * - * Relay-independent on purpose. The same handle and passphrase - * produce the same address at every self-hosted relay, which is exactly - * what lets one person keep mailboxes on two of them. It also means two - * different people who pick the same handle AND the same passphrase - * collide into one identity, which is the same thing as sharing a - * password, and no worse for being said out loud. - * - * Normalised first, or `Alice` and `alice` are two people to the KDF - * and one person to every relay that stores them. */ - fun localAccountSalt(handle: String, params: Params = Params.PBKDF2_V1): ByteArray = + fun localAccountSalt( + handle: String, + params: Params = Params.PBKDF2_V1, + ): ByteArray = Hkdf.derive( ikm = handle.trim().lowercase().toByteArray(Charsets.UTF_8), salt = ByteArray(0), diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Aead.kt b/engine/src/main/kotlin/io/flamenet/e2e/Aead.kt index caece85..ddfd598 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/Aead.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/Aead.kt @@ -7,43 +7,42 @@ import javax.crypto.spec.SecretKeySpec /** * AES-256-GCM, in the layout the protocol uses: `nonce || ciphertext || tag`. - * - * That layout is not incidental. CryptoKit's `AES.GCM.SealedBox.combined` emits - * exactly this, the JavaScript engine assembles it by hand to match, and a box - * built any other way decrypts to garbage on the other two engines rather than - * failing loudly. So it is pinned here and tested, not left to a convention. - * - * 12-byte nonces and 16-byte tags throughout, the only sizes the other engines - * produce, and the sizes GCM is actually safe at. */ object Aead { - const val NONCE_LEN = 12 const val TAG_LEN = 16 - class DecryptionFailed(message: String) : GeneralSecurityException(message) + class DecryptionFailed( + message: String, + ) : GeneralSecurityException(message) /** * Seal [plaintext], returning `nonce || ciphertext || tag`. - * - * The nonce is supplied rather than generated so callers cannot accidentally - * reuse one and so this is testable against fixed vectors. A nonce must - * never repeat under one key, GCM does not degrade gracefully there, it - * leaks the authentication subkey and with it the ability to forge. */ - fun seal(key: ByteArray, nonce: ByteArray, plaintext: ByteArray, - aad: ByteArray = ByteArray(0)): ByteArray { + fun seal( + key: ByteArray, + nonce: ByteArray, + plaintext: ByteArray, + aad: ByteArray = ByteArray(0), + ): ByteArray { require(key.size == 32) { "AES-256-GCM needs a 32-byte key, got ${key.size}" } require(nonce.size == NONCE_LEN) { "GCM nonce must be $NONCE_LEN bytes, got ${nonce.size}" } val cipher = Cipher.getInstance("AES/GCM/NoPadding") - cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "AES"), - GCMParameterSpec(TAG_LEN * 8, nonce)) + cipher.init( + Cipher.ENCRYPT_MODE, + SecretKeySpec(key, "AES"), + GCMParameterSpec(TAG_LEN * 8, nonce), + ) if (aad.isNotEmpty()) cipher.updateAAD(aad) return nonce + cipher.doFinal(plaintext) } /** Open a `nonce || ciphertext || tag` box. */ - fun open(key: ByteArray, box: ByteArray, aad: ByteArray = ByteArray(0)): ByteArray { + fun open( + key: ByteArray, + box: ByteArray, + aad: ByteArray = ByteArray(0), + ): ByteArray { require(key.size == 32) { "AES-256-GCM needs a 32-byte key, got ${key.size}" } // Refuse a box too short to contain what it claims, rather than letting // the array slicing below throw something unrelated. @@ -51,15 +50,16 @@ object Aead { throw DecryptionFailed("box is ${box.size} bytes, too short to hold a nonce and a tag") } val cipher = Cipher.getInstance("AES/GCM/NoPadding") - cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), - GCMParameterSpec(TAG_LEN * 8, box, 0, NONCE_LEN)) + cipher.init( + Cipher.DECRYPT_MODE, + SecretKeySpec(key, "AES"), + GCMParameterSpec(TAG_LEN * 8, box, 0, NONCE_LEN), + ) if (aad.isNotEmpty()) cipher.updateAAD(aad) return try { cipher.doFinal(box, NONCE_LEN, box.size - NONCE_LEN) } catch (e: GeneralSecurityException) { - // Deliberately one message for every failure. Distinguishing "bad - // tag" from "wrong key" hands an attacker a decision oracle, and - // there is nothing a caller can usefully do differently. + // Deliberately one message for every failure. throw DecryptionFailed("could not open the box") } } diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Base58.kt b/engine/src/main/kotlin/io/flamenet/e2e/Base58.kt index fa1d0c6..b4313a3 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/Base58.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/Base58.kt @@ -2,23 +2,8 @@ package io.flamenet.e2e /** * Base58, the alphabet every ed25519 chain prints addresses in. - * - * The leading-zero rule is the whole difficulty. Base58 is - * base-conversion of one big integer, and an integer has no way to represent a - * leading zero byte, so the standard prepends one `1` per leading zero byte - * separately. Implementations that forget this encode two different keys to the - * same string, and the failure surfaces as money sent to the wrong address. - * Hence the tests, which are mostly about zeros. - * - * Hand-written rather than `java.math.BigInteger`. The obvious Kotlin - * implementation is `BigInteger(1, bytes).toString(58)` with a remapped - * alphabet, and it is wrong in exactly the way above: `BigInteger` discards - * leading zeros, so a key beginning `0x00` loses a character and encodes to - * another key's address. The digit loop below is the same one the Swift engine - * runs, so the two cannot disagree. */ object Base58 { - /** * Not the Bitcoin alphabet's cousins: `0`, `O`, `I` and `l` are absent * deliberately, so an address read aloud or copied by hand cannot become a @@ -31,11 +16,7 @@ object Base58 { fun encode(data: ByteArray): String { if (data.isEmpty()) return "" - // Starts EMPTY, not with a zero digit. Seeded with one, an all-zero - // input produces one digit AND one leading '1', so a single 0x00 byte - // encodes as "11", every address with a leading zero comes out one - // character too long, and is either rejected by the other end or, - // worse, accepted as a different one. + // Starts EMPTY, not with a zero digit. val digits = ArrayList() for (byte in data) { var carry = byte.toInt() and 0xff diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Bip39.kt b/engine/src/main/kotlin/io/flamenet/e2e/Bip39.kt index d2338bd..3deaa63 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/Bip39.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/Bip39.kt @@ -7,65 +7,66 @@ import javax.crypto.spec.SecretKeySpec /** * BIP-39 mnemonics, for the wallet only. - * - * Not for anything else here. Files recovers through its own Crockford - * codes, and the reasoning for rejecting a word list there stands: 2,048 - * entries that must be byte-exact is a footgun. The wallet accepts that footgun - * for one reason, a phrase has to be understood by software nobody here - * controls, and pays for it with [verify]. - * - * A port of `Packages/FlamenetFiles/Sources/FilesEngine/BIP39.swift`, asserted - * against the same published vectors so the two cannot drift into deriving - * different wallets from one phrase. The vendored word list is byte-identical - * to the iOS one and has the same SHA-256. */ object Bip39 { + sealed class Failure( + message: String, + ) : Exception(message) { + class BadEntropySize( + val size: Int, + ) : Failure("Entropy must be 16, 20, 24, 28 or 32 bytes, not $size.") + + class BadPhraseLength( + val words: Int, + ) : Failure("A recovery phrase is 12, 15, 18, 21 or 24 words, not $words.") + + class UnknownWord( + val word: String, + ) : Failure("“$word” is not a recovery phrase word.") - sealed class Failure(message: String) : Exception(message) { - class BadEntropySize(val size: Int) : - Failure("Entropy must be 16, 20, 24, 28 or 32 bytes, not $size.") - class BadPhraseLength(val words: Int) : - Failure("A recovery phrase is 12, 15, 18, 21 or 24 words, not $words.") - class UnknownWord(val word: String) : - Failure("“$word” is not a recovery phrase word.") object ChecksumFailed : Failure("That phrase does not check out, look for a mistyped word.") - class WordlistDigestMismatch(val found: String) : - Failure("The word list does not match its published fingerprint.") - class WordlistWrongLength(val count: Int) : - Failure("A word list has exactly 2048 words, not $count.") + + class WordlistDigestMismatch( + val found: String, + ) : Failure("The word list does not match its published fingerprint.") + + class WordlistWrongLength( + val count: Int, + ) : Failure("A word list has exactly 2048 words, not $count.") } /** * The published SHA-256 of the BIP-39 English word list. - * - * Wrong here fails CLOSED, a correct list would be rejected and nothing - * would derive, which is the safe direction to be wrong in. */ const val ENGLISH_DIGEST = "2f5eed53a4727b4bf8880d8f3f199efc90e58503646d9ff8eff3a2ed3b24dbda" /** - * The whole mitigation. A silently corrupted list does not error, it - * produces a DIFFERENT wallet from the same phrase, and the way somebody - * finds out is restoring and seeing an empty account. + * The whole mitigation. */ - fun verify(words: List, digest: String = ENGLISH_DIGEST) { + fun verify( + words: List, + digest: String = ENGLISH_DIGEST, + ) { if (words.size != 2048) throw Failure.WordlistWrongLength(words.size) val canonical = words.joinToString("\n") + "\n" - val hex = MessageDigest.getInstance("SHA-256") - .digest(canonical.toByteArray(Charsets.UTF_8)) - .joinToString("") { "%02x".format(it) } + val hex = + MessageDigest + .getInstance("SHA-256") + .digest(canonical.toByteArray(Charsets.UTF_8)) + .joinToString("") { "%02x".format(it) } if (hex != digest) throw Failure.WordlistDigestMismatch(hex) } private val PERMITTED = mapOf(16 to 12, 20 to 15, 24 to 18, 28 to 21, 32 to 24) /** - * Entropy to phrase. The checksum is the first `entropy.size / 4` bits of - * SHA-256(entropy), which is what makes a mistyped word fail rather than - * silently name another wallet. + * Entropy to phrase. */ - fun phrase(entropy: ByteArray, words: List): String { + fun phrase( + entropy: ByteArray, + words: List, + ): String { if (!PERMITTED.containsKey(entropy.size)) throw Failure.BadEntropySize(entropy.size) val checksum = MessageDigest.getInstance("SHA-256").digest(entropy) val bits = StringBuilder() @@ -84,12 +85,11 @@ object Bip39 { /** * Phrase back to entropy, refusing anything that does not check out. - * - * Every failure is loud. A phrase of the wrong length, containing a word - * not on the list, or failing its checksum is rejected, never coerced into - * the nearest valid thing, which would hand somebody a wallet not theirs. */ - fun entropy(phrase: String, words: List): ByteArray { + fun entropy( + phrase: String, + words: List, + ): ByteArray { val given = normalise(phrase).trim().split(Regex("\\s+")).filter { it.isNotEmpty() } if (given.size !in listOf(12, 15, 18, 21, 24)) throw Failure.BadPhraseLength(given.size) @@ -107,8 +107,11 @@ object Bip39 { for (i in entropy.indices) { entropy[i] = bits.substring(i * 8, i * 8 + 8).toInt(2).toByte() } - val expected = MessageDigest.getInstance("SHA-256").digest(entropy) - .joinToString("") { bitsOf(it) } + val expected = + MessageDigest + .getInstance("SHA-256") + .digest(entropy) + .joinToString("") { bitsOf(it) } val carried = bits.substring(entropyBits) if (carried != expected.substring(0, entropyBits / 32)) throw Failure.ChecksumFailed return entropy @@ -116,12 +119,11 @@ object Bip39 { /** * Phrase to the 64-byte seed everything derives from. - * - * The optional passphrase is BIP-39's, not the account's. A different one - * silently yields a DIFFERENT valid wallet, the standard's design, and a - * sharp edge worth naming wherever it is offered to somebody. */ - fun seed(phrase: String, passphrase: String = ""): ByteArray = + fun seed( + phrase: String, + passphrase: String = "", + ): ByteArray = pbkdf2HmacSha512( password = normalise(phrase).toByteArray(Charsets.UTF_8), salt = normalise("mnemonic$passphrase").toByteArray(Charsets.UTF_8), @@ -131,52 +133,34 @@ object Bip39 { /** * The vendored list, read from the module's resources. - * - * Verified on every load rather than once at build. The file ships inside - * the app, and a check that only ran in CI would not notice one that - * arrived corrupted. */ fun englishWordlist(): List { - val stream = Bip39::class.java.getResourceAsStream("english.txt") - ?: throw Failure.WordlistWrongLength(0) - val words = stream.bufferedReader(Charsets.UTF_8).use { it.readText() } - .trim().split("\n").map { it.trim() }.filter { it.isNotEmpty() } + val stream = + Bip39::class.java.getResourceAsStream("english.txt") + ?: throw Failure.WordlistWrongLength(0) + val words = + stream + .bufferedReader(Charsets.UTF_8) + .use { it.readText() } + .trim() + .split("\n") + .map { it.trim() } + .filter { it.isNotEmpty() } verify(words) return words } - // ---- Internals ------------------------------------------------------- + // ---- Internals -------------------------------------------------------. - private fun bitsOf(b: Byte): String = - (b.toInt() and 0xff).toString(2).padStart(8, '0') + private fun bitsOf(b: Byte): String = (b.toInt() and 0xff).toString(2).padStart(8, '0') /** - * NFKD, and it decides which wallet you get. BIP-39 normalises the - * phrase and the salt before stretching. A phrase typed with composed - * accents and one typed with combining marks are the same words to a - * reader and different bytes to PBKDF2, so skipping this derives a - * different, perfectly valid, empty wallet. + * NFKD, and it decides which wallet you get. */ - private fun normalise(text: String): String = - Normalizer.normalize(text, Normalizer.Form.NFKD) + private fun normalise(text: String): String = Normalizer.normalize(text, Normalizer.Form.NFKD) /** * PBKDF2-HMAC-SHA512. - * - * Hand-written on top of [Mac] rather than through - * `SecretKeyFactory("PBKDF2WithHmacSHA512")`, and the reason is money. - * The JCA path takes a `CharArray` and the provider decides how to encode - * it, `AccountKdf.master` already carries a note about that for the - * account KDF, where the same ambiguity exists. For a wallet the - * consequence is worse than a failed sign-in: a phrase containing anything - * outside ASCII would stretch to different bytes under a different - * provider, and derive a different wallet that is empty and perfectly - * valid, with no error anywhere. Taking the bytes ourselves removes the - * provider from the decision entirely. - * - * Also: `PBKDF2WithHmacSHA512` is absent on Android below API 26, and - * this engine's floor is lower than the rest of the app's for a reason, - * the JVM tests run it with no emulator. */ internal fun pbkdf2HmacSha512( password: ByteArray, @@ -193,10 +177,14 @@ object Bip39 { while (written < length) { mac.reset() mac.update(salt) - mac.update(byteArrayOf( - (block ushr 24).toByte(), (block ushr 16).toByte(), - (block ushr 8).toByte(), block.toByte(), - )) + mac.update( + byteArrayOf( + (block ushr 24).toByte(), + (block ushr 16).toByte(), + (block ushr 8).toByte(), + block.toByte(), + ), + ) var u = mac.doFinal() val accumulated = u.copyOf() for (round in 2..iterations) { diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Bytes.kt b/engine/src/main/kotlin/io/flamenet/e2e/Bytes.kt index a743d8f..06b3fe1 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/Bytes.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/Bytes.kt @@ -4,30 +4,25 @@ import java.security.MessageDigest /** * Byte helpers shared by the rest of the engine. - * - * Kotlin's `ByteArray` is signed, which is a recurring source of quiet bugs - * when porting from JavaScript's `Uint8Array`: `byte` values above 127 read as - * negative, so an unguarded comparison or shift silently disagrees with the - * reference implementation. Everything here converts through `and 0xFF`. */ internal object Bytes { - fun concat(vararg parts: ByteArray): ByteArray { val out = ByteArray(parts.sumOf { it.size }) var at = 0 - for (p in parts) { p.copyInto(out, at); at += p.size } + for (p in parts) { + p.copyInto(out, at) + at += p.size + } return out } /** * Unsigned lexicographic comparison. - * - * The signed default would order 0x80 before 0x00, which for safety numbers - * means two devices pick opposite orderings and read out different numbers - * for the same conversation, a false mismatch, and the worst possible - * failure for a check whose entire purpose is detecting a real one. */ - fun compareUnsigned(a: ByteArray, b: ByteArray): Int { + fun compareUnsigned( + a: ByteArray, + b: ByteArray, + ): Int { val len = minOf(a.size, b.size) for (i in 0 until len) { val d = (a[i].toInt() and 0xFF) - (b[i].toInt() and 0xFF) @@ -38,24 +33,28 @@ internal object Bytes { /** * Comparison that does not stop early. - * - * Used wherever a mismatch is attacker-visible; `contentEquals` returns as - * soon as it can, which leaks how much of a value was right. */ - fun constantTimeEquals(a: ByteArray, b: ByteArray): Boolean { + fun constantTimeEquals( + a: ByteArray, + b: ByteArray, + ): Boolean { if (a.size != b.size) return false var diff = 0 for (i in a.indices) diff = diff or (a[i].toInt() xor b[i].toInt()) return diff == 0 } - fun sha256(data: ByteArray): ByteArray = - MessageDigest.getInstance("SHA-256").digest(data) + fun sha256(data: ByteArray): ByteArray = MessageDigest.getInstance("SHA-256").digest(data) - fun sha512(data: ByteArray): ByteArray = - MessageDigest.getInstance("SHA-512").digest(data) + fun sha512(data: ByteArray): ByteArray = MessageDigest.getInstance("SHA-512").digest(data) - fun b64(data: ByteArray): String = java.util.Base64.getEncoder().encodeToString(data) + fun b64(data: ByteArray): String = + java.util.Base64 + .getEncoder() + .encodeToString(data) - fun unb64(text: String): ByteArray = java.util.Base64.getDecoder().decode(text) + fun unb64(text: String): ByteArray = + java.util.Base64 + .getDecoder() + .decode(text) } diff --git a/engine/src/main/kotlin/io/flamenet/e2e/ContactCard.kt b/engine/src/main/kotlin/io/flamenet/e2e/ContactCard.kt index b52c95f..48a8f6b 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/ContactCard.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/ContactCard.kt @@ -2,47 +2,21 @@ package io.flamenet.e2e /** * Everything somebody needs to reach you on another relay. - * - * `flamenet:@`, an account address and the relay that homes it. - * - * The delivery key is deliberately not part of this, and the iOS version - * of this file carried one on a wrong premise worth recording. Three things - * were conflated: sealing an envelope uses the recipient DEVICE's `ik_dh`, - * which the remote directory returns; the delivery key is a separate - * anti-abuse capability the LOCAL relay checks before accepting unattributed - * mail; and the federated send carries no delivery key at all. Putting it in - * the address published a secret-shaped thing for nothing and doubled the - * length of every card. - * - * What is true, and drives the send path: a relay that accepts a federated - * envelope stores it with no sender, it was not told who sent it and must - * not invent an attribution, so a message crossing a boundary must be sealed - * or it arrives unattributable and is dropped. Sealing is optional locally and - * mandatory here. */ data class ContactCard( /** The account address, derived from the passphrase. */ val fnid: String, /** - * The relay that homes it. Not authoritative, homes can change, but it is - * where to look first. + * The relay that homes it. */ val relay: String, /** - * Kept only so an older card still parses. Nothing reads it and nothing - * should start to: see the note above. + * Kept only so an older card still parses. */ val deliveryKey: String? = null, ) { - /** * `fnid@relay`, the address alone, no scheme and no query. - * - * The key every remote conversation is filed under, on both sides: - * what a federated envelope seals as its return address, and what an - * inbound one is threaded by. [encoded] is for a human to scan or paste and - * may carry more; this is the identity and has to stay byte-stable, so it - * is built from the already-normalised fields rather than from input text. */ val address: String get() = "$fnid@$relay" @@ -54,25 +28,13 @@ data class ContactCard( val encoded: String get() = "flamenet:$address" companion object { - /** * The stable form of an address string, or null if it is not one. - * - * Goes through [decode] rather than lowercasing in place, because - * the same refusals, no `/`, no `:`, a relay shaped like a hostname, - * have to apply to an address arriving inside a sealed envelope, which - * came from a peer nobody authenticated. Two spellings of one address - * would otherwise open two threads for one person. */ fun canonical(address: String): String? = decode(address)?.address /** * Parse a card, from a QR scan or a paste. - * - * Tolerant of a bare `fnid@relay` with no scheme, because that is - * what somebody retypes off a screen. Everything else is refused rather - * than guessed at, an address that is nearly right addresses somebody - * else. */ fun decode(text: String): ContactCard? { var s = text.trim() @@ -93,12 +55,9 @@ data class ContactCard( val fnid = s.substring(0, at).trim() val relay = s.substring(at + 1).trim().lowercase() if (fnid.isEmpty() || relay.isEmpty() || '.' !in relay) return null - // A second `@` means this is not one address. Refused rather than - // split on the first, which would silently address the wrong host. + // A second `@` means this is not one address. if ('@' in relay) return null - // A relay is a hostname, never a URL. Accepting "https://x/y" - // here would let a card point the client at a path on some other - // host of the card writer's choosing. + // A relay is a hostname, never a URL. if ('/' in relay || ':' in relay) return null return ContactCard(fnid, relay, key) } diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Content.kt b/engine/src/main/kotlin/io/flamenet/e2e/Content.kt index e41401f..836283e 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/Content.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/Content.kt @@ -5,38 +5,15 @@ import java.security.SecureRandom /** * What one decrypted message *means* (spec §6.6–§6.9). - * - * The ratchet stays content-agnostic: it moves opaque strings. A string that - * begins with the marker below and parses is a content envelope carrying a - * caption, an attachment reference, group routing, a reply, a reaction or a - * call signal. Anything else is a plain text message, byte for byte. - * - * That fallback is load-bearing. It is what makes every message sent - * before content envelopes existed still render, and it is why the marker - * check must be a prefix test on the serialised bytes rather than "try to - * parse it as JSON", a message whose text happens to be valid JSON is still - * a text message. - * - * `fnc` must be encoded first, because the marker is a prefix. A JSON - * library that sorts keys puts `att` first and every envelope it produces - * silently becomes plain text on the far side, rendering as raw JSON in - * somebody's chat. The top level is therefore assembled by hand. */ data class Content( val text: String, /** - * What this envelope claims to be. Kept rather than collapsed into flags, - * because the caller, not the parser, is what knows whether an unknown - * kind should be shown, consumed, or logged. + * What this envelope claims to be. */ val kind: String = Kind.TEXT, /** * The whole envelope, for kinds this engine does not interpret. - * - * Control messages travel down the same channel as chat: the web client - * sends `delivery_key`, `kt_head` and `sent` copies this way. A client that - * renders every envelope it does not understand shows those to the user as - * a bubble full of JSON, which is exactly what happens without this. */ val raw: JSONObject? = null, val attachment: Attachment.Ref? = null, @@ -53,106 +30,68 @@ data class Content( val sticker: Sticker? = null, /** * A preview of a link in this message, generated by the SENDER (§6.12). - * - * Rendered from these bytes and nothing else. The receiver never fetches - * the URL, that would tell the site who read the link and when, once per - * member on a group message. */ val link: LinkPreview? = null, /** A live stream this story embeds (§6.13). */ val streamUrl: String? = null, - /** * Who was named in this message, by user id. - * - * Ids, not names. A mention has to reach somebody whose group is muted, - * and matching on a display name would mean anybody could be reached by - * typing their name, or missed, because they had renamed themselves. */ val mentions: List = emptyList(), - /** * When this is a story made only of words, when it stops showing. - * - * A story envelope with no attachment. Every story path here assumed a - * picture, so a text story had nowhere to be carried, the same shape that - * was missing on iOS. */ val textStoryExpires: Double? = null, - /** * This reaction is about a STORY, not a message. - * - * Both are `kind: "react"`; only this separates them. Without it a story - * reaction tries to fold its emoji onto a message with that id, and if one - * ever matched, onto entirely the wrong thing. */ val reactsToStory: Boolean = false, - /** This message replies to a story rather than to a bubble. */ val repliesToStory: Boolean = false, /** - * Set iff this envelope replaces the text of an earlier message (§6.15): - * the `mid` of the message being edited. [text] then carries the new - * wording. - * - * An edit is never a bubble of its own. It changes one that is already - * there, or it does nothing. + * Set iff this envelope replaces the text of an earlier message (§6.15). + * the `mid` of the message being edited. */ val editOf: String? = null, /** * Set iff this envelope pins or unpins a message (§6.19): the id being * pinned, or the empty string to take the pin down. - * - * An id and never a copy of the message. */ val pins: String? = null, - /** The sequence this story item belongs to (§6.23), if any. */ val storySet: StorySet? = null, ) { - /** * A sticker: an id, never an image. - * - * Sending the picture would cost an upload and a download of bytes both - * ends already ship with, on every sticker. The receiver draws it from its - * own pack, so this is about sixty bytes on the wire. See docs/STICKERS.md. - * - * An unknown pack or id must degrade, never break, the renderer falls - * back rather than showing an empty bubble. */ - data class Sticker(val pack: String, val id: String) { + data class Sticker( + val pack: String, + val id: String, + ) { fun toJson(): JSONObject = JSONObject().put("pack", pack).put("id", id) companion object { - fun fromJson(json: JSONObject) = Sticker( - pack = json.optString("pack"), - id = json.optString("id"), - ) + fun fromJson(json: JSONObject) = + Sticker( + pack = json.optString("pack"), + id = json.optString("id"), + ) } } /** * A pointer at an earlier message. - * - * Carries `preview` as well as `messageId` deliberately. A device that - * joined later, or cleared its history, has never seen the quoted message, - * and a reply rendering as "> [missing]" is worse than one that shows what - * was said. The preview is the sender's copy of their own words, the only - * copy anyone can be sure of. */ + /** * Which sequence a story item belongs to (§6.23). - * - * Every item of a set must carry the same `exp`. Expiry is per set: - * per-item expiry lets a sequence decay into items 0 and 2, which is - * confusing and is also a disclosure, the gaps reveal the order the - * author added things. */ - data class StorySet(val id: String, val i: Int, val n: Int) { - fun toJson(): JSONObject = - JSONObject().put("id", id).put("i", i).put("n", n) + data class StorySet( + val id: String, + val i: Int, + val n: Int, + ) { + fun toJson(): JSONObject = JSONObject().put("id", id).put("i", i).put("n", n) companion object { fun fromJson(o: JSONObject): StorySet? { @@ -163,33 +102,38 @@ data class Content( } } - data class Reference(val messageId: String, val author: Long, val preview: String) { - fun toJson(): JSONObject = JSONObject() - .put("mid", messageId).put("author", author).put("preview", preview) + data class Reference( + val messageId: String, + val author: Long, + val preview: String, + ) { + fun toJson(): JSONObject = + JSONObject() + .put("mid", messageId) + .put("author", author) + .put("preview", preview) companion object { - fun fromJson(json: JSONObject) = Reference( - messageId = json.optString("mid"), - author = json.optLong("author"), - preview = json.optString("preview"), - ) + fun fromJson(json: JSONObject) = + Reference( + messageId = json.optString("mid"), + author = json.optLong("author"), + preview = json.optString("preview"), + ) } } /** * Call signalling riding the ratchet (§6.8). - * - * The SDP, and with it the DTLS fingerprint the media keys hang off, - * travels inside the ratchet, so the call is end-to-end authenticated with - * no extra key agreement. Never rendered as a message. */ data class CallSignal( val action: String, val callId: String, val sdp: String? = null, val reason: String? = null, - /** Offer/answer: this call carries video. Absent on the wire = false, - * so voice-only clients interoperate unchanged. */ + /** + * Offer/answer: this call carries video. + */ val video: Boolean = false, ) { companion object { @@ -202,68 +146,31 @@ data class Content( /** * True when this belongs in a thread. - * - * Reactions fold onto an existing bubble, call signals drive the call UI, - * and control messages are consumed silently. Everything else is a message. - * - * An edit is not a message either (§6.15). It carries the new - * wording in [text], so without this it falls straight through and draws as - * a second bubble beside the one it was meant to replace, a correction - * arriving as a duplicate, which is worse than the typo. */ val isRenderable: Boolean get() = reaction == null && call == null && pins == null && editOf == null && !isControl /** * Control traffic: consumed by a layer, never shown. - * - * An allowlist of known kinds was not enough. `kind !in CONTROL` is - * only correct for kinds this build has heard of; a control message from a - * NEWER peer has a kind that is in no set here, so it fell through and was - * rendered, a bubble full of JSON in somebody's conversation. Every future - * control kind would have shipped that bug to every older install. - * - * So new control kinds carry `"ctl":1`, and this trusts that flag as well - * as the list. The list stays because the kinds that predate the flag do - * not carry it and never will. */ val isControl: Boolean - // Both shapes accepted. `org.json`'s `optBoolean` does not coerce a - // number, so reading only that missed every `"ctl":1` the other two - // engines send, and reading only the number would miss the `true` this - // engine itself wrote before 2026-08-30, which is on phones now. - get() = kind in Kind.CONTROL || - raw?.optBoolean("ctl", false) == true || - (raw?.optInt("ctl", 0) ?: 0) != 0 + // Both shapes accepted. + get() = + kind in Kind.CONTROL || + raw?.optBoolean("ctl", false) == true || + (raw?.optInt("ctl", 0) ?: 0) != 0 /** * True when the sender is passing on somebody else's words (§6.16). - * - * Says THAT it was forwarded and never from whom. The point of a forward - * is that the original author does not travel with it; this exists so - * second-hand words are not shown as the sender's own, which is a different - * thing from attribution. - * - * Both shapes accepted, for the same reason [isControl] accepts both: - * `org.json`'s `optBoolean` does not coerce a number, so reading only that - * would miss every `"fwd":1` the other engines send. */ val isForwarded: Boolean - get() = raw?.optBoolean("fwd", false) == true || - (raw?.optInt("fwd", 0) ?: 0) != 0 + get() = + raw?.optBoolean("fwd", false) == true || + (raw?.optInt("fwd", 0) ?: 0) != 0 + /** - * How long this one message lives, in seconds (§6.17), or null for no - * timer of its own. - * - * A duration, never a deadline. An absolute expiry would be the - * sender's clock, and a sender who chose it could backdate a message so it - * vanished from the recipient's phone on arrival, or forward-date one so it - * never expired. The receiver counts from when IT recorded the message, - * the rule §6.11 already follows. - * - * Only on a message. A [Kind.DISAPPEAR] envelope carries `secs` too and - * means something entirely different: it sets the whole conversation. Both - * being read off one field is why this checks the kind. + * How long this one message lives, in seconds (§6.17), or null for no timer of + * its own. */ val lifetimeSeconds: Int? get() { @@ -274,25 +181,18 @@ data class Content( /** * A kind this build does not know, which is not control traffic. - * - * Deliberately not part of [isRenderable]. Unknown content already - * parses to a `[unsupported attachment]` placeholder and is shown, which is - * right: a peer sent the user something real, and silently dropping a - * message is the one failure a messenger cannot make. This exists so the UI - * can say something better than that placeholder if it wants to. */ val needsNewerClient: Boolean get() = kind !in Kind.KNOWN && !isControl /** * A link preview the sender generated (§6.12). - * - * [title] and [desc] come from a page the sender did not write. Bounded - * here rather than at render, so the cap does not depend on every future - * renderer remembering one, and shown beside [host], a card claiming one - * destination while [url] points at another is a phishing primitive. */ - data class LinkPreview(val url: String, val title: String, val desc: String) { + data class LinkPreview( + val url: String, + val title: String, + val desc: String, + ) { val host: String get() = runCatching { java.net.URI(url).host }.getOrNull() ?: url @@ -300,8 +200,11 @@ data class Content( const val MAX_TITLE = 120 const val MAX_DESC = 200 - fun of(url: String, title: String, desc: String) = - LinkPreview(url, title.take(MAX_TITLE), desc.take(MAX_DESC)) + fun of( + url: String, + title: String, + desc: String, + ) = LinkPreview(url, title.take(MAX_TITLE), desc.take(MAX_DESC)) fun fromJson(o: JSONObject): LinkPreview? { val url = o.optString("url").takeIf { it.isNotBlank() } ?: return null @@ -311,8 +214,11 @@ data class Content( } } - fun toJson(): JSONObject = JSONObject() - .put("url", url).put("title", title).put("desc", desc) + fun toJson(): JSONObject = + JSONObject() + .put("url", url) + .put("title", title) + .put("desc", desc) } object Kind { @@ -333,47 +239,16 @@ data class Content( /** * The sender's profile picture, as an encrypted blob reference. - * - * An avatar is an attachment, not a field on the account. The - * obvious design, upload a JPEG to the relay and let it serve one per - * user, hands the server everybody's face, on a product whose whole - * claim is that it cannot see what you send. This rides the same - * encrypted blob path as a photo message: the relay stores ciphertext - * and the key travels to your contacts inside the ratchet, exactly as - * the delivery key already does. - * - * The cost, stated: it is per-contact. Somebody who has never - * messaged you cannot see your picture, and changing it means telling - * each contact again. That is the same trade sealed sender already - * makes, and it is the honest one for this product. */ const val AVATAR = "avatar" /** * A bridge line, offered by a contact (§6.19). - * - * An OFFER, never a setting. A bridge line tells this phone to - * route every relay connection through a machine somebody else chose. - * Applying one because it arrived would mean a contact, or anyone who - * has taken their account, could silently move another person's - * traffic onto a host they control. They could not read it, because - * the ratchet does not care what carries it, but they would learn who - * is online and when, and could cut them off. So this is stored as a - * pending offer and applied only by the person it was sent to. - * - * Control, so older builds swallow it. A build that has never - * heard of this kind consumes it via the `ctl` flag and shows nothing. - * The alternative, a bubble full of JSON in somebody's conversation, - * is what `ctl` was introduced to stop. */ const val BRIDGE = "bridge" /** * A per-conversation disappearing-message timer, in seconds (§6.11). - * - * Announced inside the ratchet so the relay never learns that a - * conversation is timed, let alone for how long. Carries `ctl` because - * it postdates the original four control kinds. */ const val DISAPPEAR = "ttl" @@ -382,56 +257,57 @@ data class Content( /** * A replacement for the text of an earlier message (§6.15). - * - * Not in [CONTROL]. Control kinds are swallowed whole; this one - * has to be acted on, and a build that merely swallowed it would drop - * corrections silently while looking like it worked. */ const val EDIT = "edit" /** * A pinned message, or a pin being taken down (§6.19). - * - * Not in [CONTROL]. Control kinds are swallowed whole; this one - * has to be acted on, and a build that merely swallowed it would drop - * every pin while looking like it worked. */ const val PIN = "pin" /** - * Consumed, never shown. Each of these is a real message the web - * client sends today, and rendering one puts raw JSON in somebody's - * chat. + * Consumed, never shown. */ - val CONTROL = setOf(DELIVERY_KEY, KEY_LOG_HEAD, SENT_COPY, AVATAR, DISAPPEAR, - SCREENSHOT, BRIDGE) + val CONTROL = + setOf( + DELIVERY_KEY, + KEY_LOG_HEAD, + SENT_COPY, + AVATAR, + DISAPPEAR, + SCREENSHOT, + BRIDGE, + ) /** * Every kind this build can act on. - * - * Anything outside this set came from a newer peer. It is either - * control traffic (swallowed, via the `ctl` flag) or content we cannot - * draw (shown as a placeholder), never raw JSON in a bubble. */ - val KNOWN = CONTROL + setOf( - TEXT, REACT, STICKER, PIN, EDIT, - Attachment.IMAGE, Attachment.AUDIO, Attachment.STORY, Attachment.FILE, - CallSignal.OFFER, CallSignal.ANSWER, CallSignal.END, - ) + val KNOWN = + CONTROL + + setOf( + TEXT, + REACT, + STICKER, + PIN, + EDIT, + Attachment.IMAGE, + Attachment.AUDIO, + Attachment.STORY, + Attachment.FILE, + CallSignal.OFFER, + CallSignal.ANSWER, + CallSignal.END, + ) } companion object { /** A plaintext is an envelope iff it starts with exactly this AND parses. */ private const val MARKER = "{\"fnc\":" - // -- Encoding -------------------------------------------------------- + // -- Encoding --------------------------------------------------------. /** * Plain 1:1 text with no id and no reply stays raw UTF-8. - * - * Not an optimisation. A client that predates content envelopes shows - * an envelope as literal JSON, so the safe default for the simplest - * message is the format every client has always understood. */ fun text( body: String, @@ -441,17 +317,22 @@ data class Content( forwarded: Boolean = false, lifetimeSeconds: Int? = null, ): String = - // Both of these belong in this condition, not just in the - // envelope. A plain message with nothing else to carry goes out as - // raw bytes with no envelope at all, so a forward that was not - // counted here would lose its flag and arrive looking like the - // sender's own words, and a timed message would lose its timer and - // live for ever. - if (messageId == null && about == null && link == null && !forwarded - && (lifetimeSeconds ?: 0) <= 0) body - else assemble("text", caption = body, messageId = messageId, about = about, - link = link, forwarded = forwarded, - lifetimeSeconds = lifetimeSeconds) + // Both of these belong in this condition, not just in the envelope. + if (messageId == null && about == null && link == null && !forwarded && + (lifetimeSeconds ?: 0) <= 0 + ) { + body + } else { + assemble( + "text", + caption = body, + messageId = messageId, + about = about, + link = link, + forwarded = forwarded, + lifetimeSeconds = lifetimeSeconds, + ) + } fun groupText( body: String, @@ -461,9 +342,17 @@ data class Content( groupName: String? = null, mentions: List = emptyList(), forwarded: Boolean = false, - ): String = assemble("text", caption = body, groupId = groupId, - messageId = messageId, about = about, groupName = groupName, - mentions = mentions, forwarded = forwarded) + ): String = + assemble( + "text", + caption = body, + groupId = groupId, + messageId = messageId, + about = about, + groupName = groupName, + mentions = mentions, + forwarded = forwarded, + ) fun attachment( ref: Attachment.Ref, @@ -478,88 +367,103 @@ data class Content( lifetimeSeconds: Int? = null, /** * The sequence this item belongs to (§6.23). - * - * Every item of a set must carry the same `exp`. Expiry is per - * set: per-item expiry lets a sequence decay into items 0 and 2, - * which is confusing and is also a disclosure, the gaps reveal - * the order the author added things. */ storySet: StorySet? = null, - ): String = assemble( - kind = ref.kind, - // A file envelope ALWAYS carries a caption, even with none - // written. Builds that predate this kind show the caption and drop - // the rest, so an empty one reaches them as "[unsupported - // attachment]" with no clue what was sent. Matches iOS exactly. - caption = caption?.takeIf { it.isNotEmpty() } - ?: "📎 ".plus(ref.filename.ifEmpty { "A file" }) - .takeIf { ref.kind == Attachment.FILE }, - expires = ref.expires.takeIf { ref.kind == Attachment.STORY && it > 0 }, - groupId = groupId, - attachment = ref, - filename = ref.filename.takeIf { ref.kind == Attachment.FILE }, - label = if (ref.kind == Attachment.FILE) { - ref.classification to ref.compartments - } else null, - messageId = messageId, - about = about, - streamUrl = streamUrl, - groupName = groupName, - forwarded = forwarded, - lifetimeSeconds = lifetimeSeconds, - storySet = storySet, - ) + ): String = + assemble( + kind = ref.kind, + // A file envelope ALWAYS carries a caption, even with none written. + caption = + caption?.takeIf { it.isNotEmpty() } + ?: "📎 " + .plus(ref.filename.ifEmpty { "A file" }) + .takeIf { ref.kind == Attachment.FILE }, + expires = ref.expires.takeIf { ref.kind == Attachment.STORY && it > 0 }, + groupId = groupId, + attachment = ref, + filename = ref.filename.takeIf { ref.kind == Attachment.FILE }, + label = + if (ref.kind == Attachment.FILE) { + ref.classification to ref.compartments + } else { + null + }, + messageId = messageId, + about = about, + streamUrl = streamUrl, + groupName = groupName, + forwarded = forwarded, + lifetimeSeconds = lifetimeSeconds, + storySet = storySet, + ) /** * A story that is only words. - * - * Carries a [messageId], which is what identifies it across the - * sender's devices, a picture story is identified by its blob token - * and a text story has none. Without it the same story appears once - * per phone the sender posted from. */ - fun textStory(text: String, expires: Double, messageId: String): String = - assemble(Attachment.STORY, caption = text, expires = expires, - messageId = messageId) + fun textStory( + text: String, + expires: Double, + messageId: String, + ): String = + assemble( + Attachment.STORY, + caption = text, + expires = expires, + messageId = messageId, + ) /** * React to somebody's story. - * - * Goes to the AUTHOR alone. A story is sent to each viewer rather - * than hosted, so there is no shared object to attach a reaction to - * and nobody else to tell, which is why other viewers never learn who - * reacted: there is nowhere for them to look. */ - fun storyReaction(emoji: String, storyKey: String, author: Long): String = - assemble(Kind.REACT, caption = emoji, - about = Reference(storyKey, author, ""), aboutAStory = true) + fun storyReaction( + emoji: String, + storyKey: String, + author: Long, + ): String = + assemble( + Kind.REACT, + caption = emoji, + about = Reference(storyKey, author, ""), + aboutAStory = true, + ) /** * Reply to somebody's story, as an ordinary sealed direct message. - * - * [preview] travels with the reply because stories expire: by the - * time this is read the story may be gone, and "replied to your story" - * with no idea which one cannot be understood. */ - fun storyReply(body: String, storyKey: String, author: Long, - preview: String, messageId: String): String = - assemble("text", caption = body, messageId = messageId, - about = Reference(storyKey, author, preview), aboutAStory = true) + fun storyReply( + body: String, + storyKey: String, + author: Long, + preview: String, + messageId: String, + ): String = + assemble( + "text", + caption = body, + messageId = messageId, + about = Reference(storyKey, author, preview), + aboutAStory = true, + ) /** - * An empty emoji takes a previous reaction back. Removal has to be a - * message rather than an absence: the other device has already rendered - * the first one, and there is no other way to reach it. + * An empty emoji takes a previous reaction back. */ - fun reaction(emoji: String, about: Reference, groupId: String? = null): String = - assemble("react", caption = emoji, groupId = groupId, about = about) + fun reaction( + emoji: String, + about: Reference, + groupId: String? = null, + ): String = assemble("react", caption = emoji, groupId = groupId, about = about) /** - * Hand-ordered like the rest: the receive side's marker check - * matches the serialized prefix, so `fnc` has to come first. + * Hand-ordered like the rest: the receive side's marker check matches + * the serialized prefix, so `fnc` has to come first. */ - fun sticker(pack: String, id: String, groupId: String? = null, - messageId: String? = null): String { + fun sticker( + pack: String, + id: String, + groupId: String? = null, + messageId: String? = null, + ): String { val stk = JSONObject().put("pack", pack).put("id", id) val parts = mutableListOf("\"fnc\":1", "\"kind\":${quote(Kind.STICKER)}") if (messageId != null) parts += "\"mid\":${quote(messageId)}" @@ -570,35 +474,31 @@ data class Content( /** * An edit of an earlier message (§6.15). - * - * Reuses `re` to name the target, exactly as a reaction does, a new - * field would be a second way of saying "this is about that message" - * and would need adding to two clients, an engine and a spec before - * anything worked. - * - * `re.author` is not evidence and the receiver must not read it as - * evidence. Anyone can put any user id there. The receiver decides - * whether an edit is allowed from the copy of the message it already - * holds, never from what the envelope claims about itself. */ - fun edit(newText: String, of: String, groupId: String? = null): String = - assemble(Kind.EDIT, caption = newText, groupId = groupId, - about = Reference(of, author = 0, preview = "")) + fun edit( + newText: String, + of: String, + groupId: String? = null, + ): String = + assemble( + Kind.EDIT, + caption = newText, + groupId = groupId, + about = Reference(of, author = 0, preview = ""), + ) /** * A pin, or a pin being taken down (§6.19). - * - * An empty [messageId] unpins. Removal has to be a message rather - * than an absence: the other device has already drawn the pin, and - * there is no other way to reach it. - * - * An id and never a copy of the message. A pin carrying the text - * would keep a message readable after its own timer had removed it, so - * pinning would quietly defeat disappearing messages. */ - fun pin(messageId: String, groupId: String? = null): String = - assemble(Kind.PIN, groupId = groupId, - about = Reference(messageId, author = 0, preview = "")) + fun pin( + messageId: String, + groupId: String? = null, + ): String = + assemble( + Kind.PIN, + groupId = groupId, + about = Reference(messageId, author = 0, preview = ""), + ) fun callSignal(signal: CallSignal): String { val call = JSONObject().put("id", signal.callId) @@ -628,12 +528,10 @@ data class Content( storySet: StorySet? = null, ): String { val parts = mutableListOf("\"fnc\":1", "\"kind\":${quote(kind)}") - // `1`, not `true`, §6.10's shape for every flag on this - // envelope, and what the other two engines read first. + // `1`, not `true`, §6.10's shape for every flag on this envelope, + // and what the other two engines read first. if (forwarded) parts += "\"fwd\":1" - // §6.17, this one message's own timer. Reuses `secs`, the field - // the per-conversation announcement already uses, rather than - // inventing a second name for a number of seconds. + // §6.17, this one message's own timer. if (lifetimeSeconds != null && lifetimeSeconds > 0) { parts += "\"secs\":$lifetimeSeconds" } @@ -643,21 +541,13 @@ data class Content( expires?.let { parts += "\"exp\":$it" } groupId?.let { parts += "\"gid\":${quote(it)}" } // What the group is called, on every group envelope. - // - // The relay no longer stores this, so the members are the - // only record of it and a member with no copy has nobody to ask. - // Repeating it on ordinary traffic means a phone set up later, or - // one that missed the announcement, fills the gap the first time - // anyone says anything. It is inside the ratchet either way. groupName?.takeIf { it.isNotEmpty() }?.let { parts += "\"gname\":${quote(it)}" } attachment?.let { parts += "\"att\":${it.toJson()}" } link?.let { parts += "\"lnk\":${it.toJson()}" } streamUrl?.let { parts += "\"stream\":${quote(it)}" } // §6.23, the sequence this item belongs to. storySet?.let { parts += "\"set\":${it.toJson()}" } - // File envelopes only. Additive top-level keys: a build that - // predates them ignores them and still reads the caption, which is - // what keeps a file message legible to an older peer. + // File envelopes only. filename?.takeIf { it.isNotEmpty() }?.let { parts += "\"fn\":${quote(it)}" } if (mentions.isNotEmpty()) { parts += "\"men\":${mentions.sorted().joinToString(",", "[", "]")}" @@ -671,81 +561,45 @@ data class Content( } /** A profile-picture reference, as control traffic. */ - fun avatar(ref: Attachment.Ref): String = - assemble(Kind.AVATAR, attachment = ref) + fun avatar(ref: Attachment.Ref): String = assemble(Kind.AVATAR, attachment = ref) /** * Announce this conversation's disappearing-message timer (§6.11). - * - * `seconds` is 0 to turn it off. Hand-assembled like everything else - * here so `fnc` stays the first key, the marker is a prefix test, and - * a reordered top level silently becomes a plain-text message showing - * as raw JSON in somebody's chat. */ fun disappearing(seconds: Int): String = - // `1`, not `true`. The specification says `"ctl":1`, and iOS - // decodes the field as a number, so a boolean here did not merely - // fail to set the flag, it failed the WHOLE envelope's decode and - // fell through to the plain-text path. An Android user turning on - // disappearing messages sent an iPhone a bubble containing raw - // JSON, and the timer was never applied at that end. + // `1`, not `true`. "{\"fnc\":1,\"kind\":${quote(Kind.DISAPPEAR)},\"ctl\":1,\"secs\":$seconds}" /** * Tell somebody their disappearing message was screenshotted (§6.27). - * - * A courtesy from the taker's own client, and nothing else. A - * modified build, a second phone photographing the screen, or the - * platform's own recorder all produce nothing. A notice means one was - * probably taken; no notice means NOTHING, see [screenshotDisclaimer]. */ - fun screenshot(atSeconds: Long): String = - "{\"fnc\":1,\"kind\":${quote(Kind.SCREENSHOT)},\"ctl\":1,\"at\":$atSeconds}" + fun screenshot(atSeconds: Long): String = "{\"fnc\":1,\"kind\":${quote(Kind.SCREENSHOT)},\"ctl\":1,\"at\":$atSeconds}" /** * Offer a contact the bridge this phone is using (§6.19). - * - * The line goes across whole, exactly as an operator wrote it, - * because the `cert=` is what makes it usable and the fingerprint is - * what the receiving person may want to check against another source. - * Reassembling it from parsed fields at the far end would lose both. - * - * `1`, not `true`, for the same reason [disappearing] documents at - * length: iOS decodes `ctl` as a number, and a boolean fails the whole - * envelope's decode rather than merely failing to set the flag. */ - fun bridgeOffer(line: String): String = - "{\"fnc\":1,\"kind\":${quote(Kind.BRIDGE)},\"ctl\":1,\"line\":${quote(line)}}" + fun bridgeOffer(line: String): String = "{\"fnc\":1,\"kind\":${quote(Kind.BRIDGE)},\"ctl\":1,\"line\":${quote(line)}}" /** * The bridge line in a [Kind.BRIDGE] envelope, or null. - * - * Returns the raw string and validates nothing. Whether a line is - * usable is `BridgeLine.parse`'s question, and it is asked on the - * screen where a person decides whether to accept, not here, where a - * rejection would be silent. */ fun bridgeLine(content: Content): String? = if (content.kind == Kind.BRIDGE) { content.raw?.optString("line")?.takeIf { it.isNotBlank() } - } else null + } else { + null + } /** When a [Kind.SCREENSHOT] envelope says it happened, or null. */ fun screenshotAt(content: Content): Long? = if (content.kind == Kind.SCREENSHOT) { content.raw?.optLong("at", 0L)?.takeIf { it > 0 } - } else null + } else { + null + } /** * What may never be inferred from the absence of notices. - * Presenting "no screenshots" as a fact, a count of zero, a tick, a - * reassuring summary, would be inventing evidence out of silence. - * - * Android's half of the asymmetry is stated rather than hidden. - * There is no screenshot callback at all before API 34, and the one - * added there does not cover screen recording. Claiming a symmetry - * with iOS that does not exist would be worse than the gap, because - * somebody would rely on it. */ const val screenshotDisclaimer = "You are told when their app can tell. It cannot always: another phone " + @@ -756,12 +610,6 @@ data class Content( /** * Whether to send a notice now (§6.27). - * - * Only where a timer is set. Everywhere else this would be a - * general surveillance feature rather than the narrow one it is. - * - * The timer check comes FIRST, so a rate-limit window cannot make - * an untimed conversation start reporting. */ fun shouldAnnounceScreenshot( disappearSeconds: Int, @@ -775,24 +623,22 @@ data class Content( } /** The timer a [Kind.DISAPPEAR] envelope carries, or null if it is not one. */ - fun disappearSeconds(content: Content): Int? = - if (content.kind == Kind.DISAPPEAR) content.raw?.optInt("secs", 0) else null + fun disappearSeconds(content: Content): Int? = if (content.kind == Kind.DISAPPEAR) content.raw?.optInt("secs", 0) else null /** JSON string escaping, via the library, so control characters survive. */ private fun quote(value: String): String = JSONObject.quote(value) - // -- Decoding -------------------------------------------------------- + // -- Decoding --------------------------------------------------------. /** - * An envelope of an unknown kind renders as a visible label rather - * than vanishing. The sender meant to say something, and a message that - * silently disappears is the worst possible way to handle a version - * this client does not know about. + * An envelope of an unknown kind renders as a visible label rather than + * vanishing. */ fun parse(plaintext: String): Content { if (!plaintext.startsWith(MARKER)) return Content(plaintext) - val json = runCatching { JSONObject(plaintext) }.getOrNull() - ?: return Content(plaintext) + val json = + runCatching { JSONObject(plaintext) }.getOrNull() + ?: return Content(plaintext) if (json.optInt("fnc") != 1) return Content(plaintext) val kind = json.optString("kind") @@ -800,31 +646,38 @@ data class Content( val messageId = json.optString("mid").takeIf { it.isNotEmpty() } val about = json.optJSONObject("re")?.let(Reference::fromJson) val caption = json.optString("caption") - // Additive keys. An older peer omits them and every default here - // is what that peer meant. + // Additive keys. val aboutAStory = json.optInt("st", 0) != 0 || json.optBoolean("st", false) val storySet = json.optJSONObject("set")?.let(StorySet::fromJson) - val mentions = json.optJSONArray("men")?.let { array -> - (0 until array.length()).map { array.optInt(it) } - } ?: emptyList() + val mentions = + json.optJSONArray("men")?.let { array -> + (0 until array.length()).map { array.optInt(it) } + } ?: emptyList() // A reaction is not a message and must never become a bubble. - // Checked before everything else so a field-ordering change cannot - // let one fall through and render as an empty line in the thread. if (kind == Kind.REACT && about != null) { - return Content("", kind = kind, raw = json, groupId = groupId, - messageId = messageId, about = about, reaction = caption, - reactsToStory = aboutAStory) + return Content( + "", + kind = kind, + raw = json, + groupId = groupId, + messageId = messageId, + about = about, + reaction = caption, + reactsToStory = aboutAStory, + ) } - // A story made of words. Checked before the attachment paths, which - // would otherwise treat it as an attachment reference that failed - // to parse, and before the "[unsupported attachment]" fallback an - // older build correctly shows for it. + // A story made of words. if (kind == Attachment.STORY && json.optJSONObject("att") == null) { - return Content(caption, kind = kind, raw = json, groupId = groupId, + return Content( + caption, + kind = kind, + raw = json, + groupId = groupId, messageId = messageId, - textStoryExpires = json.optDouble("exp", 0.0)) + textStoryExpires = json.optDouble("exp", 0.0), + ) } // A sticker is renderable but is NOT text: it draws as a glyph with @@ -833,97 +686,129 @@ data class Content( if (kind == Kind.STICKER) { val stk = json.optJSONObject("stk") if (stk != null) { - return Content("", kind = kind, raw = json, groupId = groupId, - messageId = messageId, sticker = Sticker.fromJson(stk)) + return Content( + "", + kind = kind, + raw = json, + groupId = groupId, + messageId = messageId, + sticker = Sticker.fromJson(stk), + ) } } // §6.15, a replacement for the text of an earlier message. - // Checked BEFORE the control swallow, which would consume it - // correctly and act on nothing, and before TEXT, which would render - // the new wording as a second bubble beside the original. if (kind == Kind.EDIT && about != null) { - return Content(caption, kind = kind, raw = json, groupId = groupId, - about = about, editOf = about.messageId) + return Content( + caption, + kind = kind, + raw = json, + groupId = groupId, + about = about, + editOf = about.messageId, + ) } - // §6.19, a pin changes a banner and is never a bubble. Checked - // before the control swallow, which would consume it correctly and - // act on nothing. + // §6.19, a pin changes a banner and is never a bubble. if (kind == Kind.PIN && about != null) { - return Content("", kind = kind, raw = json, groupId = groupId, - about = about, pins = about.messageId) + return Content( + "", + kind = kind, + raw = json, + groupId = groupId, + about = about, + pins = about.messageId, + ) } - // Control traffic. Returned with its envelope so the caller can act - // on it, and never renderable. - // - // An avatar is control traffic that CARRIES an attachment, so - // its ref is parsed here rather than falling through to the - // renderable path below, which would put a profile picture in the - // conversation as though somebody had sent it as a photo. + // Control traffic. if (kind == Kind.AVATAR) { - val avatar = json.optJSONObject("att") - ?.let { Attachment.Ref.fromJson(Attachment.IMAGE, it, 0.0) } + val avatar = + json + .optJSONObject("att") + ?.let { Attachment.Ref.fromJson(Attachment.IMAGE, it, 0.0) } return Content("", kind = kind, raw = json, attachment = avatar) } if (kind in Kind.CONTROL) return Content("", kind = kind, raw = json) if (kind in CallSignal.ACTIONS) { val call = json.optJSONObject("call") ?: return Content("", kind = kind, raw = json) - return Content("", kind = kind, raw = json, call = CallSignal( - action = kind, - callId = call.optString("id"), - sdp = call.optString("sdp").takeIf { it.isNotEmpty() }, - reason = call.optString("reason").takeIf { it.isNotEmpty() }, - video = call.optBoolean("video", false), - )) + return Content( + "", + kind = kind, + raw = json, + call = + CallSignal( + action = kind, + callId = call.optString("id"), + sdp = call.optString("sdp").takeIf { it.isNotEmpty() }, + reason = call.optString("reason").takeIf { it.isNotEmpty() }, + video = call.optBoolean("video", false), + ), + ) } if (kind == Kind.TEXT) { val link = json.optJSONObject("lnk")?.let { LinkPreview.fromJson(it) } - return Content(caption, kind = kind, raw = json, groupId = groupId, link = link, - messageId = messageId, about = about, + return Content( + caption, + kind = kind, + raw = json, + groupId = groupId, + link = link, + messageId = messageId, + about = about, mentions = mentions, - repliesToStory = aboutAStory && about != null) + repliesToStory = aboutAStory && about != null, + ) } - val ref = json.optJSONObject("att") - ?.let { - Attachment.Ref.fromJson( - kind, it, json.optDouble("exp", 0.0), - filename = json.optString("fn"), - classification = json.optInt("cls", 0), - compartments = json.optJSONArray("cmp")?.let { array -> - (0 until array.length()).map { i -> array.optInt(i) } - } ?: emptyList(), + val ref = + json + .optJSONObject("att") + ?.let { + Attachment.Ref.fromJson( + kind, + it, + json.optDouble("exp", 0.0), + filename = json.optString("fn"), + classification = json.optInt("cls", 0), + compartments = + json.optJSONArray("cmp")?.let { array -> + (0 until array.length()).map { i -> array.optInt(i) } + } ?: emptyList(), + ) + } + ?: return Content( + // The sender meant to say something; a message that + // silently vanishes is the worst way to meet a version this + // client does not know. + caption.ifEmpty { "[unsupported attachment]" }, + kind = kind, + raw = json, + groupId = groupId, + messageId = messageId, + about = about, ) - } - ?: return Content( - // The sender meant to say something; a message that - // silently vanishes is the worst way to meet a version this - // client does not know. - caption.ifEmpty { "[unsupported attachment]" }, - kind = kind, raw = json, - groupId = groupId, messageId = messageId, about = about, - ) - return Content(caption, kind = kind, raw = json, attachment = ref, - groupId = groupId, messageId = messageId, about = about, + return Content( + caption, + kind = kind, + raw = json, + attachment = ref, + groupId = groupId, + messageId = messageId, + about = about, storySet = storySet, - streamUrl = json.optString("stream").takeIf { it.isNotEmpty() }) + streamUrl = json.optString("stream").takeIf { it.isNotEmpty() }, + ) } } } /** * Encrypted blobs (spec §6.6). - * - * A fresh key per attachment, and the key travels only inside the ratchet. - * The relay stores and serves a blob it cannot read; its id is a download - * capability and nothing more. */ object Attachment { - const val IMAGE = "image" const val AUDIO = "audio" @@ -932,11 +817,6 @@ object Attachment { /** * A document from Files, carrying the label it was filed under. - * - * The label is a MARKING, not a permission. The content key is in the - * same envelope, so whoever can read the message can read the file, the - * bubble shows the classification so it can be filed the same way here, - * and must never imply this device was cleared for it. */ const val FILE = "file" @@ -964,32 +844,31 @@ object Attachment { val classification: Int = 0, val compartments: List = emptyList(), ) { - fun toJson(): JSONObject = JSONObject() - .put("id", id) - .put("key", Bytes.b64(key)) - .put("digest", Bytes.b64(digest)) - .put("bytes", bytes) - .put("mime", mime) - .put("w", width) - .put("h", height) - // Audio always; a story carries one when it is a video. - .also { if (duration > 0.0) it.put("dur", duration) } - // Without this the vault round-trip erased every expiry: - // saveStories persists refs through here, loadStories reads "exp" - // back out of this very JSON, and a field never written reloads as - // 0, which renders as "no expiry" AND, because isActive treats 0 - // as immortal, means an expired story's bytes were never dropped - // on write. The deletion promise depended on this one field. - // (On the wire "exp" travels at the envelope's top level; a copy - // inside "att" is ignored by both decoders.) - .also { if (expires > 0) it.put("exp", expires) } + fun toJson(): JSONObject = + JSONObject() + .put("id", id) + .put("key", Bytes.b64(key)) + .put("digest", Bytes.b64(digest)) + .put("bytes", bytes) + .put("mime", mime) + .put("w", width) + .put("h", height) + // Audio always; a story carries one when it is a video. + .also { if (duration > 0.0) it.put("dur", duration) } + // Without this the vault round-trip erased every expiry. + // saveStories persists refs through here, loadStories reads "exp" + // back out of this very JSON, and a field never written reloads as + // 0, which renders as "no expiry" AND, because isActive treats 0 as + // immortal, means an expired story's bytes were never dropped on + // write. + .also { if (expires > 0) it.put("exp", expires) } companion object { /** * `filename`, `classification` and `compartments` come from the - * ENVELOPE, not from `att`, they are additive top-level keys, so - * an older parser ignores them and a newer one has to be handed - * them explicitly rather than reading them off the attachment. + * ENVELOPE, not from `att`, they are additive top-level keys, so an + * older parser ignores them and a newer one has to be handed them + * explicitly rather than reading them off the attachment. */ fun fromJson( kind: String, @@ -1002,8 +881,7 @@ object Attachment { val key = runCatching { Bytes.unb64(json.optString("key")) }.getOrNull() val digest = runCatching { Bytes.unb64(json.optString("digest")) }.getOrNull() // Wrong-sized key material is a malformed reference, not a - // decryption to attempt. Returning null renders the caption - // with a label; trying anyway throws somewhere less useful. + // decryption to attempt. if (key == null || key.size != 32 || digest == null || digest.size != 32) return null return Ref( kind = kind, @@ -1025,7 +903,11 @@ object Attachment { } /** The blob to upload, and the key and digest that go in the message. */ - data class Sealed(val blob: ByteArray, val key: ByteArray, val digest: ByteArray) + data class Sealed( + val blob: ByteArray, + val key: ByteArray, + val digest: ByteArray, + ) fun seal(data: ByteArray): Sealed { val key = ByteArray(32).also(random::nextBytes) @@ -1036,14 +918,11 @@ object Attachment { /** * Open a downloaded blob. - * - * The digest is checked before decrypting. The relay handed us these - * bytes and only the ratchet-delivered reference says what they should be. - * AES-GCM would catch a substitution too, but failing on the digest names - * the problem, "the server sent different bytes", instead of reporting it - * as a decryption failure indistinguishable from a broken session. */ - fun open(blob: ByteArray, ref: Ref): ByteArray { + fun open( + blob: ByteArray, + ref: Ref, + ): ByteArray { if (!Bytes.constantTimeEquals(Bytes.sha256(blob), ref.digest)) { throw SecurityException("attachment digest mismatch: the relay served different bytes") } diff --git a/engine/src/main/kotlin/io/flamenet/e2e/DeviceIdentity.kt b/engine/src/main/kotlin/io/flamenet/e2e/DeviceIdentity.kt index 1c72dcb..c9d511e 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/DeviceIdentity.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/DeviceIdentity.kt @@ -5,36 +5,6 @@ import java.security.KeyPair /** * One device's long-term keys and its published prekeys (spec §3). - * - * A device has two identity keys, and conflating them is the classic mistake: - * [identityDH] does key agreement and [identitySigning] does signatures. - * X25519 and Ed25519 are related curves, but a single key used for both invites - * cross-protocol attacks and neither JCA nor CryptoKit will hand you the same - * object for the two jobs anyway. - * - * Retired signed prekeys must be kept, not discarded. A sender fetches a - * bundle, encrypts against that `spk_id`, and the envelope can then sit - * undelivered for the relay's full 30-day retention window. Deleting the - * private half at rotation turns every one of those queued messages into an - * AEAD failure, which is indistinguishable, to the receiving client, from an - * attack. That is why [retiredSignedPreKeys] exists and why the retention - * window here is longer than the relay's. - * - * A port of `engine/src/e2e.js`, which is normative. - * - * ## Post-quantum material (§12.3) - * - * A v2 device publishes two more things: a last-resort ML-KEM prekey that - * is never consumed, and a pool of one-time ones. Both are signed by the - * Ed25519 identity, and a peer verifies that signature before encapsulating, - * without it a relay could hand out a KEM key of its own and read the - * post-quantum half of every new session, which is exactly the substitution - * `spk_sig` exists to prevent classically. - * - * The two pools are kept the same size. A smaller PQ pool drains first - * and silently downgrades new sessions to the last-resort key, with no signal - * to either party, a real weakening (every session sharing one KEM key) that - * looks like nothing at all. */ class DeviceIdentity private constructor( val deviceId: String, @@ -44,9 +14,11 @@ class DeviceIdentity private constructor( var signedPreKeyId: Int, var signedPreKeyCreatedAt: Long, ) { - /** A retired signed prekey, and when it stopped being published. */ - private class Retired(val keyPair: KeyPair, val retiredAt: Long) + private class Retired( + val keyPair: KeyPair, + val retiredAt: Long, + ) private val retiredSignedPreKeys = LinkedHashMap() private val oneTimePreKeys = LinkedHashMap() @@ -54,11 +26,6 @@ class DeviceIdentity private constructor( /** * The last-resort post-quantum prekey, and the one-time pool. - * - * Held as seeds. Both the private key and its published half re-derive from - * 64 bytes, so the vault stores 64 rather than 2400 per key, and it is the - * same representation iOS persists, which is what lets a history export - * move between the two. */ private var pqSignedPreKeySeed: ByteArray? = null private var pqSignedPreKeyId = 1 @@ -66,71 +33,59 @@ class DeviceIdentity private constructor( private var nextPQPreKeyId = 1 /** One published prekey: the id a sender quotes back, and the public half. */ - data class PreKey(val id: Int, val publicKey: ByteArray) + data class PreKey( + val id: Int, + val publicKey: ByteArray, + ) companion object { /** The spec's "every few weeks", made a number so something can enforce it. */ const val ROTATE_AFTER_MS = 30L * 24 * 60 * 60 * 1000 /** - * How long a retired prekey stays usable. Must exceed the relay's - * undelivered-envelope retention (30 days by default), with margin - * for a sender that fetched a bundle and then went offline. Too short - * does not fail loudly, it drops old messages. + * How long a retired prekey stays usable. */ const val RETAIN_AFTER_MS = 45L * 24 * 60 * 60 * 1000 /** - * The highest handshake version this engine implements. - * - * 3. ML-KEM in every ratchet step (§13), not only the handshake. This - * read 2 until the ratchet existed, with the note that claiming 3 early - * would have peers send `pqdh`/`pqct` on every message and this engine - * reject the lot. That was right, and it is why the number moves in the - * same change as the evidence rather than ahead of it. - * - * A version is a claim other devices act on. Peers pin the - * highest proven version and the pin is monotonic (§13.4), so a build - * that advertises 3 and cannot honour it does not degrade, it strands - * every contact that believed it. What backs this number: the Kotlin - * ratchet drives a v3 session against the web engine in both directions - * (`flamenet-e2e/test/interop-kotlin.mjs`, §13), including the reply - * that only opens if the decapsulate-before-adopt ordering is right. - * - * Claiming 3 does not mean sending 3 to everyone. `E2ESession` - * gates the PQ ratchet on the PEER having proven 3 as well, because - * every v2 device offers a PQ prekey and none of them can read a - * ratchet step. + * The highest handshake version this engine implements. 3. */ const val PROTOCOL_VERSION = 3 - fun create(now: Long = System.currentTimeMillis()): DeviceIdentity = DeviceIdentity( - deviceId = java.util.UUID.randomUUID().toString().lowercase(), - identityDH = X25519.generate(), - identitySigning = Ed25519.generate(), - signedPreKey = X25519.generate(), - signedPreKeyId = 1, - signedPreKeyCreatedAt = now, - ) + fun create(now: Long = System.currentTimeMillis()): DeviceIdentity = + DeviceIdentity( + deviceId = + java.util.UUID + .randomUUID() + .toString() + .lowercase(), + identityDH = X25519.generate(), + identitySigning = Ed25519.generate(), + signedPreKey = X25519.generate(), + signedPreKeyId = 1, + signedPreKeyCreatedAt = now, + ) fun fromArchive(archive: JSONObject): DeviceIdentity { - fun x(o: JSONObject) = KeyPair( - X25519.publicKeyFromRaw(Bytes.unb64(o.getString("pub"))), - X25519.privateKeyFromPkcs8(Bytes.unb64(o.getString("priv"))), - ) - val identity = DeviceIdentity( - deviceId = archive.getString("deviceId"), - identityDH = x(archive.getJSONObject("ikDH")), - identitySigning = Ed25519.fromSeed( - Bytes.unb64(archive.getJSONObject("ikSig").getString("seed"))), - signedPreKey = x(archive.getJSONObject("spk")), - signedPreKeyId = archive.getInt("spkId"), - // 0 means "written before this field existed", and the honest - // reading of an unknown age is "old", a device that rotates - // once on first launch after an upgrade is harmless, one that - // never rotates because it defaulted to `now` is not. - signedPreKeyCreatedAt = archive.optLong("spkAt", 0), - ) + fun x(o: JSONObject) = + KeyPair( + X25519.publicKeyFromRaw(Bytes.unb64(o.getString("pub"))), + X25519.privateKeyFromPkcs8(Bytes.unb64(o.getString("priv"))), + ) + val identity = + DeviceIdentity( + deviceId = archive.getString("deviceId"), + identityDH = x(archive.getJSONObject("ikDH")), + identitySigning = + Ed25519.fromSeed(Bytes.unb64(archive.getJSONObject("ikSig").getString("seed"))), + signedPreKey = x(archive.getJSONObject("spk")), + signedPreKeyId = archive.getInt("spkId"), + // 0 means "written before this field existed", and the honest + // reading of an unknown age is "old", a device that rotates + // once on first launch after an upgrade is harmless, one that + // never rotates because it defaulted to `now` is not. + signedPreKeyCreatedAt = archive.optLong("spkAt", 0), + ) identity.nextPreKeyId = archive.optInt("nextId", 1) val otp = archive.optJSONObject("otp") ?: JSONObject() @@ -143,10 +98,8 @@ class DeviceIdentity private constructor( Retired(x(entry), entry.optLong("retiredAt", 0)) } - // Absent for a device archived before this engine had ML-KEM, - // which is every existing install. It reads back as a v1 device and - // publishes post-quantum material the next time it registers, - // rather than failing to load. + // Absent for a device archived before this engine had ML-KEM, which + // is every existing install. archive.optJSONObject("pqSpk")?.let { identity.pqSignedPreKeySeed = Bytes.unb64(it.getString("seed")) identity.pqSignedPreKeyId = it.optInt("id", 1) @@ -165,27 +118,27 @@ class DeviceIdentity private constructor( /** * The signature a peer checks before trusting the signed prekey. - * - * This is the whole reason a relay cannot quietly substitute its own prekey - * and read everything, provided the peer actually verifies it, which is - * why [E2ESession.initiate] does, unconditionally. */ - fun signedPreKeySignature(): ByteArray = - Ed25519.sign(identitySigning, X25519.rawFromPublicKey(signedPreKey.public)) + fun signedPreKeySignature(): ByteArray = Ed25519.sign(identitySigning, X25519.rawFromPublicKey(signedPreKey.public)) - fun generateOneTimePreKeys(count: Int): List = (0 until count).map { - val id = nextPreKeyId++ - val pair = X25519.generate() - oneTimePreKeys[id] = pair - PreKey(id, X25519.rawFromPublicKey(pair.public)) - } + fun generateOneTimePreKeys(count: Int): List = + (0 until count).map { + val id = nextPreKeyId++ + val pair = X25519.generate() + oneTimePreKeys[id] = pair + PreKey(id, X25519.rawFromPublicKey(pair.public)) + } - // -- Post-quantum material (§12.3) --------------------------------------- + // -- Post-quantum material (§12.3) ---------------------------------------. /** One published ML-KEM prekey, with the signature a peer must check. */ - data class PQPreKey(val id: Int, val publicKey: ByteArray, val signature: ByteArray) { - override fun equals(other: Any?): Boolean = - other is PQPreKey && id == other.id && publicKey.contentEquals(other.publicKey) + data class PQPreKey( + val id: Int, + val publicKey: ByteArray, + val signature: ByteArray, + ) { + override fun equals(other: Any?): Boolean = other is PQPreKey && id == other.id && publicKey.contentEquals(other.publicKey) + override fun hashCode(): Int = publicKey.contentHashCode() } @@ -195,52 +148,49 @@ class DeviceIdentity private constructor( val CAPS = "FlamenetE2E_Caps_v2".toByteArray(Charsets.UTF_8) /** - * The id is signed big-endian (§12.3), which is not the - * little-endian encoding used on-device. Mixing the two produces - * signatures every peer rejects, and only peers, so it looks like a - * network problem. + * The id is signed big-endian (§12.3), which is not the little-endian + * encoding used on-device. */ - fun be32(v: Int) = byteArrayOf( - (v ushr 24).toByte(), (v ushr 16).toByte(), (v ushr 8).toByte(), v.toByte()) + fun be32(v: Int) = byteArrayOf((v ushr 24).toByte(), (v ushr 16).toByte(), (v ushr 8).toByte(), v.toByte()) } /** * The last-resort post-quantum prekey, created on first use. - * - * Never consumed, so a session can always be established with - * post-quantum protection even when the one-time pool has drained. A device - * advertising `proto: 2` without one would be chosen for v2 by peers and - * then fail every handshake (§12.8). */ fun pqSignedPreKey(): PQPreKey { val seed = pqSignedPreKeySeed ?: PQKEM.generate().seed.also { pqSignedPreKeySeed = it } val pub = PQKEM.fromSeed(seed).publicKey - return PQPreKey(pqSignedPreKeyId, pub, - Ed25519.sign(identitySigning, PQDomain.SPK + pub)) + return PQPreKey( + pqSignedPreKeyId, + pub, + Ed25519.sign(identitySigning, PQDomain.SPK + pub), + ) } - fun generatePQOneTimePreKeys(count: Int): List = (0 until count).map { - val id = nextPQPreKeyId++ - val pair = PQKEM.generate() - pqOneTimePreKeys[id] = pair.seed - PQPreKey(id, pair.publicKey, - Ed25519.sign(identitySigning, PQDomain.OPK + PQDomain.be32(id) + pair.publicKey)) - } + fun generatePQOneTimePreKeys(count: Int): List = + (0 until count).map { + val id = nextPQPreKeyId++ + val pair = PQKEM.generate() + pqOneTimePreKeys[id] = pair.seed + PQPreKey( + id, + pair.publicKey, + Ed25519.sign(identitySigning, PQDomain.OPK + PQDomain.be32(id) + pair.publicKey), + ) + } /** * The seed for whichever prekey a peer encapsulated to. - * - * A one-time key is removed, which is the point of it; the - * last-resort one is not. Returns null when the id names neither, which a - * responder must treat as "this handshake cannot be v2" rather than as an - * error, an envelope naming a prekey we already spent is what a duplicate - * delivery looks like. */ - fun takePQPreKey(kind: String, id: Int): ByteArray? = when (kind) { - "spk" -> pqSignedPreKeySeed?.takeIf { id == pqSignedPreKeyId } - "opk" -> pqOneTimePreKeys.remove(id) - else -> null - } + fun takePQPreKey( + kind: String, + id: Int, + ): ByteArray? = + when (kind) { + "spk" -> pqSignedPreKeySeed?.takeIf { id == pqSignedPreKeyId } + "opk" -> pqOneTimePreKeys.remove(id) + else -> null + } val pqOneTimePreKeyCount: Int get() = pqOneTimePreKeys.size @@ -250,43 +200,30 @@ class DeviceIdentity private constructor( /** * Proves this device really claims [proto], so a relay cannot advertise a * v2 device as v1-only and quietly strip the post-quantum half off every - * new session. It can still omit the claim entirely, per-device pinning is - * what closes that (§12.4, §12.7). + * new session. */ fun capabilitySignature(proto: Int = PROTOCOL_VERSION): ByteArray = Ed25519.sign(identitySigning, PQDomain.CAPS + byteArrayOf(proto.toByte())) /** Consume a one-time prekey. Consumed exactly once, that is the point of it. */ - fun takeOneTimePreKey(id: Int): KeyPair? = - if (id < 0) null else oneTimePreKeys.remove(id) + fun takeOneTimePreKey(id: Int): KeyPair? = if (id < 0) null else oneTimePreKeys.remove(id) val oneTimePreKeyCount: Int get() = oneTimePreKeys.size /** * The signed prekey a prekey envelope refers to: the current one, or a * retired one still inside the retention window. - * - * An id of 0 means the sender did not state one, true of every envelope - * built before rotation existed, and those can only have used the current - * key, so falling back is correct rather than lax. An id that IS stated and - * unknown returns null, because guessing would derive a garbage shared - * secret and report it as tampering. */ - fun signedPreKeyFor(id: Int): KeyPair? = when { - id == 0 || id == signedPreKeyId -> signedPreKey - else -> retiredSignedPreKeys[id]?.keyPair - } + fun signedPreKeyFor(id: Int): KeyPair? = + when { + id == 0 || id == signedPreKeyId -> signedPreKey + else -> retiredSignedPreKeys[id]?.keyPair + } - fun shouldRotateSignedPreKey(now: Long = System.currentTimeMillis()): Boolean = - now - signedPreKeyCreatedAt >= ROTATE_AFTER_MS + fun shouldRotateSignedPreKey(now: Long = System.currentTimeMillis()): Boolean = now - signedPreKeyCreatedAt >= ROTATE_AFTER_MS /** * Retire the current signed prekey and mint a new one. - * - * The caller must republish the bundle and persist afterwards. Rotating - * without republishing leaves the relay handing out a bundle whose private - * half has moved; rotating without persisting loses the retired key on the - * next launch, which is the message-loss case above. */ fun rotateSignedPreKey(now: Long = System.currentTimeMillis()): KeyPair { retiredSignedPreKeys[signedPreKeyId] = Retired(signedPreKey, now) @@ -304,42 +241,33 @@ class DeviceIdentity private constructor( /** * The body of `POST /e2e/devices`. - * - * The v2 block is all-or-nothing. The relay refuses a partial one, - * and rightly: a device advertising `proto: 2` without a usable last-resort - * prekey would be chosen for v2 by every peer and then fail every handshake - * (§12.8). So either every post-quantum field is present or none is, and - * [pqOneTime] being empty is not a reason to drop the last-resort key. */ fun registrationWire( oneTime: List = emptyList(), pqOneTime: List = emptyList(), /** * Whether to publish post-quantum material at all. - * - * A caller can turn it off, an old relay that rejects the block, a - * test that wants a v1 device, and then this registers exactly as it - * always did. */ postQuantum: Boolean = true, ): JSONObject { - val wire = JSONObject() - .put("device_id", deviceId) - .put("ik_dh", Bytes.b64(identityDHPublic)) - .put("ik_sig", Bytes.b64(identitySigningPublic)) - .put("spk_id", signedPreKeyId) - .put("spk", Bytes.b64(X25519.rawFromPublicKey(signedPreKey.public))) - .put("spk_sig", Bytes.b64(signedPreKeySignature())) - .put("proto", if (postQuantum) PROTOCOL_VERSION else 1) - // `prekeys`, NOT `opks`. Guessed wrong once; the relay accepted - // the registration, returned 200, and silently stored no prekeys, - // so the device was published and unreachable, which is the worst - // of both. A live test against a real relay is what caught it. - .put("prekeys", org.json.JSONArray().also { array -> - oneTime.forEach { - array.put(JSONObject().put("id", it.id).put("pub", Bytes.b64(it.publicKey))) - } - }) + val wire = + JSONObject() + .put("device_id", deviceId) + .put("ik_dh", Bytes.b64(identityDHPublic)) + .put("ik_sig", Bytes.b64(identitySigningPublic)) + .put("spk_id", signedPreKeyId) + .put("spk", Bytes.b64(X25519.rawFromPublicKey(signedPreKey.public))) + .put("spk_sig", Bytes.b64(signedPreKeySignature())) + .put("proto", if (postQuantum) PROTOCOL_VERSION else 1) + // `prekeys`, NOT `opks`. + .put( + "prekeys", + org.json.JSONArray().also { array -> + oneTime.forEach { + array.put(JSONObject().put("id", it.id).put("pub", Bytes.b64(it.publicKey))) + } + }, + ) if (!postQuantum) return wire val spk = pqSignedPreKey() @@ -348,29 +276,29 @@ class DeviceIdentity private constructor( .put("pqspk_id", spk.id) .put("pqspk", Bytes.b64(spk.publicKey)) .put("pqspk_sig", Bytes.b64(spk.signature)) - .put("pq_prekeys", org.json.JSONArray().also { array -> - pqOneTime.forEach { - array.put(JSONObject() - .put("id", it.id) - .put("pub", Bytes.b64(it.publicKey)) - .put("sig", Bytes.b64(it.signature))) - } - }) + .put( + "pq_prekeys", + org.json.JSONArray().also { array -> + pqOneTime.forEach { + array.put( + JSONObject() + .put("id", it.id) + .put("pub", Bytes.b64(it.publicKey)) + .put("sig", Bytes.b64(it.signature)), + ) + } + }, + ) } /** * Everything that must survive a restart. - * - * This is private key material. It is the vault's job to encrypt it; - * this returns it in the clear because a store that cannot see the plaintext - * cannot store it. Never log this, never write it to shared preferences. - * - * Retired prekeys travel with it, for the reason at the top of this file. */ fun archive(): JSONObject { - fun x(pair: KeyPair) = JSONObject() - .put("priv", Bytes.b64(pair.private.encoded)) - .put("pub", Bytes.b64(X25519.rawFromPublicKey(pair.public))) + fun x(pair: KeyPair) = + JSONObject() + .put("priv", Bytes.b64(pair.private.encoded)) + .put("pub", Bytes.b64(X25519.rawFromPublicKey(pair.public))) val otp = JSONObject() oneTimePreKeys.forEach { (id, pair) -> otp.put(id.toString(), x(pair)) } @@ -383,12 +311,14 @@ class DeviceIdentity private constructor( .put("deviceId", deviceId) .put("ikDH", x(identityDH)) // Ed25519 is stored as its 32-byte seed rather than a PKCS#8 blob, - // because that is the one form both the JCA and a passphrase-derived - // account key agree on. - .put("ikSig", JSONObject() - .put("seed", Bytes.b64(identitySigning.seed)) - .put("pub", Bytes.b64(identitySigningPublic))) - .put("spk", x(signedPreKey)) + // because that is the one form both the JCA and a passphrase- + // derived account key agree on. + .put( + "ikSig", + JSONObject() + .put("seed", Bytes.b64(identitySigning.seed)) + .put("pub", Bytes.b64(identitySigningPublic)), + ).put("spk", x(signedPreKey)) .put("spkId", signedPreKeyId) .put("spkAt", signedPreKeyCreatedAt) .put("nextId", nextPreKeyId) @@ -399,9 +329,12 @@ class DeviceIdentity private constructor( // which is what lets a history export cross platforms. .also { out -> pqSignedPreKeySeed?.let { - out.put("pqSpk", JSONObject() - .put("seed", Bytes.b64(it)) - .put("id", pqSignedPreKeyId)) + out.put( + "pqSpk", + JSONObject() + .put("seed", Bytes.b64(it)) + .put("id", pqSignedPreKeyId), + ) } if (pqOneTimePreKeys.isNotEmpty()) { val pq = JSONObject() diff --git a/engine/src/main/kotlin/io/flamenet/e2e/DoubleRatchet.kt b/engine/src/main/kotlin/io/flamenet/e2e/DoubleRatchet.kt index b9d0e5a..b8a807e 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/DoubleRatchet.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/DoubleRatchet.kt @@ -48,66 +48,65 @@ class DoubleRatchet private constructor( /** Length of the PREVIOUS sending chain, so the far side knows how many to skip. */ private var previousChainLength: Int, /** - * Our current ML-KEM keypair (spec §13.2 `PQs`). Null for a v1/v2 session, - * which is what a peer that does not advertise v3 gets. + * Our current ML-KEM keypair (spec §13.2 `PQs`). */ private var pqSelf: PQKEM.KeyPair? = null, /** The peer's current ML-KEM public key (`PQr`). */ private var pqRemote: ByteArray? = null, /** * `{pub, ct}` to attach to outgoing messages (`PQpending`). - * - * Repeated on every message of the sending chain, not just the first - * (§13.3). Sending it once would mean losing that single message strands - * the whole chain: the receiver never gets the material to advance its root - * key, and nothing later in the chain can be decrypted. It is replaced by - * the next ratchet step, and is NOT cleared when the peer answers, it - * belongs to the sending chain. */ private var pqPending: Pair? = null, ) { - /** Keys for messages that arrived out of order, or have not arrived yet. */ private val skipped = LinkedHashMap() - data class SkippedKey(val ratchetPublic: String, val index: Int) + data class SkippedKey( + val ratchetPublic: String, + val index: Int, + ) data class Header( - val dh: ByteArray, val pn: Int, val n: Int, - val pqdh: ByteArray? = null, val pqct: ByteArray? = null, + val dh: ByteArray, + val pn: Int, + val n: Int, + val pqdh: ByteArray? = null, + val pqct: ByteArray? = null, ) { /** * The exact bytes that go into the AAD. - * - * Hand-built rather than serialised by a JSON library: the AAD has to be - * reproduced byte-for-byte by three implementations, and an encoder is - * free to reorder keys or add spaces between releases. Key order is - * `dh, pn, n` and there are no spaces, both are part of the protocol. */ fun canonicalJson(): ByteArray { - val sb = StringBuilder("{\"dh\":\"").append(Bytes.b64(dh)) - .append("\",\"pn\":").append(pn).append(",\"n\":").append(n) + val sb = + StringBuilder("{\"dh\":\"") + .append(Bytes.b64(dh)) + .append("\",\"pn\":") + .append(pn) + .append(",\"n\":") + .append(n) // Omitted ENTIRELY when absent, never emitted as null (§13.3). - // The AAD is reproduced byte-for-byte by three implementations; a - // header that spelled absence differently would fail every open. if (pqdh != null && pqct != null) { - sb.append(",\"pqdh\":\"").append(Bytes.b64(pqdh)) - .append("\",\"pqct\":\"").append(Bytes.b64(pqct)).append("\"") + sb + .append(",\"pqdh\":\"") + .append(Bytes.b64(pqdh)) + .append("\",\"pqct\":\"") + .append(Bytes.b64(pqct)) + .append("\"") } return sb.append("}").toString().toByteArray(Charsets.UTF_8) } } - data class Message(val header: Header, val ciphertext: ByteArray) + data class Message( + val header: Header, + val ciphertext: ByteArray, + ) companion object { private val RATCHET_INFO = "FlamenetE2E_Ratchet_v1".toByteArray(Charsets.UTF_8) /** - * The info string MUST change for v3 (spec §13.2). A v3 IKM is - * `dh_out || ss`, 64 bytes where v1 is 32, so the two cannot collide - * by length, but the version string is what makes the separation - * explicit rather than incidental. + * The info string MUST change for v3 (spec §13.2). */ private val RATCHET_INFO_V3 = "FlamenetE2E_Ratchet_v3".toByteArray(Charsets.UTF_8) private val MSGKEY_INFO = "FlamenetE2E_MsgKey_v1".toByteArray(Charsets.UTF_8) @@ -115,21 +114,11 @@ class DoubleRatchet private constructor( /** * A cap on how many keys a single header may make us derive. - * - * Without one, a header claiming `n = 2_000_000_000` is a denial of - * service that costs the sender nothing: the receiver dutifully derives - * two billion keys. The number is generous for real reordering and - * ruinous for that. */ const val MAX_SKIP = 1000 /** * How many skipped keys are kept across the whole session. - * - * `MAX_SKIP` bounds one header; without a second bound, a peer sending - * a thousand-message gap over and over grows this map forever. Oldest - * out first, a key that has waited through two thousand later messages - * is not arriving. */ const val MAX_STORE = 2000 @@ -142,7 +131,7 @@ class DoubleRatchet private constructor( remoteSignedPreKey: ByteArray, /** * The PQ prekey X3DH encapsulated to (§12.5), the SAME key, which - * is what makes it a valid `PQr` to start from. Null runs v2. + * is what makes it a valid `PQr` to start from. */ remotePQPreKey: ByteArray? = null, ): DoubleRatchet { @@ -156,36 +145,44 @@ class DoubleRatchet private constructor( ss = enc.sharedSecret pqPending = pqSelf.publicKey to enc.ciphertext } - val (rk, ck) = rootStep( - sharedSecret, X25519.agree(self.private, remoteSignedPreKey), ss) + val (rk, ck) = + rootStep(sharedSecret, X25519.agree(self.private, remoteSignedPreKey), ss) return DoubleRatchet( - rootKey = rk, sendingChain = ck, receivingChain = null, - selfRatchet = self, remoteRatchet = remoteSignedPreKey, - sent = 0, received = 0, previousChainLength = 0, - pqSelf = pqSelf, pqRemote = remotePQPreKey, pqPending = pqPending, + rootKey = rk, + sendingChain = ck, + receivingChain = null, + selfRatchet = self, + remoteRatchet = remoteSignedPreKey, + sent = 0, + received = 0, + previousChainLength = 0, + pqSelf = pqSelf, + pqRemote = remotePQPreKey, + pqPending = pqPending, ) } /** * Bob, whose signed prekey IS his first ratchet key. - * - * He does not step the root yet: he has nothing to step it with until a - * message from Alice tells him her ratchet public key. */ fun responder( sharedSecret: ByteArray, signedPreKey: java.security.KeyPair, /** * The seed of the PQ prekey the initiator encapsulated to, so the - * first inbound ratchet step can decapsulate. `PQr` is adopted from - * that first header (§13.2). + * first inbound ratchet step can decapsulate. */ pqPreKeySeed: ByteArray? = null, ): DoubleRatchet = DoubleRatchet( - rootKey = sharedSecret, sendingChain = null, receivingChain = null, - selfRatchet = signedPreKey, remoteRatchet = null, - sent = 0, received = 0, previousChainLength = 0, + rootKey = sharedSecret, + sendingChain = null, + receivingChain = null, + selfRatchet = signedPreKey, + remoteRatchet = null, + sent = 0, + received = 0, + previousChainLength = 0, pqSelf = pqPreKeySeed?.let(PQKEM::fromSeed), ) @@ -194,28 +191,33 @@ class DoubleRatchet private constructor( fun opt(key: String) = archive.optString(key, "").takeIf { it.isNotEmpty() }?.let(Bytes::unb64) val selfPrivate = opt("dhs") val selfPublic = opt("dhsPub") - val ratchet = DoubleRatchet( - rootKey = Bytes.unb64(archive.getString("rk")), - sendingChain = opt("cks"), - receivingChain = opt("ckr"), - selfRatchet = if (selfPrivate != null && selfPublic != null) { - java.security.KeyPair( - X25519.publicKeyFromRaw(selfPublic), - X25519.privateKeyFromPkcs8(selfPrivate), - ) - } else null, - remoteRatchet = opt("dhr"), - sent = archive.getInt("ns"), - received = archive.getInt("nr"), - previousChainLength = archive.getInt("pn"), - // Rebuilt from the seed, so an archive written by an older - // build simply has none and the session runs v2. - pqSelf = opt("pqs")?.let(PQKEM::fromSeed), - pqRemote = opt("pqr"), - pqPending = opt("pqPendingPub")?.let { pub -> - opt("pqPendingCt")?.let { ct -> pub to ct } - }, - ) + val ratchet = + DoubleRatchet( + rootKey = Bytes.unb64(archive.getString("rk")), + sendingChain = opt("cks"), + receivingChain = opt("ckr"), + selfRatchet = + if (selfPrivate != null && selfPublic != null) { + java.security.KeyPair( + X25519.publicKeyFromRaw(selfPublic), + X25519.privateKeyFromPkcs8(selfPrivate), + ) + } else { + null + }, + remoteRatchet = opt("dhr"), + sent = archive.getInt("ns"), + received = archive.getInt("nr"), + previousChainLength = archive.getInt("pn"), + // Rebuilt from the seed, so an archive written by an older + // build simply has none and the session runs v2. + pqSelf = opt("pqs")?.let(PQKEM::fromSeed), + pqRemote = opt("pqr"), + pqPending = + opt("pqPendingPub")?.let { pub -> + opt("pqPendingCt")?.let { ct -> pub to ct } + }, + ) val skipped = archive.optJSONArray("skipped") ?: org.json.JSONArray() for (i in 0 until skipped.length()) { val entry = skipped.getJSONObject(i) @@ -226,7 +228,9 @@ class DoubleRatchet private constructor( } private fun rootStep( - rootKey: ByteArray, dhOutput: ByteArray, ss: ByteArray? = null, + rootKey: ByteArray, + dhOutput: ByteArray, + ss: ByteArray? = null, ): Pair { val ikm = if (ss == null) dhOutput else dhOutput + ss val info = if (ss == null) RATCHET_INFO else RATCHET_INFO_V3 @@ -234,7 +238,10 @@ class DoubleRatchet private constructor( return out.copyOfRange(0, 32) to out.copyOfRange(32, 64) } - private fun hmac(key: ByteArray, msg: ByteArray): ByteArray { + private fun hmac( + key: ByteArray, + msg: ByteArray, + ): ByteArray { val mac = Mac.getInstance("HmacSHA256") mac.init(SecretKeySpec(key, "HmacSHA256")) return mac.doFinal(msg) @@ -251,20 +258,24 @@ class DoubleRatchet private constructor( } } - fun encrypt(plaintext: ByteArray, associatedData: ByteArray): Message { + fun encrypt( + plaintext: ByteArray, + associatedData: ByteArray, + ): Message { val chain = sendingChain ?: error("cannot send before the first ratchet step") val (messageKey, nextChain) = chainStep(chain) // Advanced BEFORE use, so a caller that retries cannot encrypt twice // under one key. sendingChain = nextChain - val header = Header( - dh = X25519.rawFromPublicKey(selfRatchet!!.public), - pn = previousChainLength, - n = sent, - pqdh = pqPending?.first, - pqct = pqPending?.second, - ) + val header = + Header( + dh = X25519.rawFromPublicKey(selfRatchet!!.public), + pn = previousChainLength, + n = sent, + pqdh = pqPending?.first, + pqct = pqPending?.second, + ) sent += 1 val (aesKey, nonce) = aeadParams(messageKey) @@ -277,19 +288,13 @@ class DoubleRatchet private constructor( return Message(header, sealed.copyOfRange(NONCE_LEN, sealed.size)) } - fun decrypt(message: Message, associatedData: ByteArray): ByteArray { + fun decrypt( + message: Message, + associatedData: ByteArray, + ): ByteArray { val aad = associatedData + message.header.canonicalJson() // A key held for a message that never arrived, or arrived late. - // - // Removed only AFTER the tag verifies (audit LOW). Consuming it - // first meant a forged ciphertext for a stashed (dh, n) burned the key - // the real delayed message needed, the message could then - // never be opened. Today the caller discards the whole session object - // on a failed decrypt, so the loss never reaches the vault; that is a - // property of the CALLER, and the moment anything caches a ratchet in - // memory it would become a real message-loss bug. iOS has always - // removed after the open for exactly this reason. val skippedKey = SkippedKey(Bytes.b64(message.header.dh), message.header.n) skipped[skippedKey]?.let { mk -> val (aesKey, nonce) = aeadParams(mk) @@ -346,22 +351,21 @@ class DoubleRatchet private constructor( * the sending chain they will decrypt with. */ private fun dhRatchet( - theirRatchet: ByteArray, pqdh: ByteArray? = null, pqct: ByteArray? = null, + theirRatchet: ByteArray, + pqdh: ByteArray? = null, + pqct: ByteArray? = null, ) { previousChainLength = sent sent = 0 received = 0 - // Decapsulate BEFORE adopting their new PQ key, and getting this - // order wrong is SILENT. The peer encapsulated to the key we held - // going into this step, so `pqSelf` here must still be the old one - // (§13.2). Reversing these derives a different root key with no error - // until an AEAD open fails somewhere later. + // Decapsulate BEFORE adopting their new PQ key, and getting this order + // wrong is SILENT. val ssRecv = if (pqct != null) pqSelf?.let { PQKEM.decapsulate(pqct, it.seed) } else null remoteRatchet = theirRatchet - val (rk, ck) = rootStep( - rootKey, X25519.agree(selfRatchet!!.private, theirRatchet), ssRecv) + val (rk, ck) = + rootStep(rootKey, X25519.agree(selfRatchet!!.private, theirRatchet), ssRecv) rootKey = rk receivingChain = ck @@ -377,32 +381,24 @@ class DoubleRatchet private constructor( } selfRatchet = X25519.generate() - val stepped = rootStep( - rootKey, X25519.agree(selfRatchet!!.private, theirRatchet), ssSend) + val stepped = + rootStep(rootKey, X25519.agree(selfRatchet!!.private, theirRatchet), ssSend) rootKey = stepped.first sendingChain = stepped.second } /** * Everything the conversation needs to continue after a restart. - * - * A ratchet that is not persisted is not a bug you notice, it is a bug - * your *contacts* notice. A client that loses session state falls back to - * minting a new device identity, and everyone who has ever talked to that - * account sees "security keys changed", which is the warning that is - * supposed to mean an attack. - * - * Skipped message keys travel too. They are the keys for messages that have - * not arrived yet, and dropping them at a restart loses exactly the - * messages that were already in flight. */ fun archive(): JSONObject { val skippedJson = org.json.JSONArray() skipped.forEach { (key, messageKey) -> - skippedJson.put(JSONObject() - .put("dh", key.ratchetPublic) - .put("n", key.index) - .put("mk", Bytes.b64(messageKey))) + skippedJson.put( + JSONObject() + .put("dh", key.ratchetPublic) + .put("n", key.index) + .put("mk", Bytes.b64(messageKey)), + ) } return JSONObject() .put("rk", Bytes.b64(rootKey)) @@ -414,9 +410,9 @@ class DoubleRatchet private constructor( .put("ns", sent) .put("nr", received) .put("pn", previousChainLength) - // A v3 session that lost these on a restart would silently drop - // to X25519-only healing: the ratchet keeps working, so nothing - // fails, and the post-quantum property is just gone. + // A v3 session that lost these on a restart would silently drop to + // X25519-only healing: the ratchet keeps working, so nothing fails, + // and the post-quantum property is just gone. .put("pqs", pqSelf?.let { Bytes.b64(it.seed) }) .put("pqsPub", pqSelf?.let { Bytes.b64(it.publicKey) }) .put("pqr", pqRemote?.let { Bytes.b64(it) }) diff --git a/engine/src/main/kotlin/io/flamenet/e2e/E2ESession.kt b/engine/src/main/kotlin/io/flamenet/e2e/E2ESession.kt index b7e522b..4247e21 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/E2ESession.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/E2ESession.kt @@ -4,21 +4,6 @@ import org.json.JSONObject /** * One conversation with one device (spec §8.1). - * - * This is the layer an app actually uses: [initiate] or [respond] once, then - * [encrypt] and [decrypt] strings. Everything below it, the handshake, the - * ratchet, the associated data, is set up here and then never thought about - * again. - * - * The first messages are `prekey` envelopes and must stay that way until - * the peer replies. Not until the first one is *sent*, until one is - * *answered*. Any single message may be the first to arrive, so all of them - * have to carry the handshake block; sending it once means losing that one - * message strands the whole conversation, and the symptom is a contact whose - * messages simply never appear. - * - * A port of `engine/src/e2e.js`, which is normative, and verified against it by - * `engine/test/interop-kotlin.mjs` rather than by reading. */ class E2ESession private constructor( private val ratchet: DoubleRatchet, @@ -31,40 +16,33 @@ class E2ESession private constructor( val version: Int, /** * The post-quantum block that rides the prekey envelope, on v2. - * - * Held beside [pendingHandshake] and dropped with it, because it belongs to - * the same handshake: every message before the peer answers must carry it, - * and no message after needs to. */ private var pq: PQEnvelope? = null, ) { - /** * Which of the peer's two post-quantum prekeys a ciphertext went to, and * the ciphertext. - * - * [id] and the classical `opk_id` are independent id spaces. They - * may coincide numerically and must never be assumed equal (§12.6). */ - data class PQEnvelope(val kind: String, val id: Int, val ciphertext: ByteArray) { + data class PQEnvelope( + val kind: String, + val id: Int, + val ciphertext: ByteArray, + ) { override fun equals(other: Any?): Boolean = other is PQEnvelope && kind == other.kind && id == other.id && ciphertext.contentEquals(other.ciphertext) + override fun hashCode(): Int = ciphertext.contentHashCode() } companion object { - /** * Start a conversation from a bundle fetched for a peer. - * - * The signed prekey's signature is verified here, unconditionally. - * The bundle arrives from a relay, and a relay that can substitute an - * unverified prekey can read the entire conversation. This is the single - * check that stops that, so it is not optional, not configurable, and - * not the caller's job to remember. */ - fun initiate(identity: DeviceIdentity, bundle: PeerBundle): E2ESession { + fun initiate( + identity: DeviceIdentity, + bundle: PeerBundle, + ): E2ESession { if (!Ed25519.verify(bundle.identitySigning, bundle.signedPreKeySignature, bundle.signedPreKey)) { throw SecurityException("the peer's signed prekey is not signed by its identity key") } @@ -73,33 +51,27 @@ class E2ESession private constructor( // the relay says about them. val chosen = bundle.provenPostQuantumPreKey() - val initiated = X3DH.initiate( - identityPrivateDH = identity.identityDH.private, - bundle = X3DH.Bundle( - identityKeyDH = bundle.identityDH, - signedPreKey = bundle.signedPreKey, - oneTimePreKey = bundle.oneTimePreKey, - ), - bobPQ = chosen?.publicKey, - ) + val initiated = + X3DH.initiate( + identityPrivateDH = identity.identityDH.private, + bundle = + X3DH.Bundle( + identityKeyDH = bundle.identityDH, + signedPreKey = bundle.signedPreKey, + oneTimePreKey = bundle.oneTimePreKey, + ), + bobPQ = chosen?.publicKey, + ) - val handshake = JSONObject() - .put("ik_dh", Bytes.b64(identity.identityDHPublic)) - .put("ik_sig", Bytes.b64(identity.identitySigningPublic)) - .put("ek", Bytes.b64(initiated.ephemeralPublic)) - .put("spk_id", bundle.signedPreKeyId) - .put("opk_id", bundle.oneTimePreKeyId) + val handshake = + JSONObject() + .put("ik_dh", Bytes.b64(identity.identityDHPublic)) + .put("ik_sig", Bytes.b64(identity.identitySigningPublic)) + .put("ek", Bytes.b64(initiated.ephemeralPublic)) + .put("spk_id", bundle.signedPreKeyId) + .put("opk_id", bundle.oneTimePreKeyId) - // Offering a PQ prekey is not a claim to §13. Every v2 device - // publishes one; only a v3 device can read a PQ ratchet step. Sending - // one to a v2 peer breaks the conversation twice over, their root - // step derives without `ss` under the `_v1` info string while ours - // used `_v3`, and their canonical header omits `pqdh`/`pqct` so the - // AAD does not match either. The first message never opens. - // - // `bundle.proto` alone is the relay's word for it. `chosen` is - // non-null only when the capability signature over THIS proto - // verified (§12.7), so the pair together is a proven claim to v3. + // Offering a PQ prekey is not a claim to §13. val postQuantumRatchet = if (chosen != null && bundle.proto >= 3) chosen.publicKey else null @@ -107,109 +79,100 @@ class E2ESession private constructor( // `chosen?.publicKey` is the PQ prekey X3DH just encapsulated // to, and the ratchet has to start from that exact key or the // responder cannot decapsulate the first step (§13.2). - ratchet = DoubleRatchet.initiator( - initiated.sharedSecret, bundle.signedPreKey, postQuantumRatchet), - associatedData = X3DH.associatedData( - identity.identitySigningPublic, bundle.identitySigning, - initiated.pqCiphertext), + ratchet = + DoubleRatchet.initiator(initiated.sharedSecret, bundle.signedPreKey, postQuantumRatchet), + associatedData = + X3DH.associatedData( + identity.identitySigningPublic, + bundle.identitySigning, + initiated.pqCiphertext, + ), pendingHandshake = handshake, version = if (postQuantumRatchet != null) 3 else initiated.version, - // Rides the first envelope only. Every later `msg` is - // byte-identical to v1, because the KEM secret is already - // folded into the root key and the associated data binds the - // ciphertext for the life of the session (§12.6). - pq = chosen?.let { - PQEnvelope(it.kind, it.id, initiated.pqCiphertext ?: return@let null) - }, + // Rides the first envelope only. + pq = + chosen?.let { + PQEnvelope(it.kind, it.id, initiated.pqCiphertext ?: return@let null) + }, ) } /** * Answer a `prekey` envelope. - * - * The ratchet seeds from the signed prekey X3DH actually used, - * which may be a retired one. Seeding from the current key while X3DH - * used a retired one desynchronises the very first DH step, and the - * failure appears on the peer's screen, not here. */ - fun respond(identity: DeviceIdentity, envelope: Envelope): E2ESession { - val handshake = envelope.handshake - ?: throw IllegalArgumentException("not a prekey envelope: no x3dh block") + fun respond( + identity: DeviceIdentity, + envelope: Envelope, + ): E2ESession { + val handshake = + envelope.handshake + ?: throw IllegalArgumentException("not a prekey envelope: no x3dh block") val signedPreKeyId = handshake.optInt("spk_id", 0) - val signedPreKey = identity.signedPreKeyFor(signedPreKeyId) - ?: throw IllegalArgumentException( - "unknown signed prekey $signedPreKeyId: retired past retention, or fabricated") + val signedPreKey = + identity.signedPreKeyFor(signedPreKeyId) + ?: throw IllegalArgumentException("unknown signed prekey $signedPreKeyId: retired past retention, or fabricated") val theirIdentityDH = Bytes.unb64(handshake.getString("ik_dh")) val theirIdentitySigning = Bytes.unb64(handshake.getString("ik_sig")) // §12.6, the post-quantum block, when the initiator sent one. - // - // A prekey we no longer hold is NOT an error and must not throw. - // A duplicate delivery of a v2 prekey envelope names a one-time key - // this device already spent, and refusing it outright would turn a - // retransmission into a dead conversation. The handshake then - // simply cannot be v2, and it will fail to decrypt, which is the - // correct outcome and the one the caller already handles. val pqBlock = envelope.pq - val pq = pqBlock?.let { block -> - identity.takePQPreKey(block.kind, block.id)?.let { seed -> seed to block.ciphertext } - } + val pq = + pqBlock?.let { block -> + identity.takePQPreKey(block.kind, block.id)?.let { seed -> seed to block.ciphertext } + } - val sharedSecret = X3DH.respond( - identityPrivateDH = identity.identityDH.private, - signedPreKeyPrivate = signedPreKey.private, - oneTimePrivate = identity.takeOneTimePreKey(handshake.optInt("opk_id", -1))?.private, - initiatorIdentityDH = theirIdentityDH, - initiatorEphemeral = Bytes.unb64(handshake.getString("ek")), - pq = pq, - ) + val sharedSecret = + X3DH.respond( + identityPrivateDH = identity.identityDH.private, + signedPreKeyPrivate = signedPreKey.private, + oneTimePrivate = identity.takeOneTimePreKey(handshake.optInt("opk_id", -1))?.private, + initiatorIdentityDH = theirIdentityDH, + initiatorEphemeral = Bytes.unb64(handshake.getString("ek")), + pq = pq, + ) - val session = E2ESession( - // The seed of the PQ prekey the initiator used, so the first - // inbound ratchet step can decapsulate to it. - ratchet = DoubleRatchet.responder(sharedSecret, signedPreKey, pq?.first), - associatedData = X3DH.associatedData( - theirIdentitySigning, identity.identitySigningPublic, pq?.second), - pendingHandshake = null, - // Honour what the initiator declared; this could never - // return 3. `initiate` above sets 3 whenever the PQ ratchet - // is on, but responding capped at 2, so a session this device - // ANSWERED was labelled v2 while the far side ran it as v3. - // Every message then arrived at a v3 peer without the - // post-quantum material a v3 step requires, and was refused as - // a downgrade (§13.4), correctly, and for ever. iOS-initiated - // conversations were one-way: iOS to Android worked, Android to - // iOS was silently dropped on arrival. - // - // Reads the initiator's version rather than assuming 3 - // whenever PQ is present: a v2 peer sends a PQ block too, and - // answering it in v3 breaks the pairing in the other direction. - version = when { - pq == null -> 1 - envelope.version >= 3 -> 3 - else -> 2 - }, - ) + val session = + E2ESession( + // The seed of the PQ prekey the initiator used, so the first + // inbound ratchet step can decapsulate to it. + ratchet = DoubleRatchet.responder(sharedSecret, signedPreKey, pq?.first), + associatedData = + X3DH.associatedData(theirIdentitySigning, identity.identitySigningPublic, pq?.second), + pendingHandshake = null, + // Honour what the initiator declared; this could never return + // 3. + version = + when { + pq == null -> 1 + envelope.version >= 3 -> 3 + else -> 2 + }, + ) return session } /** Restore a session written by [archive]. */ - fun fromArchive(archive: JSONObject): E2ESession = E2ESession( - ratchet = DoubleRatchet.fromArchive(archive.getJSONObject("ratchet")), - associatedData = Bytes.unb64(archive.getString("ad")), - pendingHandshake = archive.optJSONObject("pending"), - version = archive.optInt("version", 1), - // Archived for the same reason `pending` is: it is part of the - // handshake block, and a session restored without it would go on - // sending prekey envelopes with the post-quantum half missing, - // which the peer would answer by deriving a different key. - pq = archive.optJSONObject("pq")?.let { - PQEnvelope(it.getString("kind"), it.getInt("id"), - Bytes.unb64(it.getString("ct"))) - }, - ) + fun fromArchive(archive: JSONObject): E2ESession = + E2ESession( + ratchet = DoubleRatchet.fromArchive(archive.getJSONObject("ratchet")), + associatedData = Bytes.unb64(archive.getString("ad")), + pendingHandshake = archive.optJSONObject("pending"), + version = archive.optInt("version", 1), + // Archived for the same reason `pending` is: it is part of the + // handshake block, and a session restored without it would go on + // sending prekey envelopes with the post-quantum half missing, + // which the peer would answer by deriving a different key. + pq = + archive.optJSONObject("pq")?.let { + PQEnvelope( + it.getString("kind"), + it.getInt("id"), + Bytes.unb64(it.getString("ct")), + ) + }, + ) } fun encrypt(text: String): Envelope { @@ -220,46 +183,43 @@ class E2ESession private constructor( header = message.header, handshake = pendingHandshake, ciphertext = message.ciphertext, - // Only on a prekey envelope. Sending it after the handshake is - // answered would republish the ciphertext for no reason on every - // message of the conversation. + // Only on a prekey envelope. pq = if (pendingHandshake != null) pq else null, ) } fun decrypt(envelope: Envelope): String { - val plaintext = ratchet.decrypt( - DoubleRatchet.Message(envelope.header, envelope.ciphertext), associatedData) - // Only now, a reply proves the peer has the handshake, which is the - // one moment it is safe to stop repeating it. + val plaintext = + ratchet.decrypt(DoubleRatchet.Message(envelope.header, envelope.ciphertext), associatedData) + // Only now, a reply proves the peer has the handshake, which is the one + // moment it is safe to stop repeating it. pendingHandshake = null pq = null return String(plaintext, Charsets.UTF_8) } /** - * [pendingHandshake] has to be archived too. It is the only copy of the - * ephemeral public key the handshake block contains, and regenerating it - * after a restart would produce a different block, a different session, - * from the peer's point of view. + * [pendingHandshake] has to be archived too. */ - fun archive(): JSONObject = JSONObject() - .put("ratchet", ratchet.archive()) - .put("ad", Bytes.b64(associatedData)) - .put("pending", pendingHandshake) - .put("version", version) - .put("pq", pq?.let { - JSONObject().put("kind", it.kind).put("id", it.id) - .put("ct", Bytes.b64(it.ciphertext)) - }) + fun archive(): JSONObject = + JSONObject() + .put("ratchet", ratchet.archive()) + .put("ad", Bytes.b64(associatedData)) + .put("pending", pendingHandshake) + .put("version", version) + .put( + "pq", + pq?.let { + JSONObject() + .put("kind", it.kind) + .put("id", it.id) + .put("ct", Bytes.b64(it.ciphertext)) + }, + ) } /** * A peer's published keys, as fetched from `GET /e2e/keys`. - * - * `oneTimePreKey` being null is normal, not an error: the pool drains and - * the relay serves what is left. It costs initial forward secrecy for that one - * session and nothing else. */ data class PeerBundle( val deviceId: String, @@ -273,12 +233,6 @@ data class PeerBundle( val proto: Int, /** * The peer's signed claim to [proto]. - * - * `proto` itself is relayed by the server and so is untrusted, a relay - * can advertise a v2 device as v1-only and quietly strip the post-quantum - * half off every new session. This signature is what makes the claim - * checkable; absent, the peer is treated as v1, which is the safe reading - * of "we cannot tell". */ val capabilitySignature: ByteArray? = null, /** The last-resort ML-KEM prekey, never consumed. Null on a v1 device. */ @@ -293,11 +247,6 @@ data class PeerBundle( companion object { /** * Read the relay's wire shape. - * - * The one bug this protocol has actually shipped was a client and a - * relay disagreeing about field names, and nothing caught it until a - * live round trip. So the translation lives in exactly one place, and - * that place is tested. */ fun fromWire(wire: JSONObject): PeerBundle { val oneTime = wire.optJSONObject("opk") @@ -312,16 +261,22 @@ data class PeerBundle( oneTimePreKeyId = oneTime?.optInt("id", -1) ?: -1, oneTimePreKey = oneTime?.let { Bytes.unb64(it.getString("pub")) }, proto = wire.optInt("proto", 1), - capabilitySignature = wire.optString("caps_sig").ifEmpty { null } - ?.let(Bytes::unb64), + capabilitySignature = + wire + .optString("caps_sig") + .ifEmpty { null } + ?.let(Bytes::unb64), pqSignedPreKeyId = wire.optInt("pqspk_id", 0), pqSignedPreKey = wire.optString("pqspk").ifEmpty { null }?.let(Bytes::unb64), pqSignedPreKeySignature = wire.optString("pqspk_sig").ifEmpty { null }?.let(Bytes::unb64), pqOneTimePreKeyId = pqOneTime?.optInt("id", -1) ?: -1, pqOneTimePreKey = pqOneTime?.let { Bytes.unb64(it.getString("pub")) }, - pqOneTimePreKeySignature = pqOneTime?.optString("sig")?.ifEmpty { null } - ?.let(Bytes::unb64), + pqOneTimePreKeySignature = + pqOneTime + ?.optString("sig") + ?.ifEmpty { null } + ?.let(Bytes::unb64), ) } @@ -331,35 +286,26 @@ data class PeerBundle( } /** One post-quantum prekey this bundle offers, once its signature checks out. */ - data class ChosenPQ(val kind: String, val id: Int, val publicKey: ByteArray) + data class ChosenPQ( + val kind: String, + val id: Int, + val publicKey: ByteArray, + ) /** - * The post-quantum prekey to encapsulate to, or null for a v1 session. - * - * ## Every step here is a refusal waiting to happen, deliberately - * - * The signature is checked before anything else. These keys come - * from a relay, and a relay that can substitute an unverified ML-KEM prekey - * reads the post-quantum half of every new session, the exact substitution - * `spk_sig` exists to stop classically. An unsigned or wrongly-signed key is - * treated as absent, so the session falls back to v1 rather than to a KEM - * key of the relay's choosing. - * - * The claimed `proto` is checked against a signature too. A relay can - * otherwise advertise a v2 device as v1-only and quietly strip the - * post-quantum half off every new session, with nothing on either screen - * saying so. `caps_sig` makes the claim checkable; where it is missing, the - * peer is read as v1, which is the safe reading of "we cannot tell". - * - * The one-time key is preferred. Falling back to the last-resort key - * is correct when the pool has drained and is a real weakening, every - * session then shares one KEM key, so it is a fallback and not a choice. + * The post-quantum prekey to encapsulate to, or null for a v1 session. ## + * Every step here is a refusal waiting to happen, deliberately The + * signature is checked before anything else. */ fun provenPostQuantumPreKey(): ChosenPQ? { val caps = capabilitySignature ?: return null if (proto < 2) return null - if (!Ed25519.verify(identitySigning, caps, - CAPS_DOMAIN + byteArrayOf(proto.toByte()))) { + if (!Ed25519.verify( + identitySigning, + caps, + CAPS_DOMAIN + byteArrayOf(proto.toByte()), + ) + ) { return null } @@ -380,11 +326,10 @@ data class PeerBundle( return ChosenPQ("spk", pqSignedPreKeyId, lastResort) } - /** Big-endian, per §12.3. The on-device encoding is little-endian; the - * two are not interchangeable and mixing them yields signatures every peer - * rejects, and only peers, so it reads as a network fault. */ - private fun be32(v: Int) = byteArrayOf( - (v ushr 24).toByte(), (v ushr 16).toByte(), (v ushr 8).toByte(), v.toByte()) + /** + * Big-endian, per §12.3. + */ + private fun be32(v: Int) = byteArrayOf((v ushr 24).toByte(), (v ushr 16).toByte(), (v ushr 8).toByte(), v.toByte()) } /** One message on the wire (spec §8.2). */ @@ -396,30 +341,32 @@ data class Envelope( val ciphertext: ByteArray, /** * Present iff `version >= 2` and `type == "prekey"` (§12.6). - * - * Rides the first envelope only. Every later `msg` is byte-identical to v1, - * because the KEM secret is already folded into the root key and the - * associated data binds the ciphertext for the life of the session. */ val pq: E2ESession.PQEnvelope? = null, ) { - fun toJson(): JSONObject = JSONObject() - .put("v", version) - .put("type", type) - .put("header", JSONObject() - .put("dh", Bytes.b64(header.dh)) - .put("pn", header.pn) - .put("n", header.n) - // §13.3, present only on a PQ step. `JSONObject.put(String, null)` - // omits the key, which is exactly what the canonical form needs. - .put("pqdh", header.pqdh?.let(Bytes::b64)) - .put("pqct", header.pqct?.let(Bytes::b64))) - .put("x3dh", handshake) - .put("pq", pq?.let { - JSONObject().put("kind", it.kind).put("id", it.id) - .put("ct", Bytes.b64(it.ciphertext)) - }) - .put("ct", Bytes.b64(ciphertext)) + fun toJson(): JSONObject = + JSONObject() + .put("v", version) + .put("type", type) + .put( + "header", + JSONObject() + .put("dh", Bytes.b64(header.dh)) + .put("pn", header.pn) + .put("n", header.n) + // §13.3, present only on a PQ step. + .put("pqdh", header.pqdh?.let(Bytes::b64)) + .put("pqct", header.pqct?.let(Bytes::b64)), + ).put("x3dh", handshake) + .put( + "pq", + pq?.let { + JSONObject() + .put("kind", it.kind) + .put("id", it.id) + .put("ct", Bytes.b64(it.ciphertext)) + }, + ).put("ct", Bytes.b64(ciphertext)) /** * The relay carries an envelope as one opaque base64 string, so it never @@ -433,23 +380,27 @@ data class Envelope( return Envelope( version = json.optInt("v", 1), type = json.optString("type", "msg"), - header = DoubleRatchet.Header( - dh = Bytes.unb64(header.getString("dh")), - pn = header.getInt("pn"), - n = header.getInt("n"), - pqdh = header.optString("pqdh").ifEmpty { null }?.let(Bytes::unb64), - pqct = header.optString("pqct").ifEmpty { null }?.let(Bytes::unb64), - ), + header = + DoubleRatchet.Header( + dh = Bytes.unb64(header.getString("dh")), + pn = header.getInt("pn"), + n = header.getInt("n"), + pqdh = header.optString("pqdh").ifEmpty { null }?.let(Bytes::unb64), + pqct = header.optString("pqct").ifEmpty { null }?.let(Bytes::unb64), + ), handshake = json.optJSONObject("x3dh"), - pq = json.optJSONObject("pq")?.let { - E2ESession.PQEnvelope(it.getString("kind"), it.getInt("id"), - Bytes.unb64(it.getString("ct"))) - }, + pq = + json.optJSONObject("pq")?.let { + E2ESession.PQEnvelope( + it.getString("kind"), + it.getInt("id"), + Bytes.unb64(it.getString("ct")), + ) + }, ciphertext = Bytes.unb64(json.getString("ct")), ) } - fun fromPayload(payload: String): Envelope = - fromJson(JSONObject(String(Bytes.unb64(payload), Charsets.UTF_8))) + fun fromPayload(payload: String): Envelope = fromJson(JSONObject(String(Bytes.unb64(payload), Charsets.UTF_8))) } } diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Ed25519.kt b/engine/src/main/kotlin/io/flamenet/e2e/Ed25519.kt index 2ac071b..d81ad9b 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/Ed25519.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/Ed25519.kt @@ -1,49 +1,25 @@ package io.flamenet.e2e -import org.bouncycastle.math.ec.rfc8032.Ed25519 as Rfc8032 import java.security.SecureRandom +import org.bouncycastle.math.ec.rfc8032.Ed25519 as Rfc8032 /** * Ed25519 signatures, the signing half of an identity. - * - * Two distinct jobs use this. A device signs its prekeys, so a peer - * fetching a bundle from an untrusted relay can tell the relay did not - * substitute keys of its own, and signs each relay request to prove it holds - * the key its token names. An account signs its home records and its - * transparency-log claims, and its public half *is* the `fnid`. - * - * This does not go through the JCA, and that is not a preference. - * Android serves `KeyPairGenerator.Ed25519` and `KeyFactory.Ed25519` from - * `AndroidKeyStore` and from nowhere else, measured on API 35, see - * `ProviderProbeTest`, and Keystore only ever produces keys it keeps. Every - * Ed25519 key here is one we must hold ourselves: a device identity that lives - * in our own vault, and an account key derived from a passphrase. A JCA - * implementation compiles, passes 81 tests on a laptop, and throws - * `IllegalStateException: Not initialized` on the first launch on a phone. - * That is precisely what happened. - * - * A seed is not a private key. Ed25519's 32-byte seed expands, by - * hashing, into the actual scalar and the nonce prefix. [AccountKdf] derives a - * *seed*. Feeding a seed where a scalar is expected produces a valid-looking - * key with a different public half, which surfaces as a stranger's `fnid` - * rather than as an error. */ object Ed25519 { - - const val KEY_LEN = Rfc8032.SECRET_KEY_SIZE // 32 - const val SIG_LEN = Rfc8032.SIGNATURE_SIZE // 64 + const val KEY_LEN = Rfc8032.SECRET_KEY_SIZE // 32 + const val SIG_LEN = Rfc8032.SIGNATURE_SIZE // 64 private val random = SecureRandom() /** * A keypair as the two things that actually exist: the seed we store, and * the public half we publish. - * - * Not a `java.security.KeyPair`, because there is no JCA key object behind - * this on Android, and a type that implies one would invite somebody to - * pass it somewhere that needs a real `PrivateKey`. */ - data class KeyPair(val seed: ByteArray, val publicKey: ByteArray) { + data class KeyPair( + val seed: ByteArray, + val publicKey: ByteArray, + ) { override fun equals(other: Any?): Boolean = other is KeyPair && seed.contentEquals(other.seed) && publicKey.contentEquals(other.publicKey) @@ -61,24 +37,29 @@ object Ed25519 { return KeyPair(seed.copyOf(), publicKey) } - fun sign(seed: ByteArray, message: ByteArray): ByteArray { + fun sign( + seed: ByteArray, + message: ByteArray, + ): ByteArray { require(seed.size == KEY_LEN) { "an Ed25519 seed is $KEY_LEN bytes, got ${seed.size}" } val signature = ByteArray(SIG_LEN) Rfc8032.sign(seed, 0, message, 0, message.size, signature, 0) return signature } - fun sign(key: KeyPair, message: ByteArray): ByteArray = sign(key.seed, message) + fun sign( + key: KeyPair, + message: ByteArray, + ): ByteArray = sign(key.seed, message) /** * Verify, returning false rather than throwing. - * - * A malformed key or a truncated signature is a *failed verification*, not - * an exceptional condition: both arrive over the wire from someone who may - * be hostile, and a caller forced to wrap every check in a try/catch - * eventually wraps one badly and reads a thrown exception as a pass. */ - fun verify(publicKey: ByteArray, signature: ByteArray, message: ByteArray): Boolean = + fun verify( + publicKey: ByteArray, + signature: ByteArray, + message: ByteArray, + ): Boolean = try { signature.size == SIG_LEN && publicKey.size == KEY_LEN && Rfc8032.verify(signature, 0, publicKey, 0, message, 0, message.size) diff --git a/engine/src/main/kotlin/io/flamenet/e2e/FNID.kt b/engine/src/main/kotlin/io/flamenet/e2e/FNID.kt index 5259ac6..4661e5b 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/FNID.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/FNID.kt @@ -2,27 +2,12 @@ package io.flamenet.e2e /** * Global account identity, the address a person keeps when a relay does not. - * - * A relay's `user_id` is a row number it assigned itself; the account service's - * pseudonym is HMAC'd per product so two services cannot correlate their users. - * Neither survives a trip to a second relay. This does, because it is a public - * key the client derives and no server issues. - * - * Implements `engine/src/fnid.js`, which is normative. - * - * Deriving one takes two steps that live elsewhere, because neither belongs - * to an encoder: `AccountKdf.accountKeySeed` stretches the passphrase under - * `flamenet/account/identity/v1`, and `Ed25519.fromSeed` turns those 32 bytes - * into the public key this encodes. `AccountKdf.localAccountSalt` supplies the - * salt for a relay that keeps its own accounts and therefore stores none. */ object FNID { - /** * Crockford base32: no padding, and no `I`, `L`, `O` or `U`, so the * alphabet holds no pair a person can confuse reading an address aloud or - * copying it off a screen. Matches the recovery codes Files already issues - * rather than introducing a second encoding. + * copying it off a screen. */ const val ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz" @@ -53,22 +38,15 @@ object FNID { /** * What a client signs to claim an address at a relay. - * - * Binds the audience (so a claim cannot be replayed at another relay), the - * local user id (so it cannot be lifted onto another account there) and the - * key itself (so the signature cannot be transplanted). Concatenated rather - * than encoded, because three implementations must reproduce these bytes - * exactly and an encoder may respace between releases. */ - fun claimCanonical(audience: String, userId: Long, accountKeyB64: String): ByteArray = - "fnacct-v1|$audience|$userId|$accountKeyB64".toByteArray(Charsets.UTF_8) + fun claimCanonical( + audience: String, + userId: Long, + accountKeyB64: String, + ): ByteArray = "fnacct-v1|$audience|$userId|$accountKeyB64".toByteArray(Charsets.UTF_8) /** * The 32 raw bytes behind an `fnid:` address, or null if it is not one. - * - * The address *is* the public key, which is what lets a sender check a - * home record without asking anybody whether the record is true. Decoding - * is therefore not a convenience, it is the whole verification path. */ fun decode(address: String): ByteArray? { val body = address.removePrefix("fnid:") @@ -93,12 +71,10 @@ object FNID { /** * What a client signs to say which relays hold its mailboxes. - * - * The serial is inside the signature, which is what stops the rollback a - * signature alone permits: an old list is signed, so without a - * counter a relay could serve one from before a second home was added and - * quietly become the only mailbox again. */ - fun homesCanonical(fnid: String, serial: Int, relays: List): ByteArray = - "fnhomes-v1|$fnid|$serial|${relays.joinToString(",")}".toByteArray(Charsets.UTF_8) + fun homesCanonical( + fnid: String, + serial: Int, + relays: List, + ): ByteArray = "fnhomes-v1|$fnid|$serial|${relays.joinToString(",")}".toByteArray(Charsets.UTF_8) } diff --git a/engine/src/main/kotlin/io/flamenet/e2e/FileContent.kt b/engine/src/main/kotlin/io/flamenet/e2e/FileContent.kt index 02e299f..f964394 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/FileContent.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/FileContent.kt @@ -1,30 +1,9 @@ package io.flamenet.e2e /** - * File contents in the Files vault. Mirrors §7 of the published specification. - * - * Framed into independently authenticated chunks, and that framing is the - * security. A single AES-GCM blob cannot be verified until all of it has been - * buffered, which is untenable for a large file on a phone, so a naive - * implementation streams it out unverified, and the person reads attacker-chosen - * bytes before the tag ever fails. - * - * Independent chunks would otherwise open three doors, each closed by what goes - * into the authenticated data: - * - * - Truncation, drop the tail and the file just looks shorter. Closed by - * the final-chunk flag: the last chunk says it is last, so a truncated file - * ends on a chunk that claims it is not. - * - Reordering, closed by the index. - * - Splicing between files, closed by the label, plus a per-file content - * key. - * - * Named `FileContent` because the messenger's own `Content` is a different - * thing entirely, a message payload. They are not interchangeable and the - * shorter name was already taken. + * File contents in the Files vault. */ object FileContent { - private val MAGIC = "ENCL".toByteArray(Charsets.UTF_8) const val VERSION: Byte = 1 const val TAG_LEN = 16 @@ -32,28 +11,34 @@ object FileContent { const val DEFAULT_CHUNK_SIZE = 64 * 1024 /** Big-endian, four bytes. */ - internal fun u32be(value: Int): ByteArray = byteArrayOf( - (value ushr 24).toByte(), (value ushr 16).toByte(), - (value ushr 8).toByte(), value.toByte(), - ) + internal fun u32be(value: Int): ByteArray = + byteArrayOf( + (value ushr 24).toByte(), + (value ushr 16).toByte(), + (value ushr 8).toByte(), + value.toByte(), + ) - internal fun readU32be(data: ByteArray, at: Int): Long = + internal fun readU32be( + data: ByteArray, + at: Int, + ): Long = ((data[at].toLong() and 0xFF) shl 24) or ((data[at + 1].toLong() and 0xFF) shl 16) or ((data[at + 2].toLong() and 0xFF) shl 8) or (data[at + 3].toLong() and 0xFF) /** * `baseNonce(8) || index(4)`. - * - * Derived, not stored, and therefore never repeated within one file. A - * per-chunk random nonce would cost four extra bytes a chunk and buy - * nothing; a per-chunk *counter reset* would repeat a nonce under one key, - * which does not degrade GCM gracefully, it hands over the authentication - * subkey and with it the ability to forge. */ - internal fun nonce(base: ByteArray, index: Int): ByteArray = base + u32be(index) + internal fun nonce( + base: ByteArray, + index: Int, + ): ByteArray = base + u32be(index) - internal fun aad(label: Label, index: Int, isLast: Boolean): ByteArray = - label.bytes() + u32be(index) + byteArrayOf(if (isLast) 1 else 0) + internal fun aad( + label: Label, + index: Int, + isLast: Boolean, + ): ByteArray = label.bytes() + u32be(index) + byteArrayOf(if (isLast) 1 else 0) fun encrypt( plaintext: ByteArray, @@ -63,40 +48,42 @@ object FileContent { ): ByteArray { if (chunkSize <= 0) throw FilesEngineException.Malformed("chunk size out of range") val baseNonce = Random.bytes(8) - // An empty file is still ONE chunk. Writing zero chunks would leave a - // file with no authenticated end marker at all, indistinguishable from - // one truncated to nothing, the exact attack the flag exists to stop. + // An empty file is still ONE chunk. val count = maxOf(1, (plaintext.size + chunkSize - 1) / chunkSize) val out = java.io.ByteArrayOutputStream(HEADER_LEN + plaintext.size + count * TAG_LEN) - out.write(MAGIC); out.write(byteArrayOf(VERSION)) - out.write(u32be(chunkSize)); out.write(baseNonce) + out.write(MAGIC) + out.write(byteArrayOf(VERSION)) + out.write(u32be(chunkSize)) + out.write(baseNonce) for (index in 0 until count) { val start = index * chunkSize val end = minOf(start + chunkSize, plaintext.size) - val box = Aead.seal( - key = contentKey, - nonce = nonce(baseNonce, index), - plaintext = plaintext.copyOfRange(start, end), - aad = aad(label, index, index == count - 1), - ) + val box = + Aead.seal( + key = contentKey, + nonce = nonce(baseNonce, index), + plaintext = plaintext.copyOfRange(start, end), + aad = aad(label, index, index == count - 1), + ) // The nonce is derived on both sides, so it is not carried. out.write(box, Aead.NONCE_LEN, box.size - Aead.NONCE_LEN) } return out.toByteArray() } - fun decrypt(blob: ByteArray, contentKey: ByteArray, label: Label): ByteArray { + fun decrypt( + blob: ByteArray, + contentKey: ByteArray, + label: Label, + ): ByteArray { if (blob.size < HEADER_LEN) throw FilesEngineException.Malformed("truncated header") if (!blob.copyOfRange(0, 4).contentEquals(MAGIC)) { throw FilesEngineException.Malformed("not an Enclave content blob") } if (blob[4] != VERSION) throw FilesEngineException.UnsupportedVersion(blob[4]) - // Read as a Long. The header field is unsigned 32-bit, and a hostile - // or corrupt blob claiming 0xFFFFFFFF becomes -1 as an Int, which - // passes a `> 0` check written the obvious way and then allocates - // nonsense. + // Read as a Long. val declared = readU32be(blob, 5) if (declared <= 0 || declared > Int.MAX_VALUE) { throw FilesEngineException.Malformed("invalid chunk size") @@ -126,7 +113,7 @@ object FileContent { key = contentKey, box = nonce(baseNonce, index) + blob.copyOfRange(HEADER_LEN + start, HEADER_LEN + end), aad = aad(label, index, index == count - 1), - ) + ), ) } return out.toByteArray() diff --git a/engine/src/main/kotlin/io/flamenet/e2e/FileNames.kt b/engine/src/main/kotlin/io/flamenet/e2e/FileNames.kt index 70cee9a..129f042 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/FileNames.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/FileNames.kt @@ -5,45 +5,30 @@ import javax.crypto.Mac import javax.crypto.spec.SecretKeySpec /** - * Filenames in the Files vault. Mirrors §6 of the published specification. - * - * A name is encrypted, so no two encryptions of one name are equal and no - * index could ever match on the ciphertext. Lookup runs on a keyed digest - * instead, keyed per folder, so nothing holding the rows can invert a name - * or even tell that two folders contain one of the same name. - * - * Normalisation is part of the digest, and therefore permanent. "café" - * typed on a Mac and "café" typed on a phone are different byte sequences for - * the same word, one carries a combining accent, the other a precomposed - * character. Without NFC they produce different digests, and a file filed on - * one device is simply not found by the other. Changing the form later would - * invalidate every digest ever written, so it is pinned to NFC here and tested. - * - * Case is significant, deliberately. Adding case folding would be the - * same one-way door: every existing digest would stop matching. + * Filenames in the Files vault. */ object FileNames { - const val VERSION: Byte = 1 /** * NFC, matching CryptoKit's `precomposedStringWithCanonicalMapping`. - * - * NFC and not NFKC. The compatibility forms fold things that are not the - * same name, fi into fi, full-width Latin into ASCII, so two files a - * person can tell apart on screen would collide in the index. */ - fun normalize(name: String): ByteArray = - Normalizer.normalize(name, Normalizer.Form.NFC).toByteArray(Charsets.UTF_8) + fun normalize(name: String): ByteArray = Normalizer.normalize(name, Normalizer.Form.NFC).toByteArray(Charsets.UTF_8) - fun digest(folderKey: ByteArray, name: String): ByteArray { + fun digest( + folderKey: ByteArray, + name: String, + ): ByteArray { val mac = Mac.getInstance("HmacSHA256") mac.init(SecretKeySpec(folderKey, "HmacSHA256")) return mac.doFinal(normalize(name)) } /** The name ciphertext, and the digest it is bound to. */ - data class Sealed(val digest: ByteArray, val ciphertext: ByteArray) { + data class Sealed( + val digest: ByteArray, + val ciphertext: ByteArray, + ) { override fun equals(other: Any?): Boolean = other is Sealed && digest.contentEquals(other.digest) && ciphertext.contentEquals(other.ciphertext) @@ -52,39 +37,44 @@ object FileNames { } /** - * The label AND the digest are authenticated. Without both, a name - * ciphertext could be lifted onto another row, which is how somebody - * learns which plaintext belongs to a file they cannot open, and how a row - * gets quietly reclassified by editing a column. + * The label AND the digest are authenticated. */ - fun encrypt(folderKey: ByteArray, name: String, label: Label): Sealed { + fun encrypt( + folderKey: ByteArray, + name: String, + label: Label, + ): Sealed { val nameDigest = digest(folderKey, name) - val box = Aead.seal( - key = folderKey, - nonce = Random.bytes(Aead.NONCE_LEN), - plaintext = normalize(name), - aad = label.bytes() + nameDigest, - ) + val box = + Aead.seal( + key = folderKey, + nonce = Random.bytes(Aead.NONCE_LEN), + plaintext = normalize(name), + aad = label.bytes() + nameDigest, + ) return Sealed(nameDigest, byteArrayOf(VERSION) + box) } - fun decrypt(folderKey: ByteArray, blob: ByteArray, digest: ByteArray, label: Label): String { + fun decrypt( + folderKey: ByteArray, + blob: ByteArray, + digest: ByteArray, + label: Label, + ): String { // > 13, not >= : one version byte plus a nonce and a tag is the floor, // and a box of exactly that length holds no ciphertext at all. if (blob.size <= 1 + Aead.NONCE_LEN) throw FilesEngineException.Malformed("name too short") if (blob[0] != VERSION) throw FilesEngineException.UnsupportedVersion(blob[0]) - val plaintext = Aead.open( - key = folderKey, - box = blob.copyOfRange(1, blob.size), - aad = label.bytes() + digest, - ) - // Strictly, and not `String(bytes, UTF_8)`. That constructor - // substitutes U+FFFD for anything invalid, so a name that is not UTF-8 - // arrives on screen as a row of replacement characters instead of being - // refused, and iOS, which does refuse, would disagree about whether the - // same vault row exists at all. + val plaintext = + Aead.open( + key = folderKey, + box = blob.copyOfRange(1, blob.size), + aad = label.bytes() + digest, + ) + // Strictly, and not `String(bytes, UTF_8)`. return try { - Charsets.UTF_8.newDecoder() + Charsets.UTF_8 + .newDecoder() .onMalformedInput(java.nio.charset.CodingErrorAction.REPORT) .onUnmappableCharacter(java.nio.charset.CodingErrorAction.REPORT) .decode(java.nio.ByteBuffer.wrap(plaintext)) diff --git a/engine/src/main/kotlin/io/flamenet/e2e/FileWrap.kt b/engine/src/main/kotlin/io/flamenet/e2e/FileWrap.kt index 53918c1..9432a43 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/FileWrap.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/FileWrap.kt @@ -1,19 +1,9 @@ package io.flamenet.e2e /** - * Key wrapping for the Files vault. Mirrors §4 and §5 of the published - * specification. - * - * Every wrap takes the canonical label encoding as authenticated data. - * That single decision is what stops a row being reclassified by editing a - * column: change the stored classification and the key no longer unwraps, so a - * relabelled file fails to open rather than quietly changing sensitivity. It is - * also why [Label.bytes] is pinned to a vector, a label that encodes - * differently on two platforms produces no warning, only a decryption failure - * on the other phone. + * Key wrapping for the Files vault. */ object FileWrap { - /** `version(1) || ephemeral public key(32) || nonce(12) || ciphertext || tag` */ const val ASYMMETRIC_VERSION: Byte = 1 @@ -23,25 +13,26 @@ object FileWrap { private val INFO = "enclave/wrap/v1".toByteArray(Charsets.UTF_8) /** - * Both public keys go in the salt, and the order is fixed. They bind - * the derived key to this exact pair, so a wrap made for one recipient - * cannot be replayed at another. Swapping the two halves is the mistake - * that reads as working, sender and recipient each derive a key, the two - * differ, and the only symptom is a file that will not open. + * Both public keys go in the salt, and the order is fixed. */ private fun kek( privateKey: java.security.PrivateKey, peerRaw: ByteArray, ephemeralPublic: ByteArray, recipientPublic: ByteArray, - ): ByteArray = Hkdf.derive( - ikm = X25519.agree(privateKey, peerRaw), - salt = ephemeralPublic + recipientPublic, - info = INFO, - length = 32, - ) + ): ByteArray = + Hkdf.derive( + ikm = X25519.agree(privateKey, peerRaw), + salt = ephemeralPublic + recipientPublic, + info = INFO, + length = 32, + ) - fun toPublicKey(recipientRaw: ByteArray, key: ByteArray, aad: ByteArray): ByteArray { + fun toPublicKey( + recipientRaw: ByteArray, + key: ByteArray, + aad: ByteArray, + ): ByteArray { val ephemeral = X25519.generate() val ephemeralPublic = X25519.rawFromPublicKey(ephemeral.public) val derived = kek(ephemeral.private, recipientRaw, ephemeralPublic, recipientRaw) @@ -60,19 +51,24 @@ object FileWrap { } if (blob[0] != ASYMMETRIC_VERSION) throw FilesEngineException.UnsupportedVersion(blob[0]) val ephemeralPublic = blob.copyOfRange(1, 1 + X25519.KEY_LEN) - // The salt is rebuilt from the SAME two values the sender used, - // their ephemeral key and OUR public key, never from whichever key - // happens to be to hand. `ownPublicRaw` is passed in rather than - // recovered from the private key because the JCA round-trip for that is - // one more place to hand over the wrong 32 bytes silently. + // The salt is rebuilt from the SAME two values the sender used, their + // ephemeral key and OUR public key, never from whichever key happens to + // be to hand. val derived = kek(privateKey, ephemeralPublic, ephemeralPublic, ownPublicRaw) return Aead.open(derived, blob.copyOfRange(1 + X25519.KEY_LEN, blob.size), aad) } - fun withKey(kek: ByteArray, key: ByteArray, aad: ByteArray): ByteArray = - byteArrayOf(SYMMETRIC_VERSION) + Aead.seal(kek, Random.bytes(Aead.NONCE_LEN), key, aad) + fun withKey( + kek: ByteArray, + key: ByteArray, + aad: ByteArray, + ): ByteArray = byteArrayOf(SYMMETRIC_VERSION) + Aead.seal(kek, Random.bytes(Aead.NONCE_LEN), key, aad) - fun openWithKey(kek: ByteArray, blob: ByteArray, aad: ByteArray): ByteArray { + fun openWithKey( + kek: ByteArray, + blob: ByteArray, + aad: ByteArray, + ): ByteArray { if (blob.size <= 1 + Aead.NONCE_LEN) throw FilesEngineException.Malformed("wrap too short") if (blob[0] != SYMMETRIC_VERSION) throw FilesEngineException.UnsupportedVersion(blob[0]) return Aead.open(kek, blob.copyOfRange(1, blob.size), aad) @@ -81,18 +77,13 @@ object FileWrap { /** * The key hierarchy. - * - * A node key is wrapped to its OWN label key, never to the folder above - * it. The tree deliberately allows a Restricted file to sit inside a - * Confidential folder, so a folder-derived key would hand every holder of that - * folder's key content above their clearance. When the rows lived in Postgres - * an RLS predicate would still have refused to return them; there is no - * database in the local vault, and nothing else would catch it. */ object FileKeys { - - fun grantLabelKey(labelKey: ByteArray, label: Label, toRecipientRaw: ByteArray): ByteArray = - FileWrap.toPublicKey(toRecipientRaw, labelKey, label.bytes()) + fun grantLabelKey( + labelKey: ByteArray, + label: Label, + toRecipientRaw: ByteArray, + ): ByteArray = FileWrap.toPublicKey(toRecipientRaw, labelKey, label.bytes()) fun acceptLabelKey( blob: ByteArray, @@ -101,15 +92,27 @@ object FileKeys { ownPublicRaw: ByteArray, ): ByteArray = FileWrap.fromPrivateKey(privateKey, ownPublicRaw, blob, label.bytes()) - fun wrapNodeKey(nodeKey: ByteArray, labelKey: ByteArray, label: Label): ByteArray = - FileWrap.withKey(labelKey, nodeKey, label.bytes()) + fun wrapNodeKey( + nodeKey: ByteArray, + labelKey: ByteArray, + label: Label, + ): ByteArray = FileWrap.withKey(labelKey, nodeKey, label.bytes()) - fun unwrapNodeKey(blob: ByteArray, labelKey: ByteArray, label: Label): ByteArray = - FileWrap.openWithKey(labelKey, blob, label.bytes()) + fun unwrapNodeKey( + blob: ByteArray, + labelKey: ByteArray, + label: Label, + ): ByteArray = FileWrap.openWithKey(labelKey, blob, label.bytes()) - fun wrapContentKey(contentKey: ByteArray, nodeKey: ByteArray, label: Label): ByteArray = - FileWrap.withKey(nodeKey, contentKey, label.bytes()) + fun wrapContentKey( + contentKey: ByteArray, + nodeKey: ByteArray, + label: Label, + ): ByteArray = FileWrap.withKey(nodeKey, contentKey, label.bytes()) - fun unwrapContentKey(blob: ByteArray, nodeKey: ByteArray, label: Label): ByteArray = - FileWrap.openWithKey(nodeKey, blob, label.bytes()) + fun unwrapContentKey( + blob: ByteArray, + nodeKey: ByteArray, + label: Label, + ): ByteArray = FileWrap.openWithKey(nodeKey, blob, label.bytes()) } diff --git a/engine/src/main/kotlin/io/flamenet/e2e/FilesEngine.kt b/engine/src/main/kotlin/io/flamenet/e2e/FilesEngine.kt index 56f63b1..701fe4e 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/FilesEngine.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/FilesEngine.kt @@ -5,32 +5,26 @@ import java.security.SecureRandom /** * What the Files half of the engine throws, and where its randomness comes * from. - * - * Each case carries a sentence, not a code. The iOS engine learned this - * the hard way: an enum without `errorDescription` reaches a person as "The - * operation couldn't be completed. (error 1.)", and the actual reason, already - * written at the throw site, is discarded on the way to the screen. Kotlin - * makes the same mistake available by default, so the message is required here. */ -sealed class FilesEngineException(message: String) : Exception(message) { +sealed class FilesEngineException( + message: String, +) : Exception(message) { + class UnsupportedVersion( + val version: Byte, + ) : FilesEngineException( + "This was written in a newer format (version ${version.toInt() and 0xFF}) " + + "than this app understands.", + ) - class UnsupportedVersion(val version: Byte) : FilesEngineException( - "This was written in a newer format (version ${version.toInt() and 0xFF}) " + - "than this app understands." - ) - - class Malformed(why: String) : FilesEngineException(why) + class Malformed( + why: String, + ) : FilesEngineException(why) class NotCleared : FilesEngineException("You aren't cleared for this.") } /** * Random bytes, from the system CSPRNG. - * - * `SecureRandom()` and never `kotlin.random.Random`. The latter is a - * deterministic PRNG seeded from the clock, fine for shuffling a list, and a - * total break for a nonce or a content key. The two are one import apart and - * the wrong one compiles. */ object Random { private val source = SecureRandom() diff --git a/engine/src/main/kotlin/io/flamenet/e2e/FilesIdentity.kt b/engine/src/main/kotlin/io/flamenet/e2e/FilesIdentity.kt index e2cf4ee..9eb4b36 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/FilesIdentity.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/FilesIdentity.kt @@ -3,43 +3,35 @@ package io.flamenet.e2e import org.json.JSONObject /** - * The Files identity keypair, and the record that protects it. Mirrors §3 of - * the published specification. - * - * The record is written by every FlameNet client and must open in all of - * them. The browser writes it, iOS writes it, and this reads what either - * produced. That is why the awkward details below are pinned rather than - * tidied: each one is a place where two implementations could disagree and only - * find out when somebody's files will not open on their new phone. + * The Files identity keypair, and the record that protects it. */ object FilesIdentity { - /** - * The stored private key is PKCS#8, not the raw 32 bytes, because - * WebCrypto exports X25519 private keys that way and CryptoKit does not. - * For X25519 the wrapper is a fixed 16-byte prefix, so the conversion is - * exact in both directions, and on the JVM it is not even a conversion: - * the JCA wants PKCS#8 anyway, which is the one place this platform has it - * easier than the other two. + * The stored private key is PKCS#8, not the raw 32 bytes, because WebCrypto + * exports X25519 private keys that way and CryptoKit does not. */ - private val PKCS8_PREFIX = byteArrayOf( - 0x30, 0x2E, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, - 0x03, 0x2B, 0x65, 0x6E, 0x04, 0x22, 0x04, 0x20, - ) + private val PKCS8_PREFIX = + byteArrayOf( + 0x30, + 0x2E, + 0x02, + 0x01, + 0x00, + 0x30, + 0x05, + 0x06, + 0x03, + 0x2B, + 0x65, + 0x6E, + 0x04, + 0x22, + 0x04, + 0x20, + ) /** * The X25519 base point, `u = 9`. - * - * This is how the public key is recovered from the private one. RFC - * 7748 defines the public key as X25519(scalar, 9), and a key agreement - * against the base point computes exactly that, so the JCA gives it up - * without needing a scalar multiplication written here. The JCA otherwise - * has no way to derive a public key from an XDH private key, and the - * alternative is storing the public half alongside, which is one more thing - * that can be stored wrong and one more thing to disagree about. - * - * Pinned to the vector file's `identityPublicKey`, because "it returned - * 32 bytes" is not evidence that they are the right 32 bytes. */ private val BASE_POINT = ByteArray(32).also { it[0] = 9 } @@ -58,11 +50,12 @@ object FilesIdentity { } /** The public half, as the wire and every wrap salt carry it. */ - fun publicKeyFromRaw(rawPrivate: ByteArray): ByteArray = - X25519.agree(X25519.privateKeyFromPkcs8(pkcs8FromRaw(rawPrivate)), BASE_POINT) + fun publicKeyFromRaw(rawPrivate: ByteArray): ByteArray = X25519.agree(X25519.privateKeyFromPkcs8(pkcs8FromRaw(rawPrivate)), BASE_POINT) /** An unlocked identity: both halves, and the JCA handle for agreement. */ - class Unlocked(val rawPrivate: ByteArray) { + class Unlocked( + val rawPrivate: ByteArray, + ) { val pkcs8: ByteArray = pkcs8FromRaw(rawPrivate) val privateKey: java.security.PrivateKey = X25519.privateKeyFromPkcs8(pkcs8) val publicKeyRaw: ByteArray = publicKeyFromRaw(rawPrivate) @@ -78,39 +71,38 @@ object FilesIdentity { val nonce: String, val privateKey: String, ) { - fun json(): String = JSONObject() - .put("v", v).put("kdf", kdf).put("iterations", iterations) - .put("context", context).put("salt", salt).put("nonce", nonce) - .put("privateKey", privateKey) - .toString() + fun json(): String = + JSONObject() + .put("v", v) + .put("kdf", kdf) + .put("iterations", iterations) + .put("context", context) + .put("salt", salt) + .put("nonce", nonce) + .put("privateKey", privateKey) + .toString() companion object { - fun parse(text: String): Record = try { - val o = JSONObject(text) - Record( - v = o.getInt("v"), kdf = o.getInt("kdf"), - iterations = o.getInt("iterations"), context = o.getString("context"), - salt = o.getString("salt"), nonce = o.getString("nonce"), - privateKey = o.getString("privateKey"), - ) - } catch (e: org.json.JSONException) { - throw FilesEngineException.Malformed("identity record is not valid JSON") - } + fun parse(text: String): Record = + try { + val o = JSONObject(text) + Record( + v = o.getInt("v"), + kdf = o.getInt("kdf"), + iterations = o.getInt("iterations"), + context = o.getString("context"), + salt = o.getString("salt"), + nonce = o.getString("nonce"), + privateKey = o.getString("privateKey"), + ) + } catch (e: org.json.JSONException) { + throw FilesEngineException.Malformed("identity record is not valid JSON") + } } } /** * Encrypt a private key for storage. - * - * The iteration count is written into the record, not looked up. - * Raising the default must not strand accounts whose records were made - * under the old one, so the record has to say what actually produced its - * key. A registry lookup would make every past record undecryptable the day - * the number changed. - * - * The context is the authenticated data as well as the KDF separator, so - * a record made for one product will not open as another's even with the - * right passphrase. */ fun protect( rawPrivate: ByteArray, @@ -123,41 +115,52 @@ object FilesIdentity { val nonce = Random.bytes(Aead.NONCE_LEN) val box = Aead.seal(kek, nonce, pkcs8FromRaw(rawPrivate), context.toByteArray(Charsets.UTF_8)) return Record( - v = 1, kdf = params.id, iterations = params.iterations, context = context, + v = 1, + kdf = params.id, + iterations = params.iterations, + context = context, salt = Bytes.b64(salt), nonce = Bytes.b64(nonce), // Ciphertext and tag joined, WITHOUT the nonce, that is what // WebCrypto's AES-GCM emits and what the record has always held. - // Storing the combined box here instead would produce a record no - // other client can read, and this one would read its own back - // perfectly. privateKey = Bytes.b64(box.copyOfRange(Aead.NONCE_LEN, box.size)), ) } /** Unlock a record produced by this engine, by iOS, or by the browser. */ - fun unlock(record: Record, passphrase: String): Unlocked { + fun unlock( + record: Record, + passphrase: String, + ): Unlocked { if (record.v != 1) throw FilesEngineException.UnsupportedVersion(record.v.toByte()) val salt = decode(record.salt, "salt") val nonce = decode(record.nonce, "nonce") val sealed = decode(record.privateKey, "private key") - val kek = FilesKdf.derive( - passphrase, salt, record.context, - FilesKdf.Params(id = record.kdf, iterations = record.iterations), - ) - val pkcs8 = try { - // The record keeps the nonce apart; [Aead] wants it in front. - Aead.open(kek, nonce + sealed, record.context.toByteArray(Charsets.UTF_8)) - } catch (e: Aead.DecryptionFailed) { - throw FilesEngineException.Malformed("That passphrase didn't open this identity.") - } + val kek = + FilesKdf.derive( + passphrase, + salt, + record.context, + FilesKdf.Params(id = record.kdf, iterations = record.iterations), + ) + val pkcs8 = + try { + // The record keeps the nonce apart; [Aead] wants it in front. + Aead.open(kek, nonce + sealed, record.context.toByteArray(Charsets.UTF_8)) + } catch (e: Aead.DecryptionFailed) { + throw FilesEngineException.Malformed("That passphrase didn't open this identity.") + } return Unlocked(rawFromPkcs8(pkcs8)) } - private fun decode(text: String, what: String): ByteArray = try { - Bytes.unb64(text) - } catch (e: IllegalArgumentException) { - throw FilesEngineException.Malformed("identity $what is not valid base64") - } + private fun decode( + text: String, + what: String, + ): ByteArray = + try { + Bytes.unb64(text) + } catch (e: IllegalArgumentException) { + throw FilesEngineException.Malformed("identity $what is not valid base64") + } } diff --git a/engine/src/main/kotlin/io/flamenet/e2e/FilesKdf.kt b/engine/src/main/kotlin/io/flamenet/e2e/FilesKdf.kt index 31edf94..5f9fccc 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/FilesKdf.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/FilesKdf.kt @@ -4,21 +4,13 @@ import javax.crypto.Mac import javax.crypto.spec.SecretKeySpec /** - * Key derivation for the Files vault. Mirrors §2 of the published - * specification. - * - * Two stages on purpose: one expensive stretch, then a cheap per-product split. - * That is what lets a single FlameNet credential back several products without - * any of them sharing key material, a compromise of the Messenger identity key - * says nothing about the Files one. - * - * Not [AccountKdf]. Same shape, different context strings and a different - * salt, and the two must not be collapsed: the point of the split is that - * one product's derived secret is useless in another. + * Key derivation for the Files vault. */ object FilesKdf { - - data class Params(val id: Int, val iterations: Int) { + data class Params( + val id: Int, + val iterations: Int, + ) { companion object { val V1 = Params(id = 1, iterations = 650_000) } @@ -26,9 +18,6 @@ object FilesKdf { /** * Per-product context strings. - * - * These are wire format. Changing one strands every account that - * used it, the record does not open, and there is nothing to fall back to. */ object Context { const val FILES_IDENTITY = "flamenet/files/identity/v1" @@ -42,19 +31,6 @@ object FilesKdf { /** * PBKDF2-HMAC-SHA256. - * - * Hand-written on top of [Mac] rather than through - * `SecretKeyFactory("PBKDF2WithHmacSHA256")`, for the same reason - * [Bip39.pbkdf2HmacSha512] is. The JCA path takes a `CharArray` and lets - * the provider decide how to encode it. The reference implementation here - * is WebCrypto, which takes *bytes*, and CommonCrypto on iOS takes bytes - * too. Handing the JCA characters and hoping puts a provider between us and - * a byte sequence that three implementations already agree on. - * - * The failure it prevents is silent: a passphrase containing anything - * outside ASCII would stretch differently under a different provider, and - * the identity record simply would not open, on that phone, for that - * person, with no way to tell why. */ internal fun pbkdf2HmacSha256( password: ByteArray, @@ -74,9 +50,11 @@ object FilesKdf { mac.update(salt) mac.update( byteArrayOf( - (block ushr 24).toByte(), (block ushr 16).toByte(), - (block ushr 8).toByte(), block.toByte(), - ) + (block ushr 24).toByte(), + (block ushr 16).toByte(), + (block ushr 8).toByte(), + block.toByte(), + ), ) var u = mac.doFinal() val accumulated = u.copyOf() @@ -96,16 +74,14 @@ object FilesKdf { } /** Stage one. Shared across products, never used directly. */ - fun master(passphrase: String, salt: ByteArray, params: Params = Params.V1): ByteArray = - pbkdf2HmacSha256(passphrase.toByteArray(Charsets.UTF_8), salt, params.iterations, 32) + fun master( + passphrase: String, + salt: ByteArray, + params: Params = Params.V1, + ): ByteArray = pbkdf2HmacSha256(passphrase.toByteArray(Charsets.UTF_8), salt, params.iterations, 32) /** * Stage two: raw bytes for one context. - * - * The HKDF salt is empty, deliberately. The per-account salt has already - * done its work in the stretch above, and the context string is what - * separates one derived secret from another. RFC 5869 defines an empty salt - * as HashLen zeros, which is what [Hkdf] does, not "skip the extract". */ fun derive( passphrase: String, @@ -113,20 +89,20 @@ object FilesKdf { context: String, params: Params = Params.V1, length: Int = 32, - ): ByteArray = Hkdf.derive( - ikm = master(passphrase, salt, params), - salt = ByteArray(0), - info = context.toByteArray(Charsets.UTF_8), - length = length, - ) + ): ByteArray = + Hkdf.derive( + ikm = master(passphrase, salt, params), + salt = ByteArray(0), + info = context.toByteArray(Charsets.UTF_8), + length = length, + ) /** * The secret sent to the account service. - * - * Its own context, so a compromised account service holds nothing that - * decrypts a file. It has the proof you knew the passphrase and not one - * byte of anything that opens a vault. */ - fun authSecret(passphrase: String, salt: ByteArray, params: Params = Params.V1): ByteArray = - derive(passphrase, salt, Context.FILES_AUTH, params) + fun authSecret( + passphrase: String, + salt: ByteArray, + params: Params = Params.V1, + ): ByteArray = derive(passphrase, salt, Context.FILES_AUTH, params) } diff --git a/engine/src/main/kotlin/io/flamenet/e2e/HdAccount.kt b/engine/src/main/kotlin/io/flamenet/e2e/HdAccount.kt index 4bbdc5b..1a23fc6 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/HdAccount.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/HdAccount.kt @@ -2,15 +2,6 @@ package io.flamenet.e2e /** * An account's receive keys, and the address each one prints as. - * - * [next] moves the index on. A wallet that handed out the same address - * every time would let anyone watching a public chain group every payment - * somebody ever received, which is the one linkability the wallet can actually - * prevent, so it prevents it by default rather than on request. The chain - * cannot be persuaded to forget; this is the only lever there is. - * - * Every level is hardened, because SLIP-0010 defines no non-hardened - * derivation for ed25519. See [HdKey]. */ data class HdAccount( val seed: ByteArray, @@ -19,12 +10,12 @@ data class HdAccount( val account: Long = 0, val nextIndex: Long = 0, ) { - override fun equals(other: Any?) = other is HdAccount && - seed.contentEquals(other.seed) && coinType == other.coinType && - account == other.account && nextIndex == other.nextIndex + override fun equals(other: Any?) = + other is HdAccount && + seed.contentEquals(other.seed) && coinType == other.coinType && + account == other.account && nextIndex == other.nextIndex - override fun hashCode() = - 31 * seed.contentHashCode() + (coinType * 31 + account * 7 + nextIndex).toInt() + override fun hashCode() = 31 * seed.contentHashCode() + (coinType * 31 + account * 7 + nextIndex).toInt() /** BIP-44 shape, every level hardened as this curve requires. */ fun path(index: Long): String = "m/44H/${coinType}H/${account}H/0H/${index}H" @@ -33,12 +24,6 @@ data class HdAccount( /** * The address at [index], and the path it came from. - * - * Base58 of the raw public key, with no checksum, because Solana - * addresses have none. A mistyped address that still decodes to 32 bytes - * is a *valid* address belonging to nobody, money sent there is gone, and - * there is no layer below this that will catch it. Anything offering an - * address to be typed has to say so; this function cannot. */ fun receive(index: Long): Receive = Receive( @@ -47,45 +32,29 @@ data class HdAccount( path = path(index), ) - data class Receive(val index: Long, val address: String, val path: String) + data class Receive( + val index: Long, + val address: String, + val path: String, + ) /** * The signing key for [index]. - * - * Same path, same curve, same code as [receive], deliberately. If - * these two ever disagreed the wallet would sign with a key that does not - * own the address it displayed. The transaction being rejected by the - * network is the GOOD outcome; the bad one is signing for an address - * somebody else controls. */ fun signingSeed(index: Long): ByteArray = key(index).privateKey /** The next unused receive key, and the account with its counter advanced. */ - fun next(): Pair = - receive(nextIndex) to copy(nextIndex = nextIndex + 1) + fun next(): Pair = receive(nextIndex) to copy(nextIndex = nextIndex + 1) companion object { /** - * Solana's SLIP-44 coin type: the ed25519 chain family this - * derivation is defined for. Other ed25519 chains differ only in this - * number. secp256k1 chains (Bitcoin, Ethereum) need curve arithmetic - * this engine does not carry. + * Solana's SLIP-44 coin type: the ed25519 chain family this derivation + * is defined for. */ const val SOLANA: Long = 501 /** * Whether [address] could be an address on this chain. - * - * This says "well formed", NEVER "correct", and the difference is - * money. A Solana address is Base58 of a 32-byte public key and - * carries no checksum, so a single mistyped character that happens to - * decode to 32 bytes produces a perfectly valid address that nobody - * holds the key for. Bitcoin's Base58Check would catch that; this - * cannot, and neither can the network. - * - * The only real defences are pasting rather than typing, and showing - * the address back in a form somebody can compare. A caller that treats - * a `true` here as "this address is right" has misread it. */ fun looksLikeAddress(address: String): Boolean { val decoded = Base58.decode(address) ?: return false diff --git a/engine/src/main/kotlin/io/flamenet/e2e/HdKey.kt b/engine/src/main/kotlin/io/flamenet/e2e/HdKey.kt index 8dbf028..d779581 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/HdKey.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/HdKey.kt @@ -4,28 +4,7 @@ import javax.crypto.Mac import javax.crypto.spec.SecretKeySpec /** - * Hierarchical key derivation from a wallet seed. SLIP-0010, ed25519 curve. - * - * Addresses are independent of who you are, by construction. The seed is - * fresh entropy, never derived from the passphrase, the account name, or the - * Flamenet identity, so no address can be computed from a Flamenet identity - * and no identity from an address. This type only walks a tree downward from - * that seed; nothing identifying is ever an input, and nothing here should ever - * be given one. - * - * Fresh key per use, not one address reused. The tree exists so an - * ordinary path, `m/44H/501H/accountH/0H`, yields a new keypair per receive - * without new backup material: the phrase still restores all of them. Reusing a - * single address would tie every payment together on a public ledger for - * anybody who cared to look, which is a privacy property the wallet can control - * and the chain cannot. - * - * ed25519 only, and hardened only. SLIP-0010 defines no non-hardened - * derivation for this curve, the public key is a compressed Edwards point with - * no usable additive homomorphism, so [derive] refuses an unhardened index - * rather than inventing a scheme. secp256k1 chains need curve arithmetic this - * engine does not carry; supporting them is a dependency decision, not - * something to approximate here. + * Hierarchical key derivation from a wallet seed. */ data class HdKey( /** 32 bytes. The ed25519 scalar seed, not an expanded secret. */ @@ -33,18 +12,27 @@ data class HdKey( /** 32 bytes. Feeds the next derivation, never a key on its own. */ val chainCode: ByteArray, ) { - // ByteArray has reference equality, and a data class inherits it. Two - // keys derived from one path down two objects must compare equal or every - // test here asserts nothing. - override fun equals(other: Any?) = other is HdKey && - privateKey.contentEquals(other.privateKey) && chainCode.contentEquals(other.chainCode) + // ByteArray has reference equality, and a data class inherits it. + override fun equals(other: Any?) = + other is HdKey && + privateKey.contentEquals(other.privateKey) && chainCode.contentEquals(other.chainCode) override fun hashCode() = 31 * privateKey.contentHashCode() + chainCode.contentHashCode() - sealed class Failure(message: String) : Exception(message) { - class SeedOutOfRange(val size: Int) : Failure("That wallet seed is the wrong size.") - class Unhardened(val index: Long) : Failure("This wallet only derives hardened keys.") - class MalformedPath(val path: String) : Failure("$path isn't a derivation path.") + sealed class Failure( + message: String, + ) : Exception(message) { + class SeedOutOfRange( + val size: Int, + ) : Failure("That wallet seed is the wrong size.") + + class Unhardened( + val index: Long, + ) : Failure("This wallet only derives hardened keys.") + + class MalformedPath( + val path: String, + ) : Failure("$path isn't a derivation path.") } /** One hardened step. [index] may be given with or without the flag set. */ @@ -52,9 +40,7 @@ data class HdKey( val hardenedIndex = if (index >= HARDENED) index else index or HARDENED if (hardenedIndex < HARDENED) throw Failure.Unhardened(index) - // 0x00 || private key || index, big-endian. The leading zero is what - // distinguishes this from the secp256k1 layout and is part of the - // standard, not padding. + // 0x00 || private key || index, big-endian. val data = ByteArray(1 + 32 + 4) data[0] = 0 System.arraycopy(privateKey, 0, data, 1, 32) @@ -69,13 +55,6 @@ data class HdKey( /** * Walk a path such as `m/44H/501H/0H/0H`. - * - * Accepts `H`, `h` and `'` for hardened, because all three are written - * in the wild and a path copied from documentation should not fail on - * notation. An index with none of them is REFUSED rather than silently - * hardened: somebody writing `m/44` means non-hardened, which this curve - * cannot do, and quietly giving them a different key than every other - * wallet would is the worst possible answer. */ fun derive(path: String): HdKey { val components = path.split("/").filter { it.isNotEmpty() }.toMutableList() @@ -85,8 +64,9 @@ data class HdKey( if (!(component.endsWith("H") || component.endsWith("h") || component.endsWith("'"))) { throw Failure.Unhardened(0) } - val index = component.dropLast(1).toLongOrNull() - ?: throw Failure.MalformedPath(path) + val index = + component.dropLast(1).toLongOrNull() + ?: throw Failure.MalformedPath(path) if (index < 0 || index >= HARDENED) throw Failure.MalformedPath(path) key = key.derive(index) } @@ -102,20 +82,22 @@ data class HdKey( /** The master key. SLIP-0010 §Master key generation. */ fun master(seed: ByteArray): HdKey { - // The standard's own bound. BIP-39 gives 64, so this only fires on - // a caller that built a seed some other way. + // The standard's own bound. if (seed.size !in 16..64) throw Failure.SeedOutOfRange(seed.size) val bytes = hmacSha512(key = CURVE, data = seed) - // ed25519 needs no retry loop. On secp256k1 an IL of zero or ≥ n - // is re-hashed; every 32-byte string is a valid ed25519 scalar seed, - // so there is no such case and adding one would be dead code that - // looks load-bearing. + // ed25519 needs no retry loop. return HdKey(bytes.copyOfRange(0, 32), bytes.copyOfRange(32, 64)) } - fun master(seed: ByteArray, path: String): HdKey = master(seed).derive(path) + fun master( + seed: ByteArray, + path: String, + ): HdKey = master(seed).derive(path) - private fun hmacSha512(key: ByteArray, data: ByteArray): ByteArray { + private fun hmacSha512( + key: ByteArray, + data: ByteArray, + ): ByteArray { val mac = Mac.getInstance("HmacSHA512") mac.init(SecretKeySpec(key, "HmacSHA512")) return mac.doFinal(data) diff --git a/engine/src/main/kotlin/io/flamenet/e2e/HistoryExport.kt b/engine/src/main/kotlin/io/flamenet/e2e/HistoryExport.kt index c8072e2..c543fe8 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/HistoryExport.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/HistoryExport.kt @@ -10,23 +10,8 @@ import javax.crypto.spec.SecretKeySpec /** * A history backup the platform cannot read. - * - * The relay keeps no history, that is the product's whole promise, which - * means a new device starts empty. This is the designed remedy: the - * vault's history, re-sealed under a key derived from a passphrase the user - * chooses, so the file is safe to park anywhere and useless without the words. - * - * Same envelope as the iOS app (a JSON header naming the KDF around one - * AES-256-GCM box), but the v1 payload is platform-shaped, so the envelope is - * platform-tagged and a mismatched import is refused with an honest message - * rather than half-parsed. - * - * The passphrase is the only key. It is not stored anywhere, not in the - * Keystore, not in the file. A forgotten passphrase means the backup is gone, - * and that is the design, not a gap. */ object HistoryExport { - const val FILE_EXTENSION = "fnhistory" /** High on purpose: runs once per export, prices out billions of guesses. */ @@ -36,36 +21,51 @@ object HistoryExport { private const val TAG_BITS = 128 class NotAnExport : Exception("That file is not a Flamenet history export.") - class WrongPlatform(val platform: String) : - Exception("This backup was made on ${if (platform == "ios") "iPhone" else platform}, import it on a device of the same kind.") + + class WrongPlatform( + val platform: String, + ) : Exception("This backup was made on ${if (platform == "ios") "iPhone" else platform}, import it on a device of the same kind.") + class WrongPassphrase : Exception("That passphrase doesn't open this backup.") - fun seal(payload: JSONObject, passphrase: CharArray): ByteArray { + fun seal( + payload: JSONObject, + passphrase: CharArray, + ): ByteArray { val salt = ByteArray(SALT_LEN).also(SecureRandom()::nextBytes) val key = derive(passphrase, salt, ITERATIONS) val nonce = ByteArray(NONCE_LEN).also(SecureRandom()::nextBytes) val cipher = Cipher.getInstance("AES/GCM/NoPadding") cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(TAG_BITS, nonce)) val sealed = cipher.doFinal(payload.toString().toByteArray(Charsets.UTF_8)) - val envelope = JSONObject() - .put("magic", "fnhistory") - .put("v", 1) - .put("platform", "android") - .put("kdf", JSONObject() - .put("alg", "pbkdf2-sha256") - .put("salt", Bytes.b64(salt)) - .put("iters", ITERATIONS)) - .put("box", Bytes.b64(nonce + sealed)) + val envelope = + JSONObject() + .put("magic", "fnhistory") + .put("v", 1) + .put("platform", "android") + .put( + "kdf", + JSONObject() + .put("alg", "pbkdf2-sha256") + .put("salt", Bytes.b64(salt)) + .put("iters", ITERATIONS), + ).put("box", Bytes.b64(nonce + sealed)) return envelope.toString().toByteArray(Charsets.UTF_8) } - fun open(bytes: ByteArray, passphrase: CharArray): JSONObject { - val envelope = runCatching { JSONObject(String(bytes, Charsets.UTF_8)) } - .getOrNull() ?: throw NotAnExport() + fun open( + bytes: ByteArray, + passphrase: CharArray, + ): JSONObject { + val envelope = + runCatching { JSONObject(String(bytes, Charsets.UTF_8)) } + .getOrNull() ?: throw NotAnExport() val kdf = envelope.optJSONObject("kdf") if (envelope.optString("magic") != "fnhistory" || envelope.optInt("v") != 1 || kdf == null || kdf.optString("alg") != "pbkdf2-sha256" - ) throw NotAnExport() + ) { + throw NotAnExport() + } val platform = envelope.optString("platform") if (platform != "android") throw WrongPlatform(platform) val salt = runCatching { Bytes.unb64(kdf.getString("salt")) }.getOrNull() @@ -78,16 +78,25 @@ object HistoryExport { val key = derive(passphrase, salt, iters) val cipher = Cipher.getInstance("AES/GCM/NoPadding") cipher.init( - Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), - GCMParameterSpec(TAG_BITS, combined.copyOfRange(0, NONCE_LEN))) - val plain = runCatching { - cipher.doFinal(combined.copyOfRange(NONCE_LEN, combined.size)) - }.getOrNull() ?: throw WrongPassphrase() + Cipher.DECRYPT_MODE, + SecretKeySpec(key, "AES"), + GCMParameterSpec(TAG_BITS, combined.copyOfRange(0, NONCE_LEN)), + ) + val plain = + runCatching { + cipher.doFinal(combined.copyOfRange(NONCE_LEN, combined.size)) + }.getOrNull() ?: throw WrongPassphrase() return runCatching { JSONObject(String(plain, Charsets.UTF_8)) } .getOrNull() ?: throw NotAnExport() } - private fun derive(passphrase: CharArray, salt: ByteArray, iterations: Int): ByteArray = - SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256") - .generateSecret(PBEKeySpec(passphrase, salt, iterations, 256)).encoded + private fun derive( + passphrase: CharArray, + salt: ByteArray, + iterations: Int, + ): ByteArray = + SecretKeyFactory + .getInstance("PBKDF2WithHmacSHA256") + .generateSecret(PBEKeySpec(passphrase, salt, iterations, 256)) + .encoded } diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Hkdf.kt b/engine/src/main/kotlin/io/flamenet/e2e/Hkdf.kt index 19a5e72..0dde26f 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/Hkdf.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/Hkdf.kt @@ -5,28 +5,19 @@ import javax.crypto.spec.SecretKeySpec /** * HKDF-SHA256 (RFC 5869). - * - * Hand-written on top of [Mac] rather than taken from a provider, because - * Android's providers disagree about whether HKDF exists at all: it is absent - * from the platform on older API levels and available under different names - * elsewhere. HMAC-SHA256 is present everywhere this will ever run, and extract - * plus expand is twenty lines on top of it. - * - * Checked against RFC 5869's own test vectors rather than against the - * JavaScript engine. That matters: an implementation validated only against our - * other implementation inherits our misunderstandings, and the entire reason - * this third engine is being written is to find the places where the other two - * quietly agreed on something the specification does not say. */ object Hkdf { - private const val HASH_LEN = 32 /** - * Extract-then-expand. [salt] may be empty, which RFC 5869 defines as a - * string of HashLen zeros, not as "skip the extract step". + * Extract-then-expand. */ - fun derive(ikm: ByteArray, salt: ByteArray, info: ByteArray, length: Int): ByteArray { + fun derive( + ikm: ByteArray, + salt: ByteArray, + info: ByteArray, + length: Int, + ): ByteArray { require(length > 0) { "HKDF length must be positive" } // 255 * HashLen is the hard ceiling in the RFC: the counter is one byte // and starts at 1, so asking for more silently wraps in a naive @@ -35,12 +26,19 @@ object Hkdf { return expand(extract(ikm, salt), info, length) } - fun extract(ikm: ByteArray, salt: ByteArray): ByteArray { + fun extract( + ikm: ByteArray, + salt: ByteArray, + ): ByteArray { val effectiveSalt = if (salt.isEmpty()) ByteArray(HASH_LEN) else salt return hmac(effectiveSalt, ikm) } - fun expand(prk: ByteArray, info: ByteArray, length: Int): ByteArray { + fun expand( + prk: ByteArray, + info: ByteArray, + length: Int, + ): ByteArray { val out = ByteArray(length) var previous = ByteArray(0) var offset = 0 @@ -56,7 +54,10 @@ object Hkdf { return out } - private fun hmac(key: ByteArray, data: ByteArray): ByteArray { + private fun hmac( + key: ByteArray, + data: ByteArray, + ): ByteArray { val mac = Mac.getInstance("HmacSHA256") // An all-zero key is legal input here (RFC 5869 salt), and // SecretKeySpec rejects a zero-LENGTH key rather than a zero-valued diff --git a/engine/src/main/kotlin/io/flamenet/e2e/KeyLog.kt b/engine/src/main/kotlin/io/flamenet/e2e/KeyLog.kt index 5839119..abb162f 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/KeyLog.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/KeyLog.kt @@ -2,28 +2,18 @@ package io.flamenet.e2e /** * Key transparency verification (spec §19), client side. - * - * Verification only, deliberately. There is nothing here that can *build* a - * tree, because a client able to construct a proof is a client that can be - * talked into believing one it constructed itself. - * - * A transparency log without a client that refuses unproven keys is just a - * log: it faithfully records the substitution, and nobody reads it. */ object KeyLog { - /** RFC 6962 domain separation: leaves under 0x00, interior nodes under 0x01. */ fun leafHash(data: ByteArray): ByteArray = Bytes.sha256(Bytes.concat(byteArrayOf(0x00), data)) - fun nodeHash(left: ByteArray, right: ByteArray): ByteArray = - Bytes.sha256(Bytes.concat(byteArrayOf(0x01), left, right)) + fun nodeHash( + left: ByteArray, + right: ByteArray, + ): ByteArray = Bytes.sha256(Bytes.concat(byteArrayOf(0x01), left, right)) /** * The largest power of two strictly below [n]. - * - * Not n/2. The unbalanced split is what makes the tree's shape depend only - * on its size, which is what lets two verifiers agree without exchanging - * anything about its structure. */ fun split(n: Int): Int { var k = 1 @@ -33,10 +23,6 @@ object KeyLog { /** * The exact bytes the relay commits to for one key publication (§19.2). - * - * This binding is the part that matters. Proving that *some* leaf sits in - * the log proves nothing at all; what has to hold is that the leaf at the - * claimed index is this device's published identity and no other. */ fun canonicalLeaf( kind: String, @@ -49,25 +35,15 @@ object KeyLog { /** * The subject a leaf names, the account's fnid where one has been * published, and the relay-qualified row number where it has not. - * - * Shared rather than rebuilt per call site, so the three engines cannot - * drift on the fallback format. */ - fun logSubject(fnid: String?, audience: String, userId: Long): String = - if (!fnid.isNullOrEmpty()) fnid else "local:$audience/$userId" + fun logSubject( + fnid: String?, + audience: String, + userId: Long, + ): String = if (!fnid.isNullOrEmpty()) fnid else "local:$audience/$userId" /** * Recompute the root from a leaf and its audit path. - * - * The path is consumed bottom-up, the leaf's own sibling first, the - * highest sibling last. That is the order RFC 6962 specifies and the order - * the relay emits, and getting it backwards is not a loud failure: a - * top-down reading produces the correct root for any left-spine index, so - * it passes any test that only ever checks index 0. The vector suite pins a - * right-descending index precisely to catch that. - * - * Returns null rather than throwing: a bad proof is an expected answer from - * a hostile server, not an exceptional condition. */ fun rootFromInclusion( leaf: ByteArray, @@ -84,7 +60,10 @@ object KeyLog { if (i >= proof.size) return null if ((fn and 1) != 0 || fn == sn) { r = nodeHash(proof[i++], r) - while (fn != 0 && (fn and 1) == 0) { fn = fn shr 1; sn = sn shr 1 } + while (fn != 0 && (fn and 1) == 0) { + fn = fn shr 1 + sn = sn shr 1 + } } else { r = nodeHash(r, proof[i++]) } @@ -107,37 +86,19 @@ object KeyLog { } /** - * Prove the log of size `n` still contains everything the log of size `m` did. - * - * Inclusion alone is not enough, and this is the half that says so. - * A relay that rewrites history can reissue perfectly valid inclusion - * proofs against its new tree, every one of them verifies, because they - * are honest proofs about a dishonest log. What it cannot do is produce a - * consistency proof between the tree a client already pinned and the tree - * it would rather serve, because no such proof exists. Checking only - * inclusion buys nothing against the attacker this is for. - * - * A faithful port of `engine/src/keylog.js`, which is normative, and - * checked against it by the interop harness. The traversal is subtle - * enough that a re-derivation is a liability: a version that accepts too - * much passes every honest test and fails only against an attacker. - * - * When `m` is a power of two the first node is the old root itself and - * is not transmitted, the proof begins one node later. Missing that - * yields a verifier that rejects honest proofs at exactly the sizes a log - * spends most of its life at. + * Prove the log of size `n` still contains everything the log of size `m` + * did. */ + /** * Verify the relay's signature over a log head (§19.3, audit M1). - * - * The relay signs `fnkt-v1||` with its issuer key. Without - * checking it, a head is an unauthenticated pair of numbers and a fork - * caught by gossip is one party's word rather than something bound to the - * relay's own key. */ - fun verifyHead(size: Int, rootB64: String, signature: ByteArray, - issuerKey: ByteArray): Boolean = - Ed25519.verify(issuerKey, signature, "fnkt-v1|$size|$rootB64".toByteArray(Charsets.UTF_8)) + fun verifyHead( + size: Int, + rootB64: String, + signature: ByteArray, + issuerKey: ByteArray, + ): Boolean = Ed25519.verify(issuerKey, signature, "fnkt-v1|$size|$rootB64".toByteArray(Charsets.UTF_8)) fun verifyConsistency( from: Int, @@ -151,16 +112,23 @@ object KeyLog { var fn = from - 1 var sn = to - 1 - while (fn and 1 == 1) { fn = fn shr 1; sn = sn shr 1 } + while (fn and 1 == 1) { + fn = fn shr 1 + sn = sn shr 1 + } if (proof.isEmpty()) return false var fr: ByteArray var sr: ByteArray val rest: List if (fn == 0) { - fr = oldRoot; sr = oldRoot; rest = proof + fr = oldRoot + sr = oldRoot + rest = proof } else { - fr = proof[0]; sr = proof[0]; rest = proof.drop(1) + fr = proof[0] + sr = proof[0] + rest = proof.drop(1) } for (c in rest) { @@ -168,7 +136,10 @@ object KeyLog { if (fn and 1 == 1 || fn == sn) { fr = nodeHash(c, fr) sr = nodeHash(c, sr) - while (fn != 0 && fn and 1 == 0) { fn = fn shr 1; sn = sn shr 1 } + while (fn != 0 && fn and 1 == 0) { + fn = fn shr 1 + sn = sn shr 1 + } } else { sr = nodeHash(sr, c) } diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Label.kt b/engine/src/main/kotlin/io/flamenet/e2e/Label.kt index 6ee5a30..b5dfd39 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/Label.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/Label.kt @@ -1,19 +1,7 @@ package io.flamenet.e2e /** - * The security lattice. Mirrors §1 of the published Files specification. - * - * The ten-byte encoding is authenticated data for every construction that - * uses it, so a label that encodes differently on two platforms does not - * produce a warning, it produces a decryption failure, or worse, plaintext - * that quietly belongs to the wrong sensitivity. That is why [bytes] is pinned - * to a vector rather than left to look obviously right. - * - * Two dimensions, and both are required. A classification alone is a - * ladder: anything high enough reads anything lower. Compartments cut across - * it, "Secret, and only if you are in this area", and the interesting failure - * is an implementation that checks the level and forgets the areas, which reads - * as working right up until somebody sees a compartment they are not in. + * The security lattice. */ data class Label( val classification: Short, @@ -29,20 +17,11 @@ data class Label( /** * `this ⊒ other`: at least as high, AND a superset of areas. - * - * Both, always. Dropping the compartment half turns the lattice back - * into a ladder and silently widens every read. */ - fun dominates(other: Label): Boolean = - classification >= other.classification && compartments.containsAll(other.compartments) + fun dominates(other: Label): Boolean = classification >= other.classification && compartments.containsAll(other.compartments) /** * `int16be classification || uint64be compartment mask`. - * - * Big-endian, and stated because the JVM's own `ByteBuffer` default - * happens to agree while Kotlin's shifts do not care, writing this with - * `toByte()` on an unshifted value would produce little-endian bytes that - * still look plausible in a debugger and decrypt nothing. */ fun bytes(): ByteArray { val out = ByteArray(10) @@ -63,39 +42,20 @@ data class Label( /** * What one person is allowed to reach. - * - * Both bounds, always. A label must sit INSIDE the range, not merely - * below the top of it. The minimum is what stops somebody cleared for sensitive - * work from also holding the unclassified pile, which sounds like a - * convenience to remove until it is the reason an audit can say who could have - * seen what. */ -data class Clearance(val maximum: Label, val minimum: Label = Label.bottom) { - - fun permits(label: Label): Boolean = - maximum.dominates(label) && label.dominates(minimum) +data class Clearance( + val maximum: Label, + val minimum: Label = Label.bottom, +) { + fun permits(label: Label): Boolean = maximum.dominates(label) && label.dominates(minimum) /** - * No read up. Bell–LaPadula's simple security property: you cannot - * read what is above you. This is the one people expect. + * No read up. */ fun mayRead(label: Label): Boolean = permits(label) /** * Whether a write to [label] is allowed. - * - * The SAME check as reading, and that is a deliberate match with iOS - * rather than an oversight. `FilesSession.ensureLabelKey` and `use` both - * gate on `clearance.permits`, so the range is the whole rule on both - * platforms. - * - * Bell–LaPadula's -property is therefore NOT enforced. The model - * says somebody cleared to read sensitive material must not write *below* - * their clearance, because they would carry what they read down with them. - * Neither client implements that. Adding it on Android alone would mean a - * file that saves on one phone and is refused on the other, with no way for - * either person to tell which rule they had met, a worse outcome than the - * gap. Filed for both so it is decided once: android#15 / ios#65. */ fun mayWrite(label: Label): Boolean = permits(label) } diff --git a/engine/src/main/kotlin/io/flamenet/e2e/MessageSearch.kt b/engine/src/main/kotlin/io/flamenet/e2e/MessageSearch.kt index e66d8fb..59bfa2d 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/MessageSearch.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/MessageSearch.kt @@ -4,47 +4,15 @@ import org.json.JSONObject /** * Finding a word somebody said, across every conversation on this device. - * - * There is no index, and that is a decision rather than an omission. The - * obvious design builds a search index over the decrypted history, and an - * index derived from plaintext is plaintext by another name, so it would have - * to be sealed as carefully as the history itself, purged with it, and kept - * consistent with it. That is a second copy of everything anybody ever wrote, - * with its own ways to go wrong. - * - * Instead this reads the threads that already exist, through the vault, and - * throws the plaintext away when it is done. Nothing new comes to rest, so - * there is nothing new to protect. - * - * This costs more here than on iOS, and the difference is worth naming. - * The iPhone app keeps the whole decrypted history in memory, so its search is - * a scan of what is already there. Android reads and decrypts each thread on - * demand, so a search touches the vault once per conversation. That is why this - * takes a minimum query length and a bound, and why callers must run it off the - * main thread. If it ever becomes too slow, an index is the answer, and then it - * must be sealed with the vault and taken by `forgetEverything`, or the app will - * have quietly acquired an unencrypted transcript. - * - * The query itself is never persisted. What somebody searched for is - * often more revealing than what they found. */ object MessageSearch { - /** * How many hits are built at once. - * - * A bound, not a preference. A short query matches most of a long - * history, and a list of thousands of rows is a spinner that never ends for - * a result nobody reads past the first screen of. */ const val LIMIT = 60 /** * Below this, a search is not run at all. - * - * Not a UI nicety. One character matches nearly every message, so the - * work is maximal and the result is useless, and unlike iOS, that work - * means decrypting every thread on the device. */ const val MINIMUM_QUERY = 2 @@ -62,15 +30,13 @@ object MessageSearch { /** * Search every thread the vault holds. - * - * Blocking and disk-bound. The caller is responsible for keeping it off - * the main thread. */ - fun hits(query: String, vault: Vault, limit: Int = LIMIT): List { - // An empty query finds NOTHING, never everything. Returning the whole - // history the moment somebody focuses the search box would put every - // message they have on one screen, the place most likely to be read - // over a shoulder. + fun hits( + query: String, + vault: Vault, + limit: Int = LIMIT, + ): List { + // An empty query finds NOTHING, never everything. val needle = query.trim() if (needle.length < MINIMUM_QUERY) return emptyList() @@ -81,13 +47,14 @@ object MessageSearch { val m: JSONObject = stored.getJSONObject(i) val text = m.optString("text") if (!matches(text, needle)) continue - found += Hit( - threadId = threadId, - text = text, - snippet = snippet(text, needle), - at = m.optLong("at", 0L), - mine = m.optBoolean("mine", false), - ) + found += + Hit( + threadId = threadId, + text = text, + snippet = snippet(text, needle), + at = m.optLong("at", 0L), + mine = m.optBoolean("mine", false), + ) } } // Newest first: what somebody is looking for is far more often recent @@ -96,27 +63,23 @@ object MessageSearch { } /** - * Case- AND accent-insensitive. Somebody typing `cafe` means `café`, and - * a search that knows the difference quietly fails for every language with - * accents in it, which is most of them, and this app ships in Spanish next. - * - * A plain substring, never a regular expression. The needle is typed by - * a person: a stray bracket is a crash or a hang, and nobody typing into a - * search box intended a pattern. + * Case- AND accent-insensitive. */ - fun matches(haystack: String, needle: String): Boolean { + fun matches( + haystack: String, + needle: String, + ): Boolean { if (haystack.isEmpty() || needle.isEmpty()) return false return fold(haystack).contains(fold(needle)) } /** * The part of a message around the match, so a hit says why it is a hit. - * - * Bounded on both sides. A whole message means one long one pushes every - * other result off the screen; only the start means a match near the end - * looks like no match at all. */ - fun snippet(text: String, needle: String): String { + fun snippet( + text: String, + needle: String, + ): String { val at = fold(text).indexOf(fold(needle)) if (at < 0) return text.take(CONTEXT * 2) @@ -129,18 +92,16 @@ object MessageSearch { /** * Folding runs on both sides and must be length-preserving, because * [snippet] slices the ORIGINAL string at an index found in the folded one. - * A fold that dropped or added characters would cut the snippet in the - * wrong place, and for a combining accent it would cut mid-character. */ private fun fold(s: String): String { val normalised = java.text.Normalizer.normalize(s, java.text.Normalizer.Form.NFD) - val stripped = buildString(s.length) { - for (ch in normalised) { - // Combining marks only. Dropping them from the DECOMPOSED form - // leaves one character per original character. - if (Character.getType(ch) != Character.NON_SPACING_MARK.toInt()) append(ch) + val stripped = + buildString(s.length) { + for (ch in normalised) { + // Combining marks only. + if (Character.getType(ch) != Character.NON_SPACING_MARK.toInt()) append(ch) + } } - } return stripped.lowercase() } } diff --git a/engine/src/main/kotlin/io/flamenet/e2e/PQKEM.kt b/engine/src/main/kotlin/io/flamenet/e2e/PQKEM.kt index 5972a27..85a3f7e 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/PQKEM.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/PQKEM.kt @@ -11,40 +11,11 @@ import java.security.SecureRandom /** * ML-KEM-768 (FIPS 203) for the hybrid post-quantum key agreement, spec §12.2. - * - * ## Why this exists - * - * Traffic recorded today is decryptable tomorrow by anybody who eventually - * builds a quantum computer, because X25519 alone does not survive one. Mixing - * a KEM secret into the handshake means an adversary must break *both*, and a - * flaw later found in ML-KEM leaves a session no worse off than a purely - * classical one, which is why this is hybrid and never a replacement. - * - * Android had none of this, and that made the feature nobody's. A - * session is post-quantum only when both ends are, so one v1 client in a - * conversation makes the whole conversation v1, and with every Android device - * registering `proto: 1`, iPhone's implementation protected approximately no - * real conversation. This is the other half. - * - * ## The seed, not the expanded key - * - * Key material is the 64-byte FIPS 203 seed (`d ‖ z`), never the expanded - * decapsulation key. Both the private key and its public half are re-derived - * from it, so the seed is the only thing worth storing, 64 bytes in the vault - * instead of 2400, and the same representation iOS persists, which is what lets - * a history export move between the two. - * - * ## Where the implementation comes from - * - * Bouncy Castle's low-level `pqc.crypto.mlkem` classes, the same way [Ed25519] - * uses its low-level `rfc8032` class: not the JCA provider, which is awkward to - * register on Android and which R8 cannot shrink. The platform offers nothing - * here. Conscrypt has no ML-KEM at any API level, and hand-rolling a lattice - * KEM to avoid a dependency would be a far worse trade than the one Ed25519 - * already made. + * ## Why this exists Traffic recorded today is decryptable tomorrow by anybody + * who eventually builds a quantum computer, because X25519 alone does not + * survive one. */ object PQKEM { - /** Sizes are fixed by FIPS 203 for the 768 parameter set. */ const val SEED_LEN = 64 const val PUBLIC_KEY_LEN = 1184 @@ -54,36 +25,38 @@ object PQKEM { private val random = SecureRandom() /** - * Sizes are asserted rather than assumed. A wrong-length key can only ever - * fail on every peer, and it fails as an undecryptable message at the far - * end, so naming it here is the difference between a clear error and a - * contact who has silently stopped working. + * Sizes are asserted rather than assumed. */ - class Malformed(what: String, expected: Int, got: Int) : - IllegalArgumentException("ML-KEM $what should be $expected bytes, got $got") + class Malformed( + what: String, + expected: Int, + got: Int, + ) : IllegalArgumentException("ML-KEM $what should be $expected bytes, got $got") /** A keypair. Only [seed] needs persisting; [publicKey] is what is published. */ - data class KeyPair(val seed: ByteArray, val publicKey: ByteArray) { - override fun equals(other: Any?): Boolean = - other is KeyPair && seed.contentEquals(other.seed) + data class KeyPair( + val seed: ByteArray, + val publicKey: ByteArray, + ) { + override fun equals(other: Any?): Boolean = other is KeyPair && seed.contentEquals(other.seed) + override fun hashCode(): Int = seed.contentHashCode() } /** What an encapsulation produces: bytes for the wire, and a secret for the KDF. */ - data class Encapsulation(val ciphertext: ByteArray, val sharedSecret: ByteArray) { - override fun equals(other: Any?): Boolean = - other is Encapsulation && ciphertext.contentEquals(other.ciphertext) + data class Encapsulation( + val ciphertext: ByteArray, + val sharedSecret: ByteArray, + ) { + override fun equals(other: Any?): Boolean = other is Encapsulation && ciphertext.contentEquals(other.ciphertext) + override fun hashCode(): Int = ciphertext.contentHashCode() } fun generate(): KeyPair = fromSeed(ByteArray(SEED_LEN).also(random::nextBytes)) /** - * Re-derive a keypair from a stored seed. Used on every load. - * - * `d ‖ z` in FIPS 203 order, which is what iOS stores as CryptoKit's - * `seedRepresentation`. The two must agree byte for byte or a device that - * restored a history export would publish a key it cannot decapsulate with. + * Re-derive a keypair from a stored seed. */ fun fromSeed(seed: ByteArray): KeyPair { if (seed.size != SEED_LEN) throw Malformed("seed", SEED_LEN, seed.size) @@ -96,22 +69,18 @@ object PQKEM { if (publicKey.size != PUBLIC_KEY_LEN) { throw Malformed("public key", PUBLIC_KEY_LEN, publicKey.size) } - val out = MLKEMGenerator(random).generateEncapsulated( - MLKEMPublicKeyParameters(MLKEMParameters.ml_kem_768, publicKey)) + val out = + MLKEMGenerator(random).generateEncapsulated(MLKEMPublicKeyParameters(MLKEMParameters.ml_kem_768, publicKey)) return Encapsulation(out.encapsulation, out.secret) } /** * Decapsulate with a stored seed. - * - * This never fails on a substituted ciphertext. ML-KEM's implicit - * rejection returns a well-formed but different secret rather than an - * error, by design, so a tampered ciphertext surfaces as the first message - * failing to decrypt, not as an exception here. That is why §12.5 binds the - * ciphertext into the session's associated data as well: the mismatch - * becomes explicit and permanent instead of being discovered once. */ - fun decapsulate(ciphertext: ByteArray, seed: ByteArray): ByteArray { + fun decapsulate( + ciphertext: ByteArray, + seed: ByteArray, + ): ByteArray { if (ciphertext.size != CIPHERTEXT_LEN) { throw Malformed("ciphertext", CIPHERTEXT_LEN, ciphertext.size) } @@ -123,11 +92,6 @@ object PQKEM { /** * A keypair from the library's own generator, for cross-checking that the * seed path above produces the same thing the reference construction does. - * - * Test-facing. Kept here rather than in the test so it uses the same - * parameter set as everything else in this file, a cross-check that - * silently compared two different parameter sets would pass and mean - * nothing. */ internal fun generateWithLibrary(): Pair { val gen = MLKEMKeyPairGenerator() diff --git a/engine/src/main/kotlin/io/flamenet/e2e/QrCode.kt b/engine/src/main/kotlin/io/flamenet/e2e/QrCode.kt index 9d5105e..4272f4a 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/QrCode.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/QrCode.kt @@ -2,41 +2,15 @@ package io.flamenet.e2e /** * A QR code, so an address can be shown to another phone. - * - * Written rather than pulled in. Android has no QR *encoder* in the - * platform, `CIFilter.qrCodeGenerator` is an Apple API with no counterpart, - * and the usual answer is a third-party library. This is ~250 lines of a fully - * specified algorithm (ISO/IEC 18004) for one fixed job: turn one short ASCII - * address into a bitmap. A dependency for that is a supply chain, an update - * treadmill and a licence, in exchange for code nobody would otherwise read. - * - * Byte mode, level M, versions 1-10, and no more. The input is always an - * address, `flamenet:fnid:<52 chars>@`, so alphanumeric and kanji - * modes would never fire, and level M is what the iOS side already uses: at H - * the module count grows enough that the code stops scanning from a phone held - * at arm's length, which is the only way anyone will use this. Version 10 at M - * holds 216 bytes, roughly three times the longest address anyone can have. - * Anything larger throws rather than silently producing a code that will not - * scan. - * - * Verified by decoding, not by inspection. A QR encoder that is subtly - * wrong still produces a plausible-looking square of dots; nothing about - * looking at it tells you whether a scanner will read it. `QrCodeTest` renders - * these and decodes them with an implementation that shares no code with this - * one. */ object QrCode { - /** Level M. The other levels are not implemented; see the class note. */ private const val EC_LEVEL_BITS = 0b00 /** * Per version (index 1..10): error-correction codewords per block, then the - * block layout as (count, data codewords) pairs for group one and group two. - * - * Straight from the standard's table. A single wrong number here produces a - * code that is structurally valid and unreadable, which is why the test - * decodes every version rather than checking sizes. + * block layout as (count, data codewords) pairs for group one and group + * two. */ private data class Spec( val ecPerBlock: Int, @@ -49,64 +23,79 @@ object QrCode { val totalBlocks: Int get() = group1Blocks + group2Blocks } - private val SPECS = mapOf( - 1 to Spec(10, 1, 16), - 2 to Spec(16, 1, 28), - 3 to Spec(26, 1, 44), - 4 to Spec(18, 2, 32), - 5 to Spec(24, 2, 43), - 6 to Spec(16, 4, 27), - 7 to Spec(18, 4, 31), - 8 to Spec(22, 2, 38, 2, 39), - 9 to Spec(22, 3, 36, 2, 37), - 10 to Spec(26, 4, 43, 1, 44), - ) + private val SPECS = + mapOf( + 1 to Spec(10, 1, 16), + 2 to Spec(16, 1, 28), + 3 to Spec(26, 1, 44), + 4 to Spec(18, 2, 32), + 5 to Spec(24, 2, 43), + 6 to Spec(16, 4, 27), + 7 to Spec(18, 4, 31), + 8 to Spec(22, 2, 38, 2, 39), + 9 to Spec(22, 3, 36, 2, 37), + 10 to Spec(26, 4, 43, 1, 44), + ) /** Alignment-pattern centre coordinates per version. Version 1 has none. */ - private val ALIGNMENT = mapOf( - 1 to intArrayOf(), - 2 to intArrayOf(6, 18), - 3 to intArrayOf(6, 22), - 4 to intArrayOf(6, 26), - 5 to intArrayOf(6, 30), - 6 to intArrayOf(6, 34), - 7 to intArrayOf(6, 22, 38), - 8 to intArrayOf(6, 24, 42), - 9 to intArrayOf(6, 26, 46), - 10 to intArrayOf(6, 28, 50), - ) + private val ALIGNMENT = + mapOf( + 1 to intArrayOf(), + 2 to intArrayOf(6, 18), + 3 to intArrayOf(6, 22), + 4 to intArrayOf(6, 26), + 5 to intArrayOf(6, 30), + 6 to intArrayOf(6, 34), + 7 to intArrayOf(6, 22, 38), + 8 to intArrayOf(6, 24, 42), + 9 to intArrayOf(6, 26, 46), + 10 to intArrayOf(6, 28, 50), + ) /** * The finished code: `size` x `size` modules, true meaning dark. - * - * No quiet zone. The caller adds it when rendering, a QR without four - * modules of margin does not scan, and where that margin lives is a - * rendering decision, not an encoding one. */ - class Matrix(val size: Int) { + class Matrix( + val size: Int, + ) { private val dark = BooleanArray(size * size) private val reserved = BooleanArray(size * size) - operator fun get(row: Int, col: Int): Boolean = dark[row * size + col] + operator fun get( + row: Int, + col: Int, + ): Boolean = dark[row * size + col] - internal fun set(row: Int, col: Int, value: Boolean, reserve: Boolean = false) { + internal fun set( + row: Int, + col: Int, + value: Boolean, + reserve: Boolean = false, + ) { dark[row * size + col] = value if (reserve) reserved[row * size + col] = true } - internal fun isReserved(row: Int, col: Int): Boolean = reserved[row * size + col] - internal fun inBounds(row: Int, col: Int) = row in 0 until size && col in 0 until size + internal fun isReserved( + row: Int, + col: Int, + ): Boolean = reserved[row * size + col] + + internal fun inBounds( + row: Int, + col: Int, + ) = row in 0 until size && col in 0 until size } /** Encode [text] as UTF-8 in byte mode. */ fun encode(text: String): Matrix { val data = text.toByteArray(Charsets.UTF_8) - val version = SPECS.entries - .sortedBy { it.key } - .firstOrNull { data.size + headerBytes(it.key) <= it.value.dataCodewords } - ?.key - ?: throw IllegalArgumentException( - "${data.size} bytes will not fit in a version-10 QR code at level M") + val version = + SPECS.entries + .sortedBy { it.key } + .firstOrNull { data.size + headerBytes(it.key) <= it.value.dataCodewords } + ?.key + ?: throw IllegalArgumentException("${data.size} bytes will not fit in a version-10 QR code at level M") val spec = SPECS.getValue(version) val codewords = interleave(bitStream(data, version, spec), spec) @@ -117,9 +106,13 @@ object QrCode { private fun headerBytes(version: Int): Int = if (version < 10) 2 else 3 /** Mode nibble, length, payload, terminator, pad, as a codeword array. */ - private fun bitStream(data: ByteArray, version: Int, spec: Spec): IntArray { + private fun bitStream( + data: ByteArray, + version: Int, + spec: Spec, + ): IntArray { val bits = BitBuffer() - bits.append(0b0100, 4) // byte mode + bits.append(0b0100, 4) // byte mode bits.append(data.size, if (version < 10) 8 else 16) for (b in data) bits.append(b.toInt() and 0xFF, 8) @@ -138,16 +131,19 @@ object QrCode { /** * Split into blocks, compute each block's error correction, and interleave. - * - * The interleave is what makes a QR survive a smudge: consecutive - * codewords of one block end up far apart on the symbol, so damage that - * would destroy one block is spread across all of them. */ - private fun interleave(dataCodewords: IntArray, spec: Spec): IntArray { + private fun interleave( + dataCodewords: IntArray, + spec: Spec, + ): IntArray { val blocks = mutableListOf() val ecBlocks = mutableListOf() var offset = 0 - fun take(count: Int, size: Int) { + + fun take( + count: Int, + size: Int, + ) { repeat(count) { val block = dataCodewords.copyOfRange(offset, offset + size) offset += size @@ -169,19 +165,30 @@ object QrCode { return out.toIntArray() } - private fun draw(version: Int, codewords: IntArray): Matrix { + private fun draw( + version: Int, + codewords: IntArray, + ): Matrix { val size = 17 + version * 4 val m = Matrix(size) - fun finder(row: Int, col: Int) { - for (r in -1..7) for (c in -1..7) { - if (!m.inBounds(row + r, col + c)) continue - val onRing = r in 0..6 && c in 0..6 && - (r == 0 || r == 6 || c == 0 || c == 6 || (r in 2..4 && c in 2..4)) - m.set(row + r, col + c, onRing, reserve = true) + fun finder( + row: Int, + col: Int, + ) { + for (r in -1..7) { + for (c in -1..7) { + if (!m.inBounds(row + r, col + c)) continue + val onRing = + r in 0..6 && c in 0..6 && + (r == 0 || r == 6 || c == 0 || c == 6 || (r in 2..4 && c in 2..4)) + m.set(row + r, col + c, onRing, reserve = true) + } } } - finder(0, 0); finder(0, size - 7); finder(size - 7, 0) + finder(0, 0) + finder(0, size - 7) + finder(size - 7, 0) // Timing patterns, which give a scanner the module pitch. for (i in 8 until size - 8) { @@ -190,19 +197,24 @@ object QrCode { } val centres = ALIGNMENT.getValue(version) - for (r in centres) for (c in centres) { - // Not over a finder. The three corners already carry one. - if ((r <= 8 && c <= 8) || (r <= 8 && c >= size - 9) || (r >= size - 9 && c <= 8)) continue - for (dr in -2..2) for (dc in -2..2) { - val onRing = dr == -2 || dr == 2 || dc == -2 || dc == 2 || (dr == 0 && dc == 0) - m.set(r + dr, c + dc, onRing, reserve = true) + for (r in centres) { + for (c in centres) { + // Not over a finder. + if ((r <= 8 && c <= 8) || (r <= 8 && c >= size - 9) || (r >= size - 9 && c <= 8)) continue + for (dr in -2..2) { + for (dc in -2..2) { + val onRing = dr == -2 || dr == 2 || dc == -2 || dc == 2 || (dr == 0 && dc == 0) + m.set(r + dr, c + dc, onRing, reserve = true) + } + } } } - // The dark module. Always set, always here. + // The dark module. m.set(size - 8, 8, true, reserve = true) - // Reserve the format areas before data placement, or data lands in them. + // Reserve the format areas before data placement, or data lands in + // them. for (i in 0..8) { if (!m.isReserved(8, i)) m.set(8, i, false, reserve = true) if (!m.isReserved(i, 8)) m.set(i, 8, false, reserve = true) @@ -212,9 +224,11 @@ object QrCode { if (!m.isReserved(size - 1 - i, 8)) m.set(size - 1 - i, 8, false, reserve = true) } if (version >= 7) { - for (i in 0..5) for (j in 0..2) { - m.set(size - 11 + j, i, false, reserve = true) - m.set(i, size - 11 + j, false, reserve = true) + for (i in 0..5) { + for (j in 0..2) { + m.set(size - 11 + j, i, false, reserve = true) + m.set(i, size - 11 + j, false, reserve = true) + } } } @@ -230,20 +244,26 @@ object QrCode { * The zigzag: two columns at a time, right to left, alternating direction, * skipping the vertical timing column. */ - private fun placeData(m: Matrix, codewords: IntArray) { + private fun placeData( + m: Matrix, + codewords: IntArray, + ) { var bit = 0 val total = codewords.size * 8 var col = m.size - 1 var upward = true while (col > 0) { - if (col == 6) col-- // the timing column is not data + if (col == 6) col-- // the timing column is not data val rows = if (upward) (m.size - 1) downTo 0 else 0 until m.size for (row in rows) { for (c in intArrayOf(col, col - 1)) { if (m.isReserved(row, c)) continue - val value = if (bit < total) { - (codewords[bit / 8] shr (7 - bit % 8)) and 1 == 1 - } else false + val value = + if (bit < total) { + (codewords[bit / 8] shr (7 - bit % 8)) and 1 == 1 + } else { + false + } m.set(row, c, value) bit++ } @@ -253,42 +273,61 @@ object QrCode { } } - private fun maskAt(mask: Int, row: Int, col: Int): Boolean = when (mask) { - 0 -> (row + col) % 2 == 0 - 1 -> row % 2 == 0 - 2 -> col % 3 == 0 - 3 -> (row + col) % 3 == 0 - 4 -> (row / 2 + col / 3) % 2 == 0 - 5 -> (row * col) % 2 + (row * col) % 3 == 0 - 6 -> ((row * col) % 2 + (row * col) % 3) % 2 == 0 - else -> ((row + col) % 2 + (row * col) % 3) % 2 == 0 - } + private fun maskAt( + mask: Int, + row: Int, + col: Int, + ): Boolean = + when (mask) { + 0 -> (row + col) % 2 == 0 + 1 -> row % 2 == 0 + 2 -> col % 3 == 0 + 3 -> (row + col) % 3 == 0 + 4 -> (row / 2 + col / 3) % 2 == 0 + 5 -> (row * col) % 2 + (row * col) % 3 == 0 + 6 -> ((row * col) % 2 + (row * col) % 3) % 2 == 0 + else -> ((row + col) % 2 + (row * col) % 3) % 2 == 0 + } - private fun applyMask(m: Matrix, size: Int, mask: Int) { - for (row in 0 until size) for (col in 0 until size) { - if (m.isReserved(row, col)) continue - if (maskAt(mask, row, col)) m.set(row, col, !m[row, col]) + private fun applyMask( + m: Matrix, + size: Int, + mask: Int, + ) { + for (row in 0 until size) { + for (col in 0 until size) { + if (m.isReserved(row, col)) continue + if (maskAt(mask, row, col)) m.set(row, col, !m[row, col]) + } } } /** * Try all eight masks and keep the least penalised, as the standard - * requires. A mask is not cosmetic: the wrong one leaves runs that a - * scanner mistakes for finder patterns. + * requires. */ - private fun chooseMask(m: Matrix, size: Int): Int { + private fun chooseMask( + m: Matrix, + size: Int, + ): Int { var best = 0 var bestPenalty = Int.MAX_VALUE for (mask in 0..7) { applyMask(m, size, mask) val penalty = penalty(m, size) - applyMask(m, size, mask) // its own inverse - if (penalty < bestPenalty) { bestPenalty = penalty; best = mask } + applyMask(m, size, mask) // its own inverse + if (penalty < bestPenalty) { + bestPenalty = penalty + best = mask + } } return best } - private fun penalty(m: Matrix, size: Int): Int { + private fun penalty( + m: Matrix, + size: Int, + ): Int { var score = 0 // Rule 1: runs of five or more of one colour. @@ -298,27 +337,42 @@ object QrCode { for (j in 1 until size) { val a = if (transposed) m[j, i] else m[i, j] val b = if (transposed) m[j - 1, i] else m[i, j - 1] - if (a == b) run++ else { if (run >= 5) score += run - 2; run = 1 } + if (a == b) { + run++ + } else { + if (run >= 5) score += run - 2 + run = 1 + } } if (run >= 5) score += run - 2 } } // Rule 2: solid 2x2 blocks. - for (r in 0 until size - 1) for (c in 0 until size - 1) { - val v = m[r, c] - if (v == m[r, c + 1] && v == m[r + 1, c] && v == m[r + 1, c + 1]) score += 3 + for (r in 0 until size - 1) { + for (c in 0 until size - 1) { + val v = m[r, c] + if (v == m[r, c + 1] && v == m[r + 1, c] && v == m[r + 1, c + 1]) score += 3 + } } // Rule 3: the finder-like 1:1:3:1:1 sequence with four light modules. val a = booleanArrayOf(true, false, true, true, true, false, true, false, false, false, false) val b = a.reversedArray() - for (r in 0 until size) for (c in 0 until size) { - for (pattern in listOf(a, b)) { - if (c + pattern.size <= size && - (pattern.indices).all { m[r, c + it] == pattern[it] }) score += 40 - if (r + pattern.size <= size && - (pattern.indices).all { m[r + it, c] == pattern[it] }) score += 40 + for (r in 0 until size) { + for (c in 0 until size) { + for (pattern in listOf(a, b)) { + if (c + pattern.size <= size && + (pattern.indices).all { m[r, c + it] == pattern[it] } + ) { + score += 40 + } + if (r + pattern.size <= size && + (pattern.indices).all { m[r + it, c] == pattern[it] } + ) { + score += 40 + } + } } } @@ -331,7 +385,11 @@ object QrCode { } /** BCH(15,5) with the standard generator, then the fixed XOR mask. */ - private fun writeFormat(m: Matrix, size: Int, mask: Int) { + private fun writeFormat( + m: Matrix, + size: Int, + mask: Int, + ) { val data = (EC_LEVEL_BITS shl 3) or mask var rem = data shl 10 repeat(10) { @@ -340,16 +398,11 @@ object QrCode { val bits = ((data shl 10) or (rem and 0x3FF)) xor 0b101010000010010 // Most significant bit FIRST, and this is the whole ballgame. - // Written the other way round the symbol is structurally perfect, the - // finders, the timing, the alignment, the data and the mask were all - // verified correct against a reference symbol, and NOTHING can read - // it, because the fifteen bits that tell a scanner the level and the - // mask are backwards. It fails silently and identically at every - // version, which reads as "our encoder is broken" with no clue where. - // Established by reading Apple's generator at all four levels and - // matching the standard's L=01 M=00 Q=11 H=10 (only this order does). - fun place(index: Int, row: Int, col: Int) = - m.set(row, col, (bits shr (14 - index)) and 1 == 1, reserve = true) + fun place( + index: Int, + row: Int, + col: Int, + ) = m.set(row, col, (bits shr (14 - index)) and 1 == 1, reserve = true) // First copy: around the top-left finder. for (i in 0..5) place(i, 8, i) @@ -364,7 +417,11 @@ object QrCode { } /** BCH(18,6), for versions 7 and up. */ - private fun writeVersion(m: Matrix, size: Int, version: Int) { + private fun writeVersion( + m: Matrix, + size: Int, + version: Int, + ) { var rem = version shl 12 repeat(12) { if (rem shr (17 - it) and 1 == 1) rem = rem xor (0b1111100100101 shl (5 - it)) @@ -381,9 +438,14 @@ object QrCode { private class BitBuffer { private val bits = mutableListOf() val size: Int get() = bits.size - fun append(value: Int, count: Int) { + + fun append( + value: Int, + count: Int, + ) { for (i in count - 1 downTo 0) bits += (value shr i) and 1 } + fun toCodewords(): IntArray { val out = IntArray(bits.size / 8) for (i in bits.indices) { @@ -396,13 +458,8 @@ object QrCode { /** * Reed-Solomon over GF(256) with the QR primitive polynomial, 0x11D. - * - * Only the encoder half: the remainder of the message polynomial divided by the - * generator, which is what the error-correction codewords are. Decoding is the - * scanner's problem. */ internal object ReedSolomon { - private val EXP = IntArray(512) private val LOG = IntArray(256) @@ -417,8 +474,10 @@ internal object ReedSolomon { for (i in 255 until 512) EXP[i] = EXP[i - 255] } - private fun mul(a: Int, b: Int): Int = - if (a == 0 || b == 0) 0 else EXP[LOG[a] + LOG[b]] + private fun mul( + a: Int, + b: Int, + ): Int = if (a == 0 || b == 0) 0 else EXP[LOG[a] + LOG[b]] /** The generator polynomial for `degree` error-correction codewords. */ private fun generator(degree: Int): IntArray { @@ -434,7 +493,10 @@ internal object ReedSolomon { return poly } - fun remainder(data: IntArray, ecCount: Int): IntArray { + fun remainder( + data: IntArray, + ecCount: Int, + ): IntArray { val gen = generator(ecCount) val result = IntArray(data.size + ecCount) data.copyInto(result) diff --git a/engine/src/main/kotlin/io/flamenet/e2e/SafetyNumber.kt b/engine/src/main/kotlin/io/flamenet/e2e/SafetyNumber.kt index 4130d45..4e378cf 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/SafetyNumber.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/SafetyNumber.kt @@ -4,24 +4,13 @@ import java.math.BigInteger /** * Safety numbers (spec §7). - * - * The one check in the whole system that does not depend on trusting the - * server: two people read the same sixty digits aloud, and if they match there - * is nobody in the middle. Everything else here can be verified by machines; - * this is verified by humans, which is why the output is grouped for reading - * rather than rendered as hex. */ object SafetyNumber { - /** 5200 iterations, matching the reference engine. */ private const val ITERATIONS = 5200 /** * Thirty digits for one identity key. - * - * The iteration count is the point: it makes searching for a key whose - * fingerprint collides in the digits a person actually compares expensive - * rather than instant. */ fun fingerprint(publicKey: ByteArray): String { var h = publicKey @@ -44,47 +33,47 @@ object SafetyNumber { /** * The sixty digits for a conversation. - * - * Ordered by the keys themselves rather than by who is asking, so both ends - * read out the same string. Unsigned comparison, see [Bytes.compareUnsigned]. */ - fun compute(localIdentity: ByteArray, remoteIdentity: ByteArray): String { + fun compute( + localIdentity: ByteArray, + remoteIdentity: ByteArray, + ): String { val local = fingerprint(localIdentity) val remote = fingerprint(remoteIdentity) - return if (Bytes.compareUnsigned(localIdentity, remoteIdentity) < 0) local + remote - else remote + local + return if (Bytes.compareUnsigned(localIdentity, remoteIdentity) < 0) { + local + remote + } else { + remote + local + } } /** * The same number, covering the post-quantum identity as well (audit H3). - * - * The number is the one check that does not trust the relay, and it - * covered only the Ed25519 key. An adversary able to forge Ed25519, the - * exact adversary the ML-DSA identity exists to stop, could keep the real - * signing key so the digits still matched, while substituting the PQ - * identity at first contact. Folding it in makes that swap visible. - * - * Only when BOTH sides have one; otherwise the classical number stands, - * because changing it for classical pairs would invalidate every number - * already compared for no gain. Must stay byte-identical to iOS - * `SafetyNumber.compute(localIK:localPQ:remoteIK:remotePQ:)`. */ - fun compute(localIdentity: ByteArray, localPQ: ByteArray?, - remoteIdentity: ByteArray, remotePQ: ByteArray?): String { + fun compute( + localIdentity: ByteArray, + localPQ: ByteArray?, + remoteIdentity: ByteArray, + remotePQ: ByteArray?, + ): String { if (localPQ == null || remotePQ == null || localPQ.isEmpty() || remotePQ.isEmpty()) { return compute(localIdentity, remoteIdentity) } val local = fingerprint(bind(localIdentity, localPQ)) val remote = fingerprint(bind(remoteIdentity, remotePQ)) - return if (Bytes.compareUnsigned(localIdentity, remoteIdentity) < 0) local + remote - else remote + local + return if (Bytes.compareUnsigned(localIdentity, remoteIdentity) < 0) { + local + remote + } else { + remote + local + } } /** `SHA-256("fnsn-pq-v1" ‖ ik ‖ pq)`, one identity covering both keys. */ - private fun bind(ik: ByteArray, pq: ByteArray): ByteArray = - Bytes.sha256(Bytes.concat("fnsn-pq-v1".toByteArray(Charsets.UTF_8), ik, pq)) + private fun bind( + ik: ByteArray, + pq: ByteArray, + ): ByteArray = Bytes.sha256(Bytes.concat("fnsn-pq-v1".toByteArray(Charsets.UTF_8), ik, pq)) /** Grouped into fives, which is how it is meant to be read aloud. */ - fun format(number: String): String = - number.chunked(5).joinToString(" ") + fun format(number: String): String = number.chunked(5).joinToString(" ") } diff --git a/engine/src/main/kotlin/io/flamenet/e2e/SealedRoster.kt b/engine/src/main/kotlin/io/flamenet/e2e/SealedRoster.kt index 2fc78c4..3a91a4c 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/SealedRoster.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/SealedRoster.kt @@ -6,22 +6,8 @@ import java.security.SecureRandom /** * §8.9b, the member list, as something the relay cannot read. - * - * The relay used to hold `gid -> user_id` rows, which is a readable - * social graph sitting in a table. It now holds an opaque blob and a random - * token per member. This is the client half: what goes into that blob, and how - * it comes back out. - * - * This defeats a stolen database, not the operator. Every fetch is - * authenticated, so a running relay still sees which account asks about which - * group. Nothing here changes that, and no copy anywhere should suggest it does. - * - * The JSON keys and the canonical signing string must match the Swift engine - * byte for byte, or a roster written on one platform cannot be opened or - * verified on the other. Guarded by a shared test vector. */ object SealedRoster { - /** A group's members, their tags, and its name, before sealing. */ data class Contents( val members: List, @@ -34,26 +20,16 @@ object SealedRoster { /** * A group's key: one AES-256 key, minted by whoever creates the group and * handed to each member over their pairwise ratchet. - * - * Not derived from anything. A key derived from the gid or the member - * list would change when the list did, and every older blob would stop - * opening. A random key that travels with the invitation does not. */ fun newGroupKey(): ByteArray = ByteArray(32).also { random.nextBytes(it) } /** * A member's tag: 32 random bytes, meaning nothing. - * - * Random rather than derived from the account id. Anything derived is a - * pseudonym the relay can link across groups, the same account carrying a - * recognisable value everywhere, which is the graph again wearing a hash. */ fun newTag(): String = Bytes.b64(ByteArray(32).also { random.nextBytes(it) }) /** - * Keys are written in a FIXED order. `JSONObject` does not promise one, - * and a roster whose bytes depend on iteration order is a roster whose - * signature depends on it too. + * Keys are written in a FIXED order. */ private fun encode(c: Contents): ByteArray { val members = JSONArray().also { a -> c.members.forEach(a::put) } @@ -64,24 +40,23 @@ object SealedRoster { /** * A fresh random nonce every time, and `Aead` takes it as a parameter - * precisely so a caller cannot quietly reuse one. GCM does not degrade - * gracefully there. The box is `nonce || ciphertext || tag`, which is the - * same layout CryptoKit's `combined` produces, so a roster sealed on one - * platform opens on the other. + * precisely so a caller cannot quietly reuse one. */ - fun seal(contents: Contents, key: ByteArray): String { + fun seal( + contents: Contents, + key: ByteArray, + ): String { val nonce = ByteArray(12).also { random.nextBytes(it) } return Bytes.b64(Aead.seal(key, nonce, encode(contents))) } /** * Open a roster the relay handed back. - * - * Throws rather than returning null on a blob that will not open. A group - * whose key we have lost is not an empty group, and treating it as one would - * silently drop every member from a conversation that still exists. */ - fun open(blob: String, key: ByteArray): Contents { + fun open( + blob: String, + key: ByteArray, + ): Contents { val plain = Aead.open(key, Bytes.unb64(blob)) val json = JSONObject(String(plain)) val members = json.optJSONArray("members") ?: JSONArray() @@ -96,20 +71,15 @@ object SealedRoster { } /** - * What an owner signs to prove a roster is theirs. Must match - * `Group.rosterCanonical` on the relay and `SealedRoster.canonical` on iOS - * exactly, or every signature this client makes is refused. + * What an owner signs to prove a roster is theirs. */ + /** * A new group id: 32 random bytes as hex. - * - * Minted here rather than by the app so the app never needs the engine's - * internal encoding helpers, and so the shape stays in one place. It is - * the same shape the relay used to mint, before the owner's signature had - * to cover it. */ fun newGroupId(): String = - ByteArray(32).also { random.nextBytes(it) } + ByteArray(32) + .also { random.nextBytes(it) } .joinToString("") { "%02x".format(it) } /** An owner keypair for a group we create. */ @@ -118,9 +88,16 @@ object SealedRoster { fun ownerPublic(pair: Ed25519.KeyPair): String = Bytes.b64(pair.publicKey) /** Sign a roster as its owner. Base64, ready for the wire. */ - fun signRoster(ownerSeed: ByteArray, gid: String, version: Int, blob: String): String = - Bytes.b64(Ed25519.sign(ownerSeed, canonical(gid, version, blob))) + fun signRoster( + ownerSeed: ByteArray, + gid: String, + version: Int, + blob: String, + ): String = Bytes.b64(Ed25519.sign(ownerSeed, canonical(gid, version, blob))) - fun canonical(gid: String, version: Int, blob: String): ByteArray = - "fngroup-v1|$gid|$version|$blob".toByteArray() + fun canonical( + gid: String, + version: Int, + blob: String, + ): ByteArray = "fngroup-v1|$gid|$version|$blob".toByteArray() } diff --git a/engine/src/main/kotlin/io/flamenet/e2e/SealedSender.kt b/engine/src/main/kotlin/io/flamenet/e2e/SealedSender.kt index c4f45f1..9a5ff3d 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/SealedSender.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/SealedSender.kt @@ -5,28 +5,8 @@ import java.security.SecureRandom /** * Sealed sender, spec §14. - * - * Moves the sender's identity *inside* the ciphertext, so the relay learns who - * a message is for and never who it is from. - * - * A transport feature rather than a protocol version: it changes what the - * server sees and nothing about how `SK` is derived, so it composes with v1, - * v2 and v3 unchanged. - * - * The seal is confidentiality, never authentication. Anyone holding the - * recipient's public `IK_dh`, which the relay publishes to the world, can - * produce a well-formed seal claiming to be from anybody. The claimed sender is - * worth nothing until the *inner* envelope opens; the ratchet and the identity - * pin are what authenticate. Displaying, notifying on, or storing a claimed - * sender before that trades a metadata leak for free sender-spoofing, which is - * a worse product than the one being fixed. - * - * One-shot X25519 to the recipient device's published `IK_dh` rather than the - * ratchet, because a prekey message arrives before any session exists and so - * has no ratchet to use. */ object SealedSender { - private val INFO = "FlamenetE2E_SealedSender_v1".toByteArray(Charsets.UTF_8) private val ZERO32 = ByteArray(32) private const val NONCE_LEN = 12 @@ -35,14 +15,17 @@ object SealedSender { private val random = SecureRandom() /** A sealed blob, as it rides in the transport's `payload` field. */ - data class Blob(val eph: ByteArray, val ciphertext: ByteArray) { - fun toJson(): JSONObject = JSONObject() - .put("s", 1) - .put("eph", Bytes.b64(eph)) - .put("ct", Bytes.b64(ciphertext)) + data class Blob( + val eph: ByteArray, + val ciphertext: ByteArray, + ) { + fun toJson(): JSONObject = + JSONObject() + .put("s", 1) + .put("eph", Bytes.b64(eph)) + .put("ct", Bytes.b64(ciphertext)) - fun toPayload(): String = - Bytes.b64(toJson().toString().toByteArray(Charsets.UTF_8)) + fun toPayload(): String = Bytes.b64(toJson().toString().toByteArray(Charsets.UTF_8)) override fun equals(other: Any?): Boolean = other is Blob && eph.contentEquals(other.eph) && @@ -53,27 +36,12 @@ object SealedSender { /** * What a seal carried, once opened. - * - * Both identity fields say `claimed` on purpose. Renaming them to - * something confident is all it would take for a caller to start believing - * them. */ data class Opened( val claimedFromUser: Long, val claimedFromDevice: String, /** * The sender's `fnid@relay`, when the message crossed a relay boundary. - * - * Without this a federated message is unanswerable. The - * receiving relay records no sender for an envelope a peer deposited, - * and the sender's `from_user` is a row number on a machine we have - * never heard of, so the text decrypts, appears, and has nowhere to - * reply to. Null for an ordinary local message, where the relay's own - * routing already says who wrote it. - * - * `claimed`, like the two fields above it, and for the same reason: it - * came out of the seal, so it is what the sender asserted, not what - * anybody verified. */ val claimedFromAddress: String? = null, val envelope: Envelope, @@ -81,12 +49,6 @@ object SealedSender { /** * Derive the one-shot key and nonce from a shared secret. - * - * The nonce comes out of the KDF rather than being transmitted, so there is - * nothing for a relay to grind on and no nonce to get wrong. Safe because - * the ephemeral key is fresh per seal: a repeated (key, nonce) pair under - * GCM leaks the authentication subkey, and the only way this repeats is if - * the same ephemeral is used twice. */ private fun derive(shared: ByteArray): Pair { val okm = Hkdf.derive(shared, ZERO32, INFO, KEY_LEN + NONCE_LEN) @@ -95,10 +57,6 @@ object SealedSender { /** * Wrap an envelope so the relay cannot see who sent it. - * - * The AAD binds the destination device and the ephemeral key, which - * is what stops a relay taking one person's sealed blob and dropping it - * into somebody else's inbox: the copy would not open there. */ fun seal( envelope: Envelope, @@ -112,27 +70,30 @@ object SealedSender { val ephPub = X25519.rawFromPublicKey(eph.public) val (key, nonce) = derive(X25519.agree(eph.private, recipientIKdh)) - val inner = JSONObject() - .put("from_user", fromUser) - .put("from_device", fromDevice) - .also { if (fromAddress != null) it.put("from_addr", fromAddress) } - .put("envelope", envelope.toJson()) - .toString() - .toByteArray(Charsets.UTF_8) + val inner = + JSONObject() + .put("from_user", fromUser) + .put("from_device", fromDevice) + .also { if (fromAddress != null) it.put("from_addr", fromAddress) } + .put("envelope", envelope.toJson()) + .toString() + .toByteArray(Charsets.UTF_8) val aad = Bytes.concat(toDevice.toByteArray(Charsets.UTF_8), ephPub) - // `Aead.seal` returns `nonce || ct || tag`; the wire carries only - // `ct || tag`, because the nonce is derived on both sides. + // `Aead.seal` returns `nonce || ct || tag`; the wire carries only `ct + // || tag`, because the nonce is derived on both sides. val box = Aead.seal(key, nonce, inner, aad) return Blob(ephPub, box.copyOfRange(NONCE_LEN, box.size)) } /** * Open a sealed blob with this device's `IK_dh`. - * - * See the type-level warning: what comes back is a routing hint, not a fact. */ - fun open(blob: Blob, myIKdhPrivate: java.security.PrivateKey, myDeviceId: String): Opened { + fun open( + blob: Blob, + myIKdhPrivate: java.security.PrivateKey, + myDeviceId: String, + ): Opened { val (key, nonce) = derive(X25519.agree(myIKdhPrivate, blob.eph)) val aad = Bytes.concat(myDeviceId.toByteArray(Charsets.UTF_8), blob.eph) val plain = Aead.open(key, Bytes.concat(nonce, blob.ciphertext), aad) @@ -143,8 +104,6 @@ object SealedSender { // Omitted entirely for a local seal rather than written as null, // matching iOS, a build that predates the field must ignore one // that carries it, and one that has it must accept a blob without. - // The seal version deliberately stays at 1: nothing about how the - // blob opens changed. claimedFromAddress = inner.optString("from_addr").ifEmpty { null }, envelope = Envelope.fromJson(inner.getJSONObject("envelope")), ) @@ -152,17 +111,14 @@ object SealedSender { /** * Whether a transport payload is a sealed blob rather than a bare envelope. - * - * Both shapes arrive in the same field, so this is what decides which - * decoder to use. Deliberately structural, `s == 1` and an `eph` string, - * rather than a guess based on which parse throws first. */ - fun isSealed(payload: String): Boolean = try { - val json = JSONObject(String(Bytes.unb64(payload), Charsets.UTF_8)) - json.optInt("s") == 1 && json.optString("eph").isNotEmpty() - } catch (_: Exception) { - false - } + fun isSealed(payload: String): Boolean = + try { + val json = JSONObject(String(Bytes.unb64(payload), Charsets.UTF_8)) + json.optInt("s") == 1 && json.optString("eph").isNotEmpty() + } catch (_: Exception) { + false + } fun blobFromPayload(payload: String): Blob { val json = JSONObject(String(Bytes.unb64(payload), Charsets.UTF_8)) @@ -173,15 +129,6 @@ object SealedSender { /** * The 32-byte capability that authorises an unauthenticated sealed * submission to a given user (§14.4). - * - * The server stores only the digest. The key itself reaches contacts - * inside E2E messages, so the relay never sees it from its owner, which is - * the only reason a sealed submission can be authorised without naming who - * is submitting. - * - * Rotating it is the only way to cut off a sealed spammer, and it costs a - * redistribution to every contact. That is the whole trade §14.4 makes: - * anonymity for the sender, and a blunt instrument for the recipient. */ fun newDeliveryKey(): ByteArray = ByteArray(32).also(random::nextBytes) @@ -189,46 +136,25 @@ object SealedSender { fun deliveryKeyDigest(key: ByteArray): ByteArray = Bytes.sha256(key) /** The control message that hands a contact this key (§6.6, §14.4). */ + /** * The control message handing a contact our delivery key (§14.4) and, since * §21.4, a batch of single-use capabilities alongside it. - * - * One envelope, not two, and that is a compatibility decision rather - * than tidiness. A receiver recognises control traffic by KIND first and - * falls back to the `ctl` flag only for kinds it has never heard of, and - * the build in the field reads that flag with `optBoolean`, which does not - * coerce the number §6.10 specifies. A new `delivery_tokens` kind would - * therefore have arrived on those phones as a bubble full of raw JSON, - * which is the bug this engine fixed in the other direction on 2026-08-30. - * - * Hand-assembled so `fnc` stays the first key: the marker is a prefix - * test, and a reordered top level decodes as a plain text message, raw - * JSON in somebody's conversation. */ - @JvmOverloads /** - * @param groupId with [groupName], tells this contact what a group is called. - * - * This kind is the vehicle precisely because it is old. The relay no - * longer stores group names, so members have to learn them from each other, - * and the obvious way, a new `group_name` control kind, would arrive on - * every phone running v5 as a bubble full of raw JSON: those builds read - * the `ctl` flag with `optBoolean`, which does not coerce the number §6.10 - * specifies. `delivery_key` is a kind they already consume in silence and - * already ignore unknown fields on. + * @param groupId with [groupName], tells this contact what a group is + * called. */ /** * §8.9b, hand a member their tag and the group key. - * - * The only way these ever reach anybody. The relay holds neither, by - * construction, so a member who is not told cannot fetch the group at all, - * there is nobody to ask. - * - * Rides `delivery_key` for the same reason the group name does: a new - * control kind renders as raw JSON on every phone running v5. */ + @JvmOverloads fun sealedGroupEnvelope( - key: ByteArray, groupId: String, tag: String, groupKey: ByteArray, name: String + key: ByteArray, + groupId: String, + tag: String, + groupKey: ByteArray, + name: String, ): String = """{"fnc":1,"kind":"${Content.Kind.DELIVERY_KEY}","key":"${Bytes.b64(key)}"""" + ""","gid":${JSONObject.quote(groupId)}""" + diff --git a/engine/src/main/kotlin/io/flamenet/e2e/SealingRecord.kt b/engine/src/main/kotlin/io/flamenet/e2e/SealingRecord.kt index 50844cb..7aa9968 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/SealingRecord.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/SealingRecord.kt @@ -2,18 +2,6 @@ package io.flamenet.e2e /** * How often sending falls back from sealed to unsealed (§14.5, ios#16). - * - * The fallback is the thing worth counting. Sealing hides WHO SENT a - * message from the relay. When the peer's delivery key or capability is missing - * the send still goes, end-to-end encrypted as always, but unsealed, so the - * relay learns the sender. The outcome was reported per send and nothing - * accumulated, so nobody could answer "how often does this happen to me". - * - * Aggregate only, deliberately. Counting per contact would be more - * useful and would also build a record of who this account talks to and how - * often, the precise thing sealing exists to keep off a server, kept instead - * on the device. Totals answer the question without producing a social graph as - * a side effect. */ data class SealingRecord( /** Sends where the relay could not see who sent it. */ @@ -27,21 +15,12 @@ data class SealingRecord( /** * What share of sends the relay could attribute, 0..1. - * - * Zero sends is not "nothing leaked", it is "nothing sent". Returning - * 0.0 would render as a reassuring 0% on a fresh install. */ val unsealedShare: Double? get() = if (total == 0) null else unsealed.toDouble() / total.toDouble() /** * The sentence shown to the user. - * - * Says what is exposed, not that the message was readable. An - * unsealed send is still end-to-end encrypted: the relay learns who sent - * it, not what it said. Copy that blurred the two would either frighten - * people about nothing or, worse, teach them that "sealed" means - * "encrypted" and leave them relaxed when it is off. */ val summary: String get() { val share = unsealedShare ?: return "Nothing sent from this device yet." @@ -56,10 +35,15 @@ data class SealingRecord( } /** Fold one send's outcome in. */ - fun recording(wasSealed: Boolean, at: Long): SealingRecord = - if (wasSealed) copy(sealed = sealed + 1) - // A sealed send must not clear the stamp: "when did this last - // happen" is the question, and one good send afterwards does not - // unhappen it. - else copy(unsealed = unsealed + 1, lastUnsealedAt = at) + fun recording( + wasSealed: Boolean, + at: Long, + ): SealingRecord = + if (wasSealed) { + copy(sealed = sealed + 1) + } // A sealed send must not clear the stamp: "when did this last happen" + // is the question, and one good send afterwards does not unhappen it. + else { + copy(unsealed = unsealed + 1, lastUnsealedAt = at) + } } diff --git a/engine/src/main/kotlin/io/flamenet/e2e/SecureBytes.kt b/engine/src/main/kotlin/io/flamenet/e2e/SecureBytes.kt index e2652a8..38e7cff 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/SecureBytes.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/SecureBytes.kt @@ -4,48 +4,10 @@ import java.util.Arrays /** * Zeroing key material once it has been used (ios#39). - * - * This narrows a window. It does not remove the material, and claiming - * otherwise would spend the whole value of the feature on a lie. What it buys - * is that a derived key does not sit in the process for the lifetime of the app - * after the one moment it was needed, which matters for a heap dump, a crash - * report, or a debugger attached later. It buys nothing against somebody - * reading memory *while* the secret is in use. - * - * The JVM guarantee is weaker than the iOS one, and the difference is not - * a detail. iOS has `memset_s`, which is specified not to be optimised away, - * and iOS does not page application memory to disk. Neither holds here: - * - * - A compacting garbage collector moves objects. A `ByteArray` can be - * relocated between allocation and wiping, and the bytes at the old address - * are not cleared by the move. Zeroing reaches the array you are holding; it - * cannot reach a copy the runtime made on its own. There is no way to pin a - * Java array, so this is a ceiling on what any implementation here can offer. - * - Android has compressed swap. zram has been on by default for years, so - * a page holding key material can reach a backing store, unlike iOS, where - * the platform does not page application memory out at all. - * - There is no `memset_s`. `Arrays.fill` on an array that escapes the - * method is not eliminated in practice, because the JIT cannot prove the - * write is dead. "Not in practice" is a weaker statement than the C standard - * makes, and it is the honest one. - * - * A Kotlin `String` cannot be zeroed, same as Swift and for the same - * reasons: immutable, freely copied, and possibly interned. A passphrase that - * arrived as a `String` is beyond reach. The right response is to turn it into - * bytes early, wipe those, and not pretend about the original, which is why - * `AccountKdf.master` already calls `PBEKeySpec.clearPassword()` rather than - * trying to do anything about the `String` it was handed. - * - * So the claim this type supports, precisely: a derived key is not left - * sitting in the heap after use. Not "the key is gone". */ object SecureBytes { - /** * Zero this array in place. - * - * Reaches THIS array only. Anything that copied it beforehand keeps an - * untouched copy, and the runtime may have made copies nobody asked for. */ fun wipe(bytes: ByteArray?) { if (bytes == null || bytes.isEmpty()) return @@ -54,13 +16,11 @@ object SecureBytes { /** * Run [body] with key material and wipe it however [body] leaves. - * - * Wipes on the way out of a throw as well. A derivation that failed - * half-way is exactly the case where the material is most likely to be - * forgotten, which is why this is `try/finally` rather than a wipe at the - * end of a happy path. */ - inline fun withSecret(secret: ByteArray, body: (ByteArray) -> T): T = + inline fun withSecret( + secret: ByteArray, + body: (ByteArray) -> T, + ): T = try { body(secret) } finally { @@ -69,10 +29,6 @@ object SecureBytes { /** * A copy of [source], for callers that need one. - * - * The copy is ordinary memory and this object stops being responsible - * for it. Every use of this is a decision to leave a second copy behind, - * and it is spelled out rather than implicit for that reason. */ fun copyOf(source: ByteArray): ByteArray = source.copyOf() } diff --git a/engine/src/main/kotlin/io/flamenet/e2e/StreamDisclosure.kt b/engine/src/main/kotlin/io/flamenet/e2e/StreamDisclosure.kt index ef92fba..050553c 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/StreamDisclosure.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/StreamDisclosure.kt @@ -2,24 +2,10 @@ package io.flamenet.e2e /** * What a viewer is told before an embedded live stream connects (spec §6.13). - * - * This is the one media path where the viewer must contact a third party, - * and it cannot be designed away. A photo or video story is a sealed blob: - * the relay stores ciphertext and nobody else is involved. A live stream is - * continuous and hosted elsewhere, so there is nothing to seal, watching it - * means the viewer's device connects to that host, which learns their IP - * address and that they watched. - * - * §6.12's answer (have the sender fetch it once) does not transfer: a stream is - * not a thing that can be fetched once. So the rule is disclosure rather than - * prevention, and this is where the wording lives so it can be asserted. */ object StreamDisclosure { - /** - * Names the host and says what it learns. "This will play a video" is a - * button; a disclosure names the destination, because nobody can make a - * decision about "somewhere on the internet". + * Names the host and says what it learns. */ fun text(host: String): String = "Playing connects you to $host, which will see your IP address and " + @@ -27,14 +13,11 @@ object StreamDisclosure { /** * Whether a stream URL is one worth offering at all. - * - * Refuses anything that is not `http(s)` with a host. A `file:` URL - * arriving from another person is not something to hand a media player, and - * a destination that cannot be named cannot be consented to. */ - fun acceptable(raw: String): Boolean = runCatching { - val uri = java.net.URI(raw) - val scheme = uri.scheme?.lowercase() - (scheme == "http" || scheme == "https") && !uri.host.isNullOrEmpty() - }.getOrDefault(false) + fun acceptable(raw: String): Boolean = + runCatching { + val uri = java.net.URI(raw) + val scheme = uri.scheme?.lowercase() + (scheme == "http" || scheme == "https") && !uri.host.isNullOrEmpty() + }.getOrDefault(false) } diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt index e05b520..5ef69a5 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt @@ -9,35 +9,29 @@ import javax.crypto.spec.GCMParameterSpec /** * Everything the app must remember, encrypted at rest. - * - * This deliberately does not use [Aead]. `Aead` takes raw key bytes, and - * the key here comes from the Android Keystore, which never hands out its key - * material, that is the entire reason to use it. A design that needed the raw - * bytes would quietly have to keep them somewhere, which is the thing being - * avoided. So this works with a `SecretKey` handle and the platform `Cipher`. - * - * Every record's name is bound into the AAD. Without that, a blob is - * portable between slots: someone with file access could paste the session for - * one contact into another contact's slot, and the app would decrypt it - * happily and attribute messages to the wrong person. The name is not a - * filename here, it is part of the ciphertext's meaning. - * - * Deleting has to actually delete. "Deleted means deleted" is a promise - * the product makes, so [forget] removes the record rather than marking it, and - * [forgetEverything] takes the identity with it. */ -class Vault(private val storage: Storage, private val key: SecretKey) { - +class Vault( + private val storage: Storage, + private val key: SecretKey, +) { /** Wherever bytes live. A directory on a phone, a map in a test. */ interface Storage { fun read(name: String): ByteArray? - fun write(name: String, bytes: ByteArray) + + fun write( + name: String, + bytes: ByteArray, + ) + fun delete(name: String) + fun names(): List } - class Corrupt(name: String, cause: Throwable?) : - Exception("the vault record '$name' did not open", cause) + class Corrupt( + name: String, + cause: Throwable?, + ) : Exception("the vault record '$name' did not open", cause) companion object { private const val NONCE_LEN = 12 @@ -67,11 +61,6 @@ class Vault(private val storage: Storage, private val key: SecretKey) { /** * A ceiling on remembered addresses. - * - * The key is chosen by whoever writes to us, so an unbounded list is - * storage anybody on the federation can grow. Oldest goes first, which - * costs at most a thread heading, the messages themselves are keyed by - * the binding, not by this list. */ private const val MAX_REMOTE_ADDRESSES = 500 private const val KEYLOG_SERVED = "keylog-served" @@ -96,6 +85,7 @@ class Vault(private val storage: Storage, private val key: SecretKey) { private const val GROUP_SECRETS = "group-secrets" private const val DEVICE_OWNER_PREFIX = "devowner/" private const val KT_ISSUER_PIN = "kt-issuer-pin" + /** Kept because the whole log is re-sealed on every write. */ private const val CALL_LOG_MAX = 500 @@ -106,28 +96,17 @@ class Vault(private val storage: Storage, private val key: SecretKey) { private const val MAX_GROUP_NAME = 80 /** The two kinds of thread, named once so they cannot drift apart. */ + /** * Who a group story will reach, and who among them this account has * excluded from its stories (§6.24). - * - * The roster decides; the story audience does not apply. Those - * are two different lists making two different statements, and they - * will disagree. This returns the overlap rather than a warning, - * because a sender who believes their story audience still applies is - * being misled by the interface, and a disclosure that cannot name the - * consequence is not one. - * - * The two categories do not double-count. Somebody who is not a - * contact is not "excluded from your stories", they were never on - * that list at all. */ fun groupStoryReach( members: List, me: Long, /** * Who this account's story audience would have allowed, already - * resolved. Passed in rather than an audience type: the audience - * lives in the app layer and this module has no dependency on it. + * resolved. */ allowedByAudience: Set, contacts: List, @@ -144,25 +123,11 @@ class Vault(private val storage: Storage, private val key: SecretKey) { /** * Collapse a set to the expiry every item shares (§6.23). - * - * The earliest wins. Every item is required to carry the same - * `exp`, but a sender can write different ones, and if the latest - * won, the difference would be a way to keep part of a sequence alive - * after the rest had gone. - * - * A zero means "no expiry on that item" and is skipped, not read as - * "expires at the epoch", which would kill the whole set. A text story - * has no attachment at all and contributes nothing here. */ - fun setExpiry(items: List): Double = - items.mapNotNull { it.ref?.expires?.takeIf { e -> e > 0 } }.minOrNull() ?: 0.0 + fun setExpiry(items: List): Double = items.mapNotNull { it.ref?.expires?.takeIf { e -> e > 0 } }.minOrNull() ?: 0.0 /** * Group one author's stories into sets, in position order (§6.23). - * - * Renders what arrived. `n` is the sender's claim and any item can - * fail to arrive, so a set is never held back or shown as incomplete, - * a missing item is indistinguishable from one never sent. */ fun groupedStories(stories: List): List> { val order = mutableListOf() @@ -171,7 +136,10 @@ class Vault(private val storage: Storage, private val key: SecretKey) { // A story with no set is its own group of one, keyed so it can // never collide with a real set id. val key = story.set?.let { "set:${it.id}" } ?: "one:${story.id}" - if (sets[key] == null) { order += key; sets[key] = mutableListOf() } + if (sets[key] == null) { + order += key + sets[key] = mutableListOf() + } sets.getValue(key) += story } return order.map { key -> sets.getValue(key).sortedBy { it.set?.i ?: 0 } } @@ -179,18 +147,11 @@ class Vault(private val storage: Storage, private val key: SecretKey) { /** * Fold newly-expired stories into the archive (ios#54). - * - * Refuses anything not the author's own. The caller filters as - * well, and this is the guard that survives the caller being - * rewritten, an archive holding somebody else's expired story breaks - * the promise the app made to them, and "it is private to you" does - * not repair that. - * - * De-duplicated by id, because the sweep runs on every load and a - * story that expired weeks ago would otherwise be appended each time. - * Newest first, so the screen needs no sort. */ - fun archive(existing: List, adding: List): List { + fun archive( + existing: List, + adding: List, + ): List { val seen = existing.map { it.id }.toMutableSet() val result = existing.toMutableList() adding.forEach { story -> @@ -201,31 +162,21 @@ class Vault(private val storage: Storage, private val key: SecretKey) { /** * Drop muted authors' stories from the strip (ios#33). - * - * `me` is never dropped. Muting yourself is not offered, but a - * muted id that reached the set some other way, a restored backup - * from a build without the guard, must not hide the user's own posts - * from them, which reads as the posts having failed to send. - * - * Order is the caller's, newest first. Filtering must not re-sort. */ - fun visibleStories(stories: List, muted: Set, me: Long): List = - stories.filter { it.authorId == me || it.authorId !in muted } + fun visibleStories( + stories: List, + muted: Set, + me: Long, + ): List = stories.filter { it.authorId == me || it.authorId !in muted } + /** - * How long a message actually lives, given its own timer and its - * conversation's (§6.17). Null means it does not expire. - * - * The shorter of the two always wins, and that is the whole - * rule. A per-message timer may only take life away, never add it. - * Otherwise a sender could defeat the recipient's conversation timer by - * putting a long one on every message, turning a setting the recipient - * chose, on their own device, into something the other party overrules. - * - * Zero means "no timer", not "expire immediately", on both axes. - * Reading it the other way would delete a message the instant it - * arrived, and zero is how "off" has always been spelled here. + * How long a message actually lives, given its own timer and its conversation's + * (§6.17). */ - fun effectiveLifetime(message: Int, conversation: Int): Int? { + fun effectiveLifetime( + message: Int, + conversation: Int, + ): Int? { val own = message.takeIf { it > 0 } val thread = conversation.takeIf { it > 0 } return when { @@ -236,39 +187,30 @@ class Vault(private val storage: Storage, private val key: SecretKey) { /** * Whether this story may be written out of the app (ios#34). - * - * Only ever your own, and the rule lives here rather than in the - * button. Saving somebody else's story keeps a copy of something - * they were told would disappear, the opposite of what the feature - * promises them, and no wording on a menu item makes that acceptable. - * A guard in a composable is one refactor away from being lost. - * - * A text story (§6.21) has no blob and nothing to write out. False - * rather than rendering the words into a picture: a story is what the - * author posted, and an image of it is a different object. */ - fun isSaveable(mine: Boolean, hasBlob: Boolean): Boolean = mine && hasBlob + fun isSaveable( + mine: Boolean, + hasBlob: Boolean, + ): Boolean = mine && hasBlob fun threadForUser(userId: Long) = "user:$userId" + fun threadForGroup(groupId: String) = "group:$groupId" /** * A conversation with somebody on another relay. - * - * A THIRD prefix rather than reusing `user:`. A remote peer's user - * id belongs to their relay, so folding them into the local keyspace - * would collide two unrelated people the moment two relays happened to - * assign the same row number, silently, into a thread already showing - * somebody else's name. */ fun threadForAddress(address: String) = "addr:$address" private val random = SecureRandom() } - // -- Bytes --------------------------------------------------------------- + // -- Bytes ---------------------------------------------------------------. - private fun seal(name: String, plaintext: ByteArray): ByteArray { + private fun seal( + name: String, + plaintext: ByteArray, + ): ByteArray { val nonce = ByteArray(NONCE_LEN).also(random::nextBytes) val cipher = Cipher.getInstance("AES/GCM/NoPadding") cipher.init(Cipher.ENCRYPT_MODE, key, GCMParameterSpec(TAG_BITS, nonce)) @@ -276,37 +218,39 @@ class Vault(private val storage: Storage, private val key: SecretKey) { return nonce + cipher.doFinal(plaintext) } - private fun open(name: String, box: ByteArray): ByteArray { + private fun open( + name: String, + box: ByteArray, + ): ByteArray { if (box.size <= NONCE_LEN) throw Corrupt(name, null) return try { val cipher = Cipher.getInstance("AES/GCM/NoPadding") cipher.init( - Cipher.DECRYPT_MODE, key, + Cipher.DECRYPT_MODE, + key, GCMParameterSpec(TAG_BITS, box.copyOfRange(0, NONCE_LEN)), ) cipher.updateAAD(name.toByteArray(Charsets.UTF_8)) cipher.doFinal(box, NONCE_LEN, box.size - NONCE_LEN) } catch (e: Exception) { - // Deliberately one exception for every cause. Which of "wrong key", - // "wrong slot" and "flipped bit" it was is information an attacker - // would like and the user cannot act on. + // Deliberately one exception for every cause. throw Corrupt(name, e) } } - private fun readJson(name: String): JSONObject? = - storage.read(name)?.let { JSONObject(String(open(name, it), Charsets.UTF_8)) } + private fun readJson(name: String): JSONObject? = storage.read(name)?.let { JSONObject(String(open(name, it), Charsets.UTF_8)) } - private fun writeJson(name: String, json: JSONObject) = - storage.write(name, seal(name, json.toString().toByteArray(Charsets.UTF_8))) + private fun writeJson( + name: String, + json: JSONObject, + ) = storage.write(name, seal(name, json.toString().toByteArray(Charsets.UTF_8))) - // -- Identity ------------------------------------------------------------ + // -- Identity ------------------------------------------------------------. /** * Returning null here means "this is a new install", and the caller will - * mint a fresh device, which every contact sees as "security keys - * changed", the warning that is supposed to mean an attack. So a record - * that exists but will not open must throw rather than read as absent. + * mint a fresh device, which every contact sees as "security keys changed", + * the warning that is supposed to mean an attack. */ fun loadIdentity(): DeviceIdentity? = readJson(IDENTITY)?.let(DeviceIdentity::fromArchive) @@ -314,21 +258,10 @@ class Vault(private val storage: Storage, private val key: SecretKey) { fun hasIdentity(): Boolean = storage.read(IDENTITY) != null - // -- The account credential ---------------------------------------------- + // -- The account credential ----------------------------------------------. /** * Who we are signed in as, so a restart does not mean signing in again. - * - * This is the long-lived credential, not the password. The relay - * mints it, returns it once and keeps only its hash, so this is the only - * copy, losing it means signing in again, and leaking it means someone - * else is signed in as this account until it is revoked. It lives here - * rather than in shared preferences for exactly that reason. - * - * The relay address is stored with it and must be. The same credential - * against a different relay is meaningless, and a client that remembered - * the credential but not where it came from would present it to whatever - * server happened to be configured next. */ data class Credential( val relayBaseUrl: String, @@ -337,187 +270,114 @@ class Vault(private val storage: Storage, private val key: SecretKey) { val userId: Long, /** * This account's global address. - * - * Stored because it can only be DERIVED at sign-in. It comes - * from the passphrase, which exists for exactly the length of one - * sign-in and is never kept, so a restored session had no way to - * recompute it, and the Account screen correctly reported that this - * device had no address every time the app was relaunched. Re-deriving - * is not an option: it is 650,000 PBKDF2 iterations and the input is - * gone. - * - * Public by nature, it is what people are given, so this adds - * nothing to what the vault already protects. */ val fnid: String? = null, ) - fun loadCredential(): Credential? = readJson(CREDENTIAL)?.let { - Credential( - relayBaseUrl = it.getString("relay"), - username = it.getString("username"), - appPassword = it.getString("appPassword"), - userId = it.getLong("userId"), - // Optional read: a credential written before addresses existed - // is still a valid credential, and must not fail to load. - fnid = it.optString("fnid").ifEmpty { null }, + fun loadCredential(): Credential? = + readJson(CREDENTIAL)?.let { + Credential( + relayBaseUrl = it.getString("relay"), + username = it.getString("username"), + appPassword = it.getString("appPassword"), + userId = it.getLong("userId"), + // Optional read: a credential written before addresses existed is + // still a valid credential, and must not fail to load. + fnid = it.optString("fnid").ifEmpty { null }, + ) + } + + fun saveCredential(credential: Credential) = + writeJson( + CREDENTIAL, + JSONObject() + .put("relay", credential.relayBaseUrl) + .put("username", credential.username) + .put("appPassword", credential.appPassword) + .put("userId", credential.userId) + .put("fnid", credential.fnid ?: ""), ) - } - fun saveCredential(credential: Credential) = writeJson(CREDENTIAL, JSONObject() - .put("relay", credential.relayBaseUrl) - .put("username", credential.username) - .put("appPassword", credential.appPassword) - .put("userId", credential.userId) - .put("fnid", credential.fnid ?: "")) - - // -- Sessions ------------------------------------------------------------ + // -- Sessions ------------------------------------------------------------. private fun sessionName(peerDeviceId: String) = SESSION_PREFIX + peerDeviceId - fun loadSession(peerDeviceId: String): E2ESession? = - readJson(sessionName(peerDeviceId))?.let(E2ESession::fromArchive) + fun loadSession(peerDeviceId: String): E2ESession? = readJson(sessionName(peerDeviceId))?.let(E2ESession::fromArchive) - fun saveSession(peerDeviceId: String, session: E2ESession) = - writeJson(sessionName(peerDeviceId), session.archive()) + fun saveSession( + peerDeviceId: String, + session: E2ESession, + ) = writeJson(sessionName(peerDeviceId), session.archive()) /** * The session a re-establishment retired, kept so its in-flight messages * can still be opened. - * - * When both sides run X3DH before either has processed the other's - * prekey, two people opening the same chat at once, each adopts the - * other's chain. Everything the peer sends from then on travels on the - * chain being replaced, so overwriting it without a copy makes that - * direction undecryptable for the rest of the conversation, silently. - * - * One retired session per device: a second re-establishment displaces - * the first. Keeping the most recent is the right trade, because the older - * chain's messages have had longer to arrive. */ - fun loadRetiredSession(peerDeviceId: String): E2ESession? = - readJson(RETIRED_PREFIX + peerDeviceId)?.let(E2ESession::fromArchive) + fun loadRetiredSession(peerDeviceId: String): E2ESession? = readJson(RETIRED_PREFIX + peerDeviceId)?.let(E2ESession::fromArchive) - fun saveRetiredSession(peerDeviceId: String, session: E2ESession) = - writeJson(RETIRED_PREFIX + peerDeviceId, session.archive()) + fun saveRetiredSession( + peerDeviceId: String, + session: E2ESession, + ) = writeJson(RETIRED_PREFIX + peerDeviceId, session.archive()) - fun knownPeers(): List = - storage.names().filter { it.startsWith(SESSION_PREFIX) }.map { it.removePrefix(SESSION_PREFIX) } + fun knownPeers(): List = storage.names().filter { it.startsWith(SESSION_PREFIX) }.map { it.removePrefix(SESSION_PREFIX) } // -- Identity trust (§19) ------------------------------------------------ - // - // Encrypted at rest like everything else here - // rather than a nicety. These records are what the client compares a - // served key against. Somewhere an attacker can rewrite them is somewhere - // an attacker can pin their own key and have the comparison agree with - // them for ever afterwards, silently, because a match is what "fine" - // looks like. + // Encrypted at rest like everything else here rather than a nicety. private fun trustName(userId: Long) = TRUST_PREFIX + userId /** * What we have seen this person's devices use, and whether a human checked. - * - * `identities` is every identity key pinned for them, base64. Plural - * because a person is not a device: someone with a phone and a tablet has - * two, and both are legitimately theirs. */ data class Trust( val identities: List, val verified: Boolean, /** * The global address this contact was first seen under, if any. - * - * Pinned, not looked up per use. The key log files a leaf under - * whichever subject the account had at publication time, so verifying a - * served key means recomputing against the address we already accepted - * for this person, not against whatever the relay says today. Asking - * again each time would let a relay change the answer and have every - * check agree with the new one. - * - * Null means "no address seen", which is both "never claimed one" and - * "the relay would not say", the relay answers those identically on - * purpose, so the client cannot tell them apart either. */ val fnid: String? = null, - /** * A key change was detected and has NOT been accepted yet. - * - * Without this the detection is thrown away. `record()` computes - * CHANGED while a send is in flight and returns it; nothing persisted - * it, so `state()` could only ever answer UNKNOWN, VERIFIED or PINNED. - * The chat header therefore went on saying "keys unchanged since you - * met" while every send failed with "identity keys changed", and the - * "Accept new keys" button, which only renders for CHANGED, could - * never appear. A contact who reinstalled became permanently - * unmessageable, with the UI insisting nothing was wrong. - * - * The pinned `identities` stay as they were. This flag records that - * something ELSE was offered, not that we believe it. */ val changed: Boolean = false, ) - fun loadTrust(userId: Long): Trust? = readJson(trustName(userId))?.let { o -> - val a = o.optJSONArray("identities") ?: org.json.JSONArray() - Trust( - identities = (0 until a.length()).map { a.getString(it) }, - verified = o.optBoolean("verified", false), - // `"null"` is rejected, and that is not paranoia, records - // written that way are already on phones. `saveTrust` used to - // write an explicit JSON null here, and Android's `org.json` - // coerces that to the four-character string on the way back out. - // See the note on [saveTrust]; this is the half that repairs what - // has already been stored. - fnid = o.optString("fnid").takeIf { it.isNotEmpty() && it != "null" }, - changed = o.optBoolean("changed", false), - ) - } + fun loadTrust(userId: Long): Trust? = + readJson(trustName(userId))?.let { o -> + val a = o.optJSONArray("identities") ?: org.json.JSONArray() + Trust( + identities = (0 until a.length()).map { a.getString(it) }, + verified = o.optBoolean("verified", false), + // `"null"` is rejected, and that is not paranoia, records written + // that way are already on phones. + fnid = o.optString("fnid").takeIf { it.isNotEmpty() && it != "null" }, + changed = o.optBoolean("changed", false), + ) + } /** - * The key is OMITTED when there is no address, never written as an - * explicit JSON null. Android's bundled `org.json` reads a JSON null back - * as the four-character string `"null"`, so a contact pinned before their - * fnid was known reloaded with `fnid = "null"`, a non-null value that is - * not an address. What that caused: - * - * - `Trust.pin` compares a newly offered fnid against the pinned one and - * treats a difference as a different person. `"null" != "fnid:…"`, so the - * first time a real address arrived the contact was marked CHANGED, - * the "security keys changed" warning, which is supposed to mean an - * attack, fired on an ordinary first sighting. - * - `rememberIdentity` returns early once `fnid != null`, so the real - * address was never resolved and the record stayed wrong for good. - * - `pinnedFnid` fed `"null"` into key-log verification, which recomputes a - * leaf against the address a contact was accepted under. Every check - * would have been against the wrong subject. - * - * An absent key reads as `""` on both platforms, which is what the reader - * has always expected. + * The key is OMITTED when there is no address, never written as an explicit + * JSON null. */ - fun saveTrust(userId: Long, trust: Trust) = writeJson(trustName(userId), JSONObject() - .put("identities", org.json.JSONArray(trust.identities)) - .put("verified", trust.verified) - .also { if (trust.fnid != null) it.put("fnid", trust.fnid) } - .put("changed", trust.changed)) + fun saveTrust( + userId: Long, + trust: Trust, + ) = writeJson( + trustName(userId), + JSONObject() + .put("identities", org.json.JSONArray(trust.identities)) + .put("verified", trust.verified) + .also { if (trust.fnid != null) it.put("fnid", trust.fnid) } + .put("changed", trust.changed), + ) fun forgetTrust(userId: Long) = storage.delete(trustName(userId)) - // -- Sealed sender (§14.4) ----------------------------------------------- + // -- Sealed sender (§14.4) -----------------------------------------------. /** * This account's delivery key, and whether the relay has its digest yet. - * - * The key itself never goes to the relay, only `SHA-256(key)` does. - * It reaches contacts inside E2E messages, so the relay never sees it from - * its owner, and that is the only reason an unauthenticated sealed - * submission can be authorised without naming who is submitting. Sending - * the raw key to the server would make the whole mechanism decorative. - * - * `published` tracks the digest upload separately from the key, because a - * key generated and never published is one every contact would try to seal - * to and every seal would be rejected. */ data class Delivery( val key: ByteArray, @@ -534,40 +394,55 @@ class Vault(private val storage: Storage, private val key: SecretKey) { override fun hashCode(): Int = key.contentHashCode() * 31 + published.hashCode() } - fun loadDelivery(): Delivery? = readJson(DELIVERY_KEY)?.let { o -> - val peers = o.optJSONObject("peers") ?: JSONObject() - val told = o.optJSONArray("told") ?: org.json.JSONArray() - Delivery( - key = java.util.Base64.getDecoder().decode(o.getString("key")), - published = o.optBoolean("published", false), - peers = peers.keys().asSequence().associate { k -> - k.toLong() to java.util.Base64.getDecoder().decode(peers.getString(k)) - }, - toldPeers = (0 until told.length()).map { told.getLong(it) }.toSet(), - ) - } + fun loadDelivery(): Delivery? = + readJson(DELIVERY_KEY)?.let { o -> + val peers = o.optJSONObject("peers") ?: JSONObject() + val told = o.optJSONArray("told") ?: org.json.JSONArray() + Delivery( + key = + java.util.Base64 + .getDecoder() + .decode(o.getString("key")), + published = o.optBoolean("published", false), + peers = + peers.keys().asSequence().associate { k -> + k.toLong() to + java.util.Base64 + .getDecoder() + .decode(peers.getString(k)) + }, + toldPeers = (0 until told.length()).map { told.getLong(it) }.toSet(), + ) + } fun saveDelivery(delivery: Delivery) { val peers = JSONObject() delivery.peers.forEach { (id, k) -> - peers.put(id.toString(), java.util.Base64.getEncoder().encodeToString(k)) + peers.put( + id.toString(), + java.util.Base64 + .getEncoder() + .encodeToString(k), + ) } - writeJson(DELIVERY_KEY, JSONObject() - .put("key", java.util.Base64.getEncoder().encodeToString(delivery.key)) - .put("published", delivery.published) - .put("peers", peers) - .put("told", org.json.JSONArray(delivery.toldPeers.toList()))) + writeJson( + DELIVERY_KEY, + JSONObject() + .put( + "key", + java.util.Base64 + .getEncoder() + .encodeToString(delivery.key), + ).put("published", delivery.published) + .put("peers", peers) + .put("told", org.json.JSONArray(delivery.toldPeers.toList())), + ) } - // -- Stories (§6.6) ------------------------------------------------------ + // -- Stories (§6.6) ------------------------------------------------------. /** * One story, as this device holds it. - * - * `expires` is the author's claim, carried in the envelope. It is not - * enforceable, a recipient's client could keep the bytes for ever, and - * this one deletes them because it chooses to. A story is not a secret you - * can take back, and the UI should not suggest otherwise. */ data class GroupStoryReach( val recipients: List, @@ -583,10 +458,6 @@ class Vault(private val storage: Storage, private val key: SecretKey) { val mine: Boolean, /** * The picture or video, when there is one. - * - * Nullable because a story can be words alone. Non-nullable, there - * was nowhere for a text story to exist, which is why posting one was - * impossible rather than merely unbuilt, on both platforms. */ val ref: Attachment.Ref?, val caption: String, @@ -598,23 +469,10 @@ class Vault(private val storage: Storage, private val key: SecretKey) { val set: Content.StorySet? = null, /** * The group this story was posted to (§6.24), if any. - * - * Names the AUDIENCE, not a destination thread. A group story - * belongs in the story surface; rendering it into the group's messages - * would make a story into a message, which it is not. - * - * Persisted. Without it a reload forgets which story went where. */ val gid: String? = null, /** * A live stream this story embeds, if it is one (§6.13). - * - * Playing it connects the VIEWER to the stream host, revealing - * their address and that they watched. A live stream is continuous and - * hosted elsewhere, so unlike a link preview there is nothing to seal - * and no sender-side trick that avoids it. The URL is hidden from the - * relay; the connection is not hidden from the host, so [ref] is a - * POSTER, and playback is an explicit, host-named tap. */ val streamUrl: String? = null, ) { @@ -632,12 +490,7 @@ class Vault(private val storage: Storage, private val key: SecretKey) { } // -- Avatars ------------------------------------------------------------- - // - // Encrypted at rest like everything else here. An avatar is a face, - // and a face on disk in the clear is the one thing a stolen phone should - // not hand over about somebody's contacts. These are references, not - // bytes, the bytes live in the ordinary attachment cache and are fetched - // from the relay as ciphertext. + // Encrypted at rest like everything else here. /** The profile picture we last accepted from this contact, if any. */ fun avatarFor(userId: Long): Attachment.Ref? { @@ -645,34 +498,33 @@ class Vault(private val storage: Storage, private val key: SecretKey) { return runCatching { Attachment.Ref.fromJson(Attachment.IMAGE, json, 0.0) }.getOrNull() } - // -- Sealed group secrets (§8.9b) ----------------------------------------- + // -- Sealed group secrets (§8.9b) + // -----------------------------------------. /** * What this device holds for one sealed group. - * - * Lose these and the group is gone. The relay cannot tell you what - * you are a member of, that is the point of sealing, so the tag is - * the only way to fetch the group and the key is the only way to read it. - * Both ride the encrypted history export and the group invitation, the same - * paths the group name already uses. */ data class GroupSecrets( /** The opaque token that fetches this group from the relay. */ val tag: String, /** Opens the roster blob. */ val key: ByteArray, - /** Ed25519 private key iff we own this group, only the owner may - * rewrite a roster, and only with this. */ + /** + * Ed25519 private key iff we own this group, only the owner may. + */ val ownerPrivate: ByteArray? = null, - /** Last roster version seen, so a rewrite increments rather than - * guesses: the relay refuses a version that does not increase. */ + /** + * Last roster version seen, so a rewrite increments rather than. + */ val version: Int = 1, ) { override fun equals(other: Any?): Boolean = other is GroupSecrets && tag == other.tag && key.contentEquals(other.key) && version == other.version && - (ownerPrivate?.contentEquals(other.ownerPrivate ?: ByteArray(0)) - ?: (other.ownerPrivate == null)) + ( + ownerPrivate?.contentEquals(other.ownerPrivate ?: ByteArray(0)) + ?: (other.ownerPrivate == null) + ) override fun hashCode(): Int = tag.hashCode() * 31 + key.contentHashCode() } @@ -687,22 +539,31 @@ class Vault(private val storage: Storage, private val key: SecretKey) { ) } - fun saveGroupSecrets(groupId: String, secrets: GroupSecrets) { + fun saveGroupSecrets( + groupId: String, + secrets: GroupSecrets, + ) { val json = readJson(GROUP_SECRETS) ?: JSONObject() - json.put(groupId, JSONObject() - .put("tag", secrets.tag) - .put("key", Bytes.b64(secrets.key)) - .put("version", secrets.version) - .also { o -> secrets.ownerPrivate?.let { o.put("owner", Bytes.b64(it)) } }) + json.put( + groupId, + JSONObject() + .put("tag", secrets.tag) + .put("key", Bytes.b64(secrets.key)) + .put("version", secrets.version) + .also { o -> secrets.ownerPrivate?.let { o.put("owner", Bytes.b64(it)) } }, + ) writeJson(GROUP_SECRETS, json) } /** Every group this device can open, the only such list that exists. */ fun allGroupSecrets(): Map { val json = readJson(GROUP_SECRETS) ?: return emptyMap() - return json.keys().asSequence().mapNotNull { gid -> - groupSecrets(gid)?.let { gid to it } - }.toMap() + return json + .keys() + .asSequence() + .mapNotNull { gid -> + groupSecrets(gid)?.let { gid to it } + }.toMap() } /** Forget a group on leaving. The tag is surrendered separately. */ @@ -712,35 +573,24 @@ class Vault(private val storage: Storage, private val key: SecretKey) { writeJson(GROUP_SECRETS, json) } - // -- Group names --------------------------------------------------------- + // -- Group names ---------------------------------------------------------. /** * What each group is called. - * - * The relay used to hold this and no longer does. A group name is - * the most revealing single string it had, "Chemotherapy support", - * "Union organising", sitting in a column in the clear, for a field the - * relay never once read. It now lives only on the devices of the people in - * the group, learned over their pairwise ratchets like any other content, - * and here it is inside the sealed vault like everything else. - * - * A group we have not been told the name of returns null, and the UI - * shows the relay's generic placeholder rather than a gid. It fills in by - * itself the first time any member says anything, because every group - * envelope repeats the name. */ - fun groupName(groupId: String): String? = - readJson(GROUP_NAMES)?.optString(groupId)?.takeIf { it.isNotEmpty() } + fun groupName(groupId: String): String? = readJson(GROUP_NAMES)?.optString(groupId)?.takeIf { it.isNotEmpty() } - fun saveGroupName(groupId: String, name: String) { + fun saveGroupName( + groupId: String, + name: String, + ) { val json = readJson(GROUP_NAMES) ?: JSONObject() json.put(groupId, name.take(MAX_GROUP_NAME)) writeJson(GROUP_NAMES, json) } /** - * Forget a group's name on leaving it. The one string the server was asked - * to stop holding should not outlive the membership that justified it. + * Forget a group's name on leaving it. */ fun clearGroupName(groupId: String) { val json = readJson(GROUP_NAMES) ?: return @@ -750,33 +600,16 @@ class Vault(private val storage: Storage, private val key: SecretKey) { /** * A half-written message, kept across a relaunch. - * - * In the vault rather than in SharedPreferences, and that is the whole - * point. A draft is plaintext the person has not decided to send, very - * often the most sensitive text in the app, because it is the thing they - * are still weighing. `SharedPreferences` writes it as readable XML that - * survives into backups; here it is sealed under the same key as the - * conversation, with the record's own name bound into the AAD, and - * [forgetEverything] takes it on sign-out. - * - * Keyed by the same `threadFor*` ids the transcript uses, so a group, a - * local contact and somebody on another relay cannot collide, see the note - * on [threadForAddress] for why that third case is not theoretical. */ - fun draft(threadId: String): String? = - readJson(DRAFTS)?.optString(threadId)?.takeIf { it.isNotEmpty() } + fun draft(threadId: String): String? = readJson(DRAFTS)?.optString(threadId)?.takeIf { it.isNotEmpty() } /** - * Blank is not a draft. Storing " " would restore a composer that - * looks empty and reports itself as having content, which on this screen - * means an enabled send button that sends nothing. - * - * Stored AS TYPED though, not trimmed: somebody who left a trailing - * space was mid-word, and handing back a subtly different string than the - * one they left is the kind of small wrongness that makes an app feel - * untrustworthy. + * Blank is not a draft. */ - fun saveDraft(threadId: String, text: String) { + fun saveDraft( + threadId: String, + text: String, + ) { val json = readJson(DRAFTS) ?: JSONObject() if (text.isBlank()) json.remove(threadId) else json.put(threadId, text) writeJson(DRAFTS, json) @@ -784,28 +617,29 @@ class Vault(private val storage: Storage, private val key: SecretKey) { /** * Record a contact's picture. - * - * Last one wins, deliberately. A person who changes their picture sends - * a new reference and the old blob simply stops being asked for; there is - * no history of what somebody used to look like, and there should not be. */ - fun saveAvatar(userId: Long, ref: Attachment.Ref?) { + fun saveAvatar( + userId: Long, + ref: Attachment.Ref?, + ) { val json = readJson(AVATARS) ?: JSONObject() - if (ref == null) json.remove(userId.toString()) - else json.put(userId.toString(), ref.toJson()) + if (ref == null) { + json.remove(userId.toString()) + } else { + json.put(userId.toString(), ref.toJson()) + } writeJson(AVATARS, json) } /** * Which version of our picture a contact has already been told about. - * - * The blob id, not a boolean. A flag would say "told" for ever and a - * new picture would never reach anybody who already had the old one. */ - fun avatarSharedWith(userId: Long): String? = - readJson(AVATARS)?.optString("shared:$userId")?.takeIf { it.isNotEmpty() } + fun avatarSharedWith(userId: Long): String? = readJson(AVATARS)?.optString("shared:$userId")?.takeIf { it.isNotEmpty() } - fun markAvatarShared(userId: Long, blobId: String) { + fun markAvatarShared( + userId: Long, + blobId: String, + ) { val json = readJson(AVATARS) ?: JSONObject() json.put("shared:$userId", blobId) writeJson(AVATARS, json) @@ -818,8 +652,8 @@ class Vault(private val storage: Storage, private val key: SecretKey) { } fun saveMyAvatar(ref: Attachment.Ref?) { - // `storage.delete`, not `forget`, that one is session-scoped and - // would delete a record called `session/my-avatar` that does not exist, + // `storage.delete`, not `forget`, that one is session-scoped and would + // delete a record called `session/my-avatar` that does not exist, // leaving the picture in place while reporting success. if (ref == null) storage.delete(MY_AVATAR) else writeJson(MY_AVATAR, ref.toJson()) } @@ -831,17 +665,14 @@ class Vault(private val storage: Storage, private val key: SecretKey) { val array = record?.optJSONArray("items") ?: return emptyList() return (0 until array.length()).mapNotNull { i -> val o = array.getJSONObject(i) - // `optJSONObject`, not `getJSONObject`. A text story has no ref, - // and the throwing accessor would take down the load of EVERY - // story rather than skipping one, the feed would simply be empty - // with nothing to say why. + // `optJSONObject`, not `getJSONObject`. val refJson = o.optJSONObject("ref") - val ref = refJson?.let { - Attachment.Ref.fromJson(Attachment.STORY, it, it.optDouble("exp", 0.0)) - } + val ref = + refJson?.let { + Attachment.Ref.fromJson(Attachment.STORY, it, it.optDouble("exp", 0.0)) + } // A row with a ref that would not parse is broken; one with no ref - // at all is a text story. Telling those apart is the difference - // between dropping a corrupt row and dropping a valid one. + // at all is a text story. if (refJson != null && ref == null) return@mapNotNull null Story( id = o.getString("id"), @@ -860,32 +691,34 @@ class Vault(private val storage: Storage, private val key: SecretKey) { /** * Replace the stored set. - * - * Expired stories are dropped on WRITE rather than filtered on read, so - * the bytes actually leave the device. Filtering on read leaves a store - * that grows for ever and still holds every image somebody posted, which - * is the opposite of what an expiring story promises. */ - fun saveStories(stories: List, nowSeconds: Double) { - writeJson(STORIES, JSONObject() - .put("items", storiesToJson(stories.filter { it.isActive(nowSeconds) }))) + fun saveStories( + stories: List, + nowSeconds: Double, + ) { + writeJson( + STORIES, + JSONObject() + .put("items", storiesToJson(stories.filter { it.isActive(nowSeconds) })), + ) } /** Shared by the live set and the archive (ios#54). */ private fun storiesToJson(stories: List): org.json.JSONArray { val array = org.json.JSONArray() stories.forEach { story -> - val row = JSONObject() - .put("id", story.id) - .put("author", story.authorId) - .put("mine", story.mine) - .put("caption", story.caption) - .put("at", story.postedAt) - .put("seen", story.seen) - .put("expires", story.expires) + val row = + JSONObject() + .put("id", story.id) + .put("author", story.authorId) + .put("mine", story.mine) + .put("caption", story.caption) + .put("at", story.postedAt) + .put("seen", story.seen) + .put("expires", story.expires) // Only when there is one, `put(key, null)` REMOVES a key here, - // which is what we want, but being explicit is clearer than - // relying on it. + // which is what we want, but being explicit is clearer than relying + // on it. story.ref?.let { row.put("ref", it.toJson()) } story.set?.let { row.put("set", it.toJson()) } story.gid?.let { row.put("gid", it) } @@ -896,37 +729,24 @@ class Vault(private val storage: Storage, private val key: SecretKey) { /** * Stories this account posted, kept past their expiry (ios#54). - * - * Only ever your own, and only when the setting is on. This keeps - * something the interface told everybody would disappear. */ fun storyArchive(): List = storiesFrom(readJson(STORY_ARCHIVE)) - fun saveStoryArchive(stories: List) = - writeJson(STORY_ARCHIVE, JSONObject().put("items", storiesToJson(stories))) + fun saveStoryArchive(stories: List) = writeJson(STORY_ARCHIVE, JSONObject().put("items", storiesToJson(stories))) - // -- Key-log pin (§19.3) ------------------------------------------------- + // -- Key-log pin (§19.3) -------------------------------------------------. /** * The head this device last accepted, and the reason a rewritten log is * detectable at all. - * - * Trust on first use: absent, the first head seen is taken and pinned. - * Present, every later head must be provably an extension of it, so a - * relay that rewrites history has to produce a consistency proof between - * two trees where none exists, which is the one thing it cannot do. */ - data class KeyLogPin(val size: Int, val root: String) + data class KeyLogPin( + val size: Int, + val root: String, + ) /** - * Per relay, like [keyLogServed]. A single pin meant that signing in - * to a second relay checked ITS head against the first relay's root, which - * cannot reconcile, so an honest relay was reported as forked and every - * contact on it became unreachable. - * - * Reads fall back to the pre-2026-08-30 global entry, so a device already - * holding a pin keeps it rather than starting trust-on-first-use again. - * Writes only ever go to the per-relay entry. + * Per relay, like [keyLogServed]. */ fun loadKeyLogPin(relay: String? = null): KeyLogPin? { if (relay != null) { @@ -937,80 +757,57 @@ class Vault(private val storage: Storage, private val key: SecretKey) { return readJson(KEYLOG_PIN)?.let { KeyLogPin(it.getInt("size"), it.getString("root")) } } - fun saveKeyLogPin(pin: KeyLogPin, relay: String? = null) = - writeJson(if (relay == null) KEYLOG_PIN else KEYLOG_PIN + "-" + relay, - JSONObject().put("size", pin.size).put("root", pin.root)) + fun saveKeyLogPin( + pin: KeyLogPin, + relay: String? = null, + ) = writeJson( + if (relay == null) KEYLOG_PIN else KEYLOG_PIN + "-" + relay, + JSONObject().put("size", pin.size).put("root", pin.root), + ) /** * Whether this relay has ever answered the head route. - * - * This is what stops a relay switching transparency off. Key-log - * checking is automatic wherever a relay serves a log, which means a relay - * that serves none is skipped, so without a memory, the way out is one - * line of configuration: publish a log until clients are talking, then stop - * answering, and every client silently goes back to trusting whatever key - * it is handed. - * - * Recorded separately from [loadKeyLogPin] rather than inferred from it, - * because the two facts are different. A pin can arrive from anywhere, the - * gossip path writes one from a head a *contact* sent, while this says - * only that this device asked this relay for a head and got one. Inferring - * from the pin would make a gossiped head start demanding a log route the - * relay may never have had. */ + /** - * Per relay. A single global flag was the first version and was - * wrong: signing in to a second relay that keeps no log would have been - * read as the FIRST one going quiet, so an honest deployment without - * transparency became a fork warning and every contact on it became - * unreachable. + * Per relay. */ fun keyLogServed(relay: String): Boolean = readJson(KEYLOG_SERVED + "-" + relay) != null - fun rememberKeyLogServed(relay: String) = - writeJson(KEYLOG_SERVED + "-" + relay, JSONObject().put("seen", true)) + fun rememberKeyLogServed(relay: String) = writeJson(KEYLOG_SERVED + "-" + relay, JSONObject().put("seen", true)) - // -- Inbox watermark ----------------------------------------------------- + // -- Inbox watermark -----------------------------------------------------. /** * The highest inbox id already decrypted and stored. - * - * Advance it on receipt rather than after storing and a crash mid-batch - * loses the rest of the batch silently, the relay will never offer those - * ids again. */ var inboxWatermark: Long get() = readJson(WATERMARK)?.optLong("id", 0) ?: 0 set(value) = writeJson(WATERMARK, JSONObject().put("id", value)) - // -- §21.4 single-use delivery capabilities ------------------------------- + // -- §21.4 single-use delivery capabilities + // -------------------------------. /** * Capabilities we ISSUED, by contact. - * - * This map is the whole revocation mechanism, and it lives here - * rather than on the relay because the relay must not be able to tell - * senders apart, it holds hashes and cannot say whose they are. Losing it - * is losing the ability to cut one contact off. */ fun issuedTokens(userId: Long): List = tokenList(ISSUED_TOKENS, userId) - fun saveIssuedTokens(userId: Long, tokens: List) = - saveTokenList(ISSUED_TOKENS, userId, tokens) + fun saveIssuedTokens( + userId: Long, + tokens: List, + ) = saveTokenList(ISSUED_TOKENS, userId, tokens) /** Capabilities a contact issued US. Spent one per sealed message. */ fun peerTokens(userId: Long): List = tokenList(PEER_TOKENS, userId) - fun savePeerTokens(userId: Long, tokens: List) = - saveTokenList(PEER_TOKENS, userId, tokens) + fun savePeerTokens( + userId: Long, + tokens: List, + ) = saveTokenList(PEER_TOKENS, userId, tokens) /** * Take the next capability for a contact, removing it. - * - * Removed as it is taken, before the send is attempted. One reused after - * a failed send may already have been spent at the relay, and presenting it - * again is refused, so a lost send costs one capability rather than a - * conversation that quietly stops working. */ fun takePeerToken(userId: Long): String? { val held = peerTokens(userId).toMutableList() @@ -1022,61 +819,54 @@ class Vault(private val storage: Storage, private val key: SecretKey) { /** * Messages taken from a contact since they were issued a batch. - * - * This is how replenishment works without anybody asking. The holder - * cannot ask the relay how many it has left, the relay does not know whose - * are whose, but the issuer can count, because every message it receives - * from somebody spent one of theirs. */ - fun receivedSinceIssue(userId: Long): Int = - readJson(RECEIVED_SINCE + userId)?.optInt("n", 0) ?: 0 + fun receivedSinceIssue(userId: Long): Int = readJson(RECEIVED_SINCE + userId)?.optInt("n", 0) ?: 0 fun noteReceivedSinceIssue(userId: Long) = - writeJson(RECEIVED_SINCE + userId, - JSONObject().put("n", receivedSinceIssue(userId) + 1)) + writeJson( + RECEIVED_SINCE + userId, + JSONObject().put("n", receivedSinceIssue(userId) + 1), + ) - fun resetReceivedSinceIssue(userId: Long) = - writeJson(RECEIVED_SINCE + userId, JSONObject().put("n", 0)) + fun resetReceivedSinceIssue(userId: Long) = writeJson(RECEIVED_SINCE + userId, JSONObject().put("n", 0)) - private fun tokenList(prefix: String, userId: Long): List = + private fun tokenList( + prefix: String, + userId: Long, + ): List = readJson(prefix + userId)?.optJSONArray("t")?.let { a -> (0 until a.length()).map { a.getString(it) } } ?: emptyList() - private fun saveTokenList(prefix: String, userId: Long, tokens: List) { - if (tokens.isEmpty()) storage.delete(prefix + userId) - else writeJson(prefix + userId, - JSONObject().put("t", org.json.JSONArray(tokens))) + private fun saveTokenList( + prefix: String, + userId: Long, + tokens: List, + ) { + if (tokens.isEmpty()) { + storage.delete(prefix + userId) + } else { + writeJson( + prefix + userId, + JSONObject().put("t", org.json.JSONArray(tokens)), + ) + } } - // -- Extra mailboxes (§20.4) --------------------------------------------- + // -- Extra mailboxes (§20.4) ---------------------------------------------. /** * The inbox cursor for a relay other than the one this app signed in to. - * - * Per relay, and it has to be. Envelope ids come from each relay's - * own database, so id 40 at one home and id 40 at another are unrelated - * rows, one shared cursor would let the first home's progress silently - * skip the second home's mail. */ - fun watermarkFor(relay: String): Long = - readJson(WATERMARK + "-" + relay)?.optLong("id", 0) ?: 0 + fun watermarkFor(relay: String): Long = readJson(WATERMARK + "-" + relay)?.optLong("id", 0) ?: 0 - fun setWatermarkFor(relay: String, value: Long) = - writeJson(WATERMARK + "-" + relay, JSONObject().put("id", value)) + fun setWatermarkFor( + relay: String, + value: Long, + ) = writeJson(WATERMARK + "-" + relay, JSONObject().put("id", value)) /** * Whether this message has already been read from another mailbox. - * - * The point of several mailboxes is that a message goes to all of - * them. Without this the person reads it once per home, and would not, - * in fact, because the ratchet deletes a message key once it is used, so - * the second copy fails to decrypt. The visible symptom is therefore not a - * duplicate but a dropped envelope and a message that looks lost. - * - * Bounded, because the key comes from whoever wrote to us. Oldest first: a - * forgotten id can at worst show a very old message twice, which beats a - * list anybody can grow without limit. */ fun hasSeenEnvelope(id: String): Boolean = id in seenEnvelopeIds() @@ -1095,30 +885,16 @@ class Vault(private val storage: Storage, private val key: SecretKey) { /** * A relay this account keeps a mailbox at, and how to sign in there. - * - * A credential per relay, not one shared. An app password is minted - * by the relay that issued it and refused everywhere else, so a single - * stored credential authenticates at exactly one home and silently fails at - * the rest, which reads as "the backup mailbox never has anything in it". */ data class ExtraHome( /** * The hostname, as the signed home record names it. - * - * This is the identity of the mailbox and what a sender resolves, - * it is inside the signature, so it cannot carry a scheme or a port. */ val relay: String, val username: String, val appPassword: String, /** * How THIS device reaches that relay, which is a separate question. - * - * Defaults to `https://`, which is the only correct answer in - * production and the only one the record can express. Held separately - * rather than rebuilt at every call site so there is one place that - * decides, and so a self-hosted or test deployment reachable some other - * way does not need the record to lie about its own name. */ val baseUrl: String = "https://$relay", ) @@ -1128,17 +904,25 @@ class Vault(private val storage: Storage, private val key: SecretKey) { (0 until a.length()).map { val o = a.getJSONObject(it) val relay = o.getString("relay") - ExtraHome(relay, o.getString("user"), o.getString("pw"), - o.optString("url").ifEmpty { "https://$relay" }) + ExtraHome( + relay, + o.getString("user"), + o.getString("pw"), + o.optString("url").ifEmpty { "https://$relay" }, + ) } } ?: emptyList() fun saveExtraHomes(homes: List) { val array = org.json.JSONArray() homes.forEach { - array.put(JSONObject() - .put("relay", it.relay).put("user", it.username).put("pw", it.appPassword) - .put("url", it.baseUrl)) + array.put( + JSONObject() + .put("relay", it.relay) + .put("user", it.username) + .put("pw", it.appPassword) + .put("url", it.baseUrl), + ) } writeJson(EXTRA_HOMES, JSONObject().put("homes", array)) } @@ -1148,57 +932,42 @@ class Vault(private val storage: Storage, private val key: SecretKey) { storage.delete(WATERMARK + "-" + relay) } - // -- Message history ----------------------------------------------------- + // -- Message history -----------------------------------------------------. /** * Messages, encrypted at rest, keyed by the person rather than the device. - * - * Keyed by thread, not by device. A conversation is with someone, - * or with a group, and their phone and tablet are the same conversation. - * Filing by device shows one person as several threads that each hold half - * the story. Thread ids are `user:` and `group:`; see - * [threadForUser] and [threadForGroup]. - * - * The whole thread is rewritten on every append. That is fine for a - * chat and would not be for a log; if a thread ever grows large enough for - * this to matter, the fix is an append-only record format, not a bigger - * buffer. */ + /** * A thread, with anything past its disappearing-message timer already gone. - * - * Expiry happens here rather than on a schedule. Reading is the one - * path every caller shares, so pruning here is what makes it impossible to - * see, or keep, an expired message. A sweep that ran on a timer would - * leave the data on disk between runs, and "disappearing" that leaves the - * data in the vault is a claim, not a feature. - * - * `now` is injectable so the behaviour can be tested without sleeping. */ - fun history(threadId: String, now: Long = System.currentTimeMillis()): org.json.JSONArray = - pruneExpired(threadId, now) + fun history( + threadId: String, + now: Long = System.currentTimeMillis(), + ): org.json.JSONArray = pruneExpired(threadId, now) - fun append(threadId: String, message: JSONObject) { + fun append( + threadId: String, + message: JSONObject, + ) { val existing = history(threadId) existing.put(message) writeJson(historyName(threadId), JSONObject().put("messages", existing)) } - // -- Attention management: snooze and reply reminders --------------------- + // -- Attention management: snooze and reply reminders + // ---------------------. /** - * When this conversation stops being snoozed, epoch millis. 0 = not snoozed. - * - * In the vault and nowhere else. "I am ignoring this person until - * Tuesday" is a statement about a relationship, and it is exactly the kind - * of thing a server has no business holding. It also means a snooze does - * not follow you to a new device, which is the honest trade, the - * alternative is telling the relay who you are avoiding. + * When this conversation stops being snoozed, epoch millis. 0 = not + * snoozed. */ - fun snoozedUntil(threadId: String): Long = - readJson(SNOOZE)?.optLong(threadId, 0L) ?: 0L + fun snoozedUntil(threadId: String): Long = readJson(SNOOZE)?.optLong(threadId, 0L) ?: 0L - fun saveSnoozedUntil(threadId: String, until: Long) { + fun saveSnoozedUntil( + threadId: String, + until: Long, + ) { val all = readJson(SNOOZE) ?: JSONObject() if (until <= 0L) all.remove(threadId) else all.put(threadId, until) writeJson(SNOOZE, all) @@ -1207,20 +976,23 @@ class Vault(private val storage: Storage, private val key: SecretKey) { /** Thread ids whose snooze has not yet run out at [now]. */ fun snoozedThreads(now: Long): Set { val all = readJson(SNOOZE) ?: return emptySet() - return all.keys().asSequence().filter { all.optLong(it, 0L) > now }.toSet() + return all + .keys() + .asSequence() + .filter { all.optLong(it, 0L) > now } + .toSet() } /** - * When to remind about an unanswered message, epoch millis. 0 = no reminder. - * - * Cleared by a reply arriving, not by the reminder firing. The whole - * point is "remind me if they have not answered", so a reminder that still - * fires after they did is worse than none, it trains people to ignore it. + * When to remind about an unanswered message, epoch millis. 0 = no + * reminder. */ - fun replyReminder(threadId: String): Long = - readJson(REPLY_REMINDER)?.optLong(threadId, 0L) ?: 0L + fun replyReminder(threadId: String): Long = readJson(REPLY_REMINDER)?.optLong(threadId, 0L) ?: 0L - fun saveReplyReminder(threadId: String, at: Long) { + fun saveReplyReminder( + threadId: String, + at: Long, + ) { val all = readJson(REPLY_REMINDER) ?: JSONObject() if (at <= 0L) all.remove(threadId) else all.put(threadId, at) writeJson(REPLY_REMINDER, all) @@ -1229,50 +1001,45 @@ class Vault(private val storage: Storage, private val key: SecretKey) { /** Reminders that are due at [now], as thread id -> when it was set for. */ fun dueReplyReminders(now: Long): Map { val all = readJson(REPLY_REMINDER) ?: return emptyMap() - return all.keys().asSequence() + return all + .keys() + .asSequence() .mapNotNull { id -> all.optLong(id, 0L).takeIf { it in 1..now }?.let { id to it } } .toMap() } - // -- Disappearing messages (§6.11) --------------------------------------- + // -- Disappearing messages (§6.11) ---------------------------------------. /** * This conversation's timer in seconds, 0 when off. - * - * In the vault, like everything else. A per-conversation timer is a - * statement about how sensitive a relationship is, which is precisely the - * metadata this app keeps off the server. */ - fun disappearSeconds(threadId: String): Int = - readJson(DISAPPEAR)?.optInt(threadId, 0) ?: 0 + fun disappearSeconds(threadId: String): Int = readJson(DISAPPEAR)?.optInt(threadId, 0) ?: 0 /** * A bridge line a contact offered, and who offered it (§6.19). - * - * Stored, never applied. A bridge line routes every relay - * connection through a machine somebody else chose. Applying one on - * arrival would let a contact, or whoever has taken their account, move - * another person's traffic onto a host they control: not readable, because - * the ratchet does not care what carries it, but they would learn who is - * online and when, and could cut them off at will. So the transport layer - * writes it here and the person it was sent to is the only thing that can - * turn it on. - * - * One offer per contact, replaced rather than accumulated. An operator - * who rotates a bridge sends a new line, and a list of every line they - * ever had is a menu of stale hosts. */ fun bridgeOffers(): List { val all = readJson(BRIDGE_OFFERS) ?: return emptyList() - return all.keys().asSequence().mapNotNull { key -> - val o = all.optJSONObject(key) ?: return@mapNotNull null - val line = o.optString("line").takeIf { it.isNotBlank() } ?: return@mapNotNull null - BridgeOffer(fromUser = key.toLongOrNull() ?: return@mapNotNull null, - line = line, at = o.optLong("at", 0L)) - }.sortedByDescending { it.at }.toList() + return all + .keys() + .asSequence() + .mapNotNull { key -> + val o = all.optJSONObject(key) ?: return@mapNotNull null + val line = o.optString("line").takeIf { it.isNotBlank() } ?: return@mapNotNull null + BridgeOffer( + fromUser = key.toLongOrNull() ?: return@mapNotNull null, + line = line, + at = o.optLong("at", 0L), + ) + }.sortedByDescending { it.at } + .toList() } - fun saveBridgeOffer(fromUser: Long, line: String, at: Long) { + fun saveBridgeOffer( + fromUser: Long, + line: String, + at: Long, + ) { val all = readJson(BRIDGE_OFFERS) ?: JSONObject() all.put(fromUser.toString(), JSONObject().put("line", line).put("at", at)) writeJson(BRIDGE_OFFERS, all) @@ -1284,9 +1051,16 @@ class Vault(private val storage: Storage, private val key: SecretKey) { writeJson(BRIDGE_OFFERS, all) } - data class BridgeOffer(val fromUser: Long, val line: String, val at: Long) + data class BridgeOffer( + val fromUser: Long, + val line: String, + val at: Long, + ) - fun saveDisappearSeconds(threadId: String, seconds: Int) { + fun saveDisappearSeconds( + threadId: String, + seconds: Int, + ) { val all = readJson(DISAPPEAR) ?: JSONObject() if (seconds <= 0) all.remove(threadId) else all.put(threadId, seconds) writeJson(DISAPPEAR, all) @@ -1294,37 +1068,23 @@ class Vault(private val storage: Storage, private val key: SecretKey) { /** * Drop everything in this thread older than its timer, and persist that. - * - * Called from [history], so nothing can read past it. Expiry that - * runs on a timer, or only when a screen opens, leaves the messages on disk - * and "disappearing" that leaves the data sitting in the vault is a claim - * rather than a feature. Reading is the one path every caller shares. - * - * Ages are measured from the timestamp this device recorded, which for a - * received message is when it arrived and for a sent one is when it was - * sent. Deliberately not a clock value the peer supplies: a sender could - * otherwise backdate a message so it vanished from the recipient's phone - * immediately, or forward-date one so it never expired at all. - * - * Returns the surviving messages. */ - fun pruneExpired(threadId: String, now: Long): org.json.JSONArray { + fun pruneExpired( + threadId: String, + now: Long, + ): org.json.JSONArray { val conversation = disappearSeconds(threadId) - val stored = readJson(historyName(threadId))?.optJSONArray("messages") - ?: org.json.JSONArray() + val stored = + readJson(historyName(threadId))?.optJSONArray("messages") + ?: org.json.JSONArray() if (stored.length() == 0) return stored val kept = org.json.JSONArray() for (i in 0 until stored.length()) { val m = stored.getJSONObject(i) - // Per MESSAGE, not per thread. A conversation with no timer can - // still hold messages carrying their own (§6.17), so the old - // `if (seconds <= 0) return` skipped the whole thread and silently - // kept every timed message in it. + // Per MESSAGE, not per thread. val life = effectiveLifetime(m.optInt("secs", 0), conversation) - // A message with no timestamp predates the field. Kept rather than - // destroyed: an unknown age is not evidence of an old one, and - // deleting somebody's history on a guess is unrecoverable. + // A message with no timestamp predates the field. val at = m.optLong("at", 0L) if (life == null || at == 0L || at >= now - life * 1000L) kept.put(m) } @@ -1336,53 +1096,30 @@ class Vault(private val storage: Storage, private val key: SecretKey) { /** * Messages typed but not yet delivered to anybody. - * - * This has to live here, on the sender's phone, and that is forced by - * the encryption rather than chosen. A relay cannot hold a message for - * somebody with no registered device: every envelope is sealed to a - * specific device's keys, so with no device there is nothing to seal it to. - * The relay could only queue plaintext, which is the one thing this app - * exists not to do. So the sender keeps it and sends it when they can. - * - * In the vault, so it is encrypted at rest and survives being killed, - * an outbox that a task-switcher can empty is not an outbox. */ fun outbox(): JSONArray = readJson(OUTBOX)?.optJSONArray("pending") ?: JSONArray() - fun saveOutbox(pending: JSONArray) = - writeJson(OUTBOX, JSONObject().put("pending", pending)) + fun saveOutbox(pending: JSONArray) = writeJson(OUTBOX, JSONObject().put("pending", pending)) /** * Calls placed and received, newest last. - * - * In the vault like everything else. Who somebody rang and when is - * exactly the metadata this app exists to keep off a server, and it would - * be strange to encrypt the words and leave the call log in the clear. - * - * Bounded. A log nobody prunes grows for the life of the install, and - * this one is re-sealed on every write. */ fun callLog(): JSONArray = readJson(CALL_LOG)?.optJSONArray("calls") ?: JSONArray() fun saveCallLog(calls: JSONArray) { - val trimmed = if (calls.length() <= CALL_LOG_MAX) calls else JSONArray().also { out -> - for (i in (calls.length() - CALL_LOG_MAX) until calls.length()) out.put(calls.get(i)) - } + val trimmed = + if (calls.length() <= CALL_LOG_MAX) { + calls + } else { + JSONArray().also { out -> + for (i in (calls.length() - CALL_LOG_MAX) until calls.length()) out.put(calls.get(i)) + } + } writeJson(CALL_LOG, JSONObject().put("calls", trimmed)) } /** * Who may see this account's stories. - * - * Enforced by who receives the bytes, not by a flag. A story is - * sealed to each recipient's devices through the ratchet, so somebody - * outside the audience is never sent it at all, there is no copy on the - * relay for them to be denied. That is a stronger guarantee than every - * mainstream product, where the server holds one copy and decides who is - * shown it, and it is worth not weakening later for convenience. - * - * In the vault. An audience list is a statement about who somebody - * trusts, which is exactly the metadata this app exists to keep private. */ fun storyAudience(): JSONObject? = readJson(STORY_AUDIENCE) @@ -1390,11 +1127,6 @@ class Vault(private val storage: Storage, private val key: SecretKey) { /** * Whose stories this device does not show. - * - * Encrypted at rest with everything else in here. Who somebody has - * quietly stopped watching is exactly the kind of thing a stolen phone - * should not hand over, and it is a judgement about a person, which is - * worse to leak than a preference. */ fun storyMutes(): Set { val array = readJson(STORY_MUTES)?.optJSONArray("ids") ?: return emptySet() @@ -1407,86 +1139,59 @@ class Vault(private val storage: Storage, private val key: SecretKey) { writeJson(STORY_MUTES, JSONObject().put("ids", array)) } - // -- Key-log issuer pin (audit M1) --------------------------------------- + // -- Key-log issuer pin (audit M1) ---------------------------------------. /** * The relay's log-signing key, pinned on first sight. - * - * Pinned, not fetched-and-trusted: a key taken fresh from the same relay - * whose signature it checks proves nothing, the relay would serve whatever - * key matches what it just signed. Pinned once, it is what makes a fork - * evidence rather than an accusation. */ fun keyLogIssuerPin(): ByteArray? = - readJson(KT_ISSUER_PIN)?.optString("key")?.takeIf { it.isNotEmpty() } + readJson(KT_ISSUER_PIN) + ?.optString("key") + ?.takeIf { it.isNotEmpty() } ?.let { runCatching { Bytes.unb64(it) }.getOrNull() } - fun saveKeyLogIssuerPin(key: ByteArray) = - writeJson(KT_ISSUER_PIN, JSONObject().put("key", Bytes.b64(key))) + fun saveKeyLogIssuerPin(key: ByteArray) = writeJson(KT_ISSUER_PIN, JSONObject().put("key", Bytes.b64(key))) - // -- Device → owner binding (audit C1/C2) -------------------------------- + // -- Device → owner binding (audit C1/C2) --------------------------------. /** * The user a device is bound to, first-use-wins. - * - * The ratchet proves which DEVICE wrote a message; the user id inside a - * sealed envelope is a claim its author chose. Without this binding a - * contact could encrypt on their own session and stamp another user's id - * on it, the message would be filed and named as that person, and worse, - * allowed to overwrite their delivery key (which forces our next send to - * them UNSEALED, handing the relay the sender sealing exists to hide). */ - fun bindDeviceOwner(userId: Long, deviceId: String): Long { + fun bindDeviceOwner( + userId: Long, + deviceId: String, + ): Long { deviceOwner(deviceId)?.let { return it } if (userId <= 0L) return userId writeJson(DEVICE_OWNER_PREFIX + deviceId, JSONObject().put("user", userId)) return userId } - fun deviceOwner(deviceId: String): Long? = - readJson(DEVICE_OWNER_PREFIX + deviceId)?.optLong("user")?.takeIf { it > 0L } + fun deviceOwner(deviceId: String): Long? = readJson(DEVICE_OWNER_PREFIX + deviceId)?.optLong("user")?.takeIf { it > 0L } /** Forget a binding, used when an identity change is accepted. */ fun clearDeviceOwner(deviceId: String) = storage.delete(DEVICE_OWNER_PREFIX + deviceId) // -- The address keyspace (§20.5) ---------------------------------------- - // - // A federated peer has no user id here. Their relay assigned them one and - // it means nothing on ours, user 12 there and user 12 here are unrelated - // people, so everything below is keyed by `fnid@relay` instead. Kept apart - // from the local keyspace on purpose: collapsing the two would let a claim - // about an address decide who a LOCAL user id belongs to, which is the one - // crossing a federation boundary must never be able to make. + // A federated peer has no user id here. /** * Bind a device to the address it first claimed, first use wins. - * - * Call only once the inner envelope has decrypted. The address is a - * claim from inside the seal and anyone holding this device's published - * `IK_dh` can write what they like there; what the ratchet proves is the - * DEVICE. Binding the two means a device that later claims a different - * address keeps the one it was first seen with, so an established thread - * cannot be renamed out from under the person reading it. - * - * Returns the address in force, which may not be the one claimed. */ - fun bindRemoteAddress(address: String, deviceId: String): String { + fun bindRemoteAddress( + address: String, + deviceId: String, + ): String { remoteAddressFor(deviceId)?.let { return it } if (address.isEmpty()) return address writeJson(REMOTE_ADDRESS_PREFIX + deviceId, JSONObject().put("addr", address)) return address } - fun remoteAddressFor(deviceId: String): String? = - readJson(REMOTE_ADDRESS_PREFIX + deviceId)?.optString("addr")?.ifEmpty { null } + fun remoteAddressFor(deviceId: String): String? = readJson(REMOTE_ADDRESS_PREFIX + deviceId)?.optString("addr")?.ifEmpty { null } /** * The identity keys pinned for an address, trust on first use. - * - * The same rule as the local pin and in the other keyspace: every key - * offered must already be known, because a key never seen before is either - * a new device of theirs or one their relay minted, and nothing on the wire - * distinguishes those. */ fun pinnedAddressIdentities(address: String): List { val stored = readJson(REMOTE_PIN_PREFIX + address) ?: return emptyList() @@ -1496,7 +1201,10 @@ class Vault(private val storage: Storage, private val key: SecretKey) { } } - fun pinAddressIdentities(address: String, keys: List) { + fun pinAddressIdentities( + address: String, + keys: List, + ) { val array = org.json.JSONArray() keys.forEach { array.put(Bytes.b64(it)) } writeJson(REMOTE_PIN_PREFIX + address, JSONObject().put("keys", array)) @@ -1510,21 +1218,16 @@ class Vault(private val storage: Storage, private val key: SecretKey) { fun rememberRemoteAddress(address: String) { val current = remoteAddresses().filter { it != address } + address - writeJson(REMOTE_ADDRESSES, JSONObject().put( - "addrs", org.json.JSONArray(current.takeLast(MAX_REMOTE_ADDRESSES)))) + writeJson( + REMOTE_ADDRESSES, + JSONObject().put("addrs", org.json.JSONArray(current.takeLast(MAX_REMOTE_ADDRESSES))), + ) } - // -- Contact list (P1) --------------------------------------------------- + // -- Contact list (P1) ---------------------------------------------------. /** * The contact list, once it lives here rather than on the relay. - * - * `offServer` is the one-way door: once true, adds and removes are - * local-only and the relay holds no copy. Kept in the vault like - * everything else, who somebody talks to is exactly the metadata this - * app exists to keep private, and exported/merged with the history - * backup, so a new device restores its contacts from the user's own - * backup rather than from a server-side graph. */ fun loadRoster(): Pair { val json = readJson(ROSTER) ?: return org.json.JSONArray() to false @@ -1532,23 +1235,21 @@ class Vault(private val storage: Storage, private val key: SecretKey) { json.optBoolean("offServer", false) } - fun saveRoster(items: org.json.JSONArray, offServer: Boolean) = - writeJson(ROSTER, JSONObject().put("items", items).put("offServer", offServer)) + fun saveRoster( + items: org.json.JSONArray, + offServer: Boolean, + ) = writeJson(ROSTER, JSONObject().put("items", items).put("offServer", offServer)) /** Rewrite a thread wholesale, for restating a message's delivery state. */ - fun replaceHistory(threadId: String, messages: JSONArray) = - writeJson(historyName(threadId), JSONObject().put("messages", messages)) + fun replaceHistory( + threadId: String, + messages: JSONArray, + ) = writeJson(historyName(threadId), JSONObject().put("messages", messages)) - // -- History backup (D4) ------------------------------------------------- + // -- History backup (D4) -------------------------------------------------. /** * Everything a history backup carries, as one JSON value. - * - * The outbox is deliberately left out: a pending send belongs to the - * device that queued it, and importing one elsewhere would either send a - * duplicate or strand a ghost that can never be flushed. Sessions, trust - * and keys are left out too, a backup restores what was said, never who - * this device is. */ fun exportPayload(): JSONObject { val threadsObj = JSONObject() @@ -1558,40 +1259,25 @@ class Vault(private val storage: Storage, private val key: SecretKey) { .put("callLog", callLog()) .also { p -> readJson(STORIES)?.let { p.put("stories", it) } } .also { p -> readJson(ROSTER)?.let { p.put("roster", it) } } - // The relay does not hold group names any more, so this - // device's copy is the only copy. Before that change a restored - // device got them back from the server for free; an export that - // carried the messages but not the names would restore a chat list - // of groups all called "Group", the exact loss this file exists to - // prevent. + // The relay does not hold group names any more, so this device's + // copy is the only copy. .also { p -> readJson(GROUP_NAMES)?.let { p.put("groupNames", it) } } - // Without these a restored device loses its sealed groups for - // good. The relay cannot say what you are a member of, that is - // the point of sealing, so the tag is the only way to fetch a group - // and the key the only way to read it. A backup carrying the - // messages but not these restores a device that can see old - // conversations and can never rejoin the groups they came from. - // - // The most sensitive thing this export holds: a group key opens - // every roster it ever sealed. The export is already encrypted under - // a passphrase-derived key, which is what makes carrying it - // defensible at all. + // Without these a restored device loses its sealed groups for good. .also { p -> readJson(GROUP_SECRETS)?.let { p.put("groupSecrets", it) } } } - data class ImportCounts(val messages: Int, val stories: Int) + data class ImportCounts( + val messages: Int, + val stories: Int, + ) /** - * Merge a backup into what this device already has. Returns how much of - * it was actually new. - * - * Merge, never replace: the device has been receiving mail since it - * signed in, and an import that overwrote those rows would trade old - * history for new. Lines deduplicate by content (mine + timestamp + text - * + attachment), stories by their real id, calls by peer + timestamp, - * and the same backup imported twice adds nothing the second time. + * Merge a backup into what this device already has. */ - fun mergeImportedPayload(payload: JSONObject, nowSeconds: Double): ImportCounts { + fun mergeImportedPayload( + payload: JSONObject, + nowSeconds: Double, + ): ImportCounts { var newMessages = 0 val threadsObj = payload.optJSONObject("threads") ?: JSONObject() for (tid in threadsObj.keys()) { @@ -1621,8 +1307,8 @@ class Vault(private val storage: Storage, private val key: SecretKey) { for (i in 0 until current.length()) known.add(current.getJSONObject(i).optString("id")) for (i in 0 until importedStories.length()) { val s = importedStories.optJSONObject(i) ?: continue - // An expired story must not sneak back in through a backup, - // the expiry is a promise about the bytes leaving the device. + // An expired story must not sneak back in through a backup, the + // expiry is a promise about the bytes leaving the device. val exp = s.optJSONObject("ref")?.optDouble("exp", 0.0) ?: 0.0 val active = exp == 0.0 || nowSeconds < exp if (active && s.optString("id").isNotEmpty() && known.add(s.optString("id"))) { @@ -1657,8 +1343,8 @@ class Vault(private val storage: Storage, private val key: SecretKey) { } } - // The contact list rides the backup too (P1): union by id, and the - // off-server mode is adopted if the backup carries it. + // The contact list rides the backup too (P1): union by id, and the off- + // server mode is adopted if the backup carries it. payload.optJSONObject("roster")?.let { imported -> val (current, offServer) = loadRoster() val known = HashSet() @@ -1671,14 +1357,8 @@ class Vault(private val storage: Storage, private val key: SecretKey) { saveRoster(current, offServer || imported.optBoolean("offServer", false)) } - // Group names ride the backup, because nothing else can supply - // them now. The relay stopped holding them, so a restored device that - // did not read them here would show every group as "Group" until some - // other member happened to send a message. - // - // Only for groups this device has no name for. A backup is older - // than the device's own state by definition, and a rename made since - // must not be undone by restoring. + // Group names ride the backup, because nothing else can supply them + // now. payload.optJSONObject("groupNames")?.let { imported -> for (gid in imported.keys()) { if (groupName(gid) != null) continue @@ -1687,15 +1367,16 @@ class Vault(private val storage: Storage, private val key: SecretKey) { } } - // §8.9b, gaps only, like the names. A backup is older than the device - // by definition, and a roster version that went backwards would be - // refused by the relay on the next rewrite anyway. + // §8.9b, gaps only, like the names. payload.optJSONObject("groupSecrets")?.let { imported -> val existing = readJson(GROUP_SECRETS) ?: JSONObject() var changed = false for (gid in imported.keys()) { if (existing.has(gid)) continue - imported.optJSONObject(gid)?.let { existing.put(gid, it); changed = true } + imported.optJSONObject(gid)?.let { + existing.put(gid, it) + changed = true + } } if (changed) writeJson(GROUP_SECRETS, existing) } @@ -1710,26 +1391,6 @@ class Vault(private val storage: Storage, private val key: SecretKey) { /** * Every attachment id this device still has a reason to hold bytes for. - * - * Refs are found by SHAPE, not by looking in the places we remember. - * An attachment reference serialises with `id`, `key` and `digest` - * together, and nothing else in the vault has all three, so this walks - * every record and matches on that, wherever a ref happens to sit. The - * alternative, a hand-written list of stores, is wrong the day somebody - * adds a store and does not update the list: the sweep that uses this would - * then delete attachments that are still in use, which is a far worse - * failure than the one it exists to fix. - * - * It really is every record, and that is not paranoia. Enumerating by - * hand, the obvious sources are message history and stories, and the vault - * also keeps refs in the story ARCHIVE, in per-contact avatars and in this - * account's own avatar. A sweep built on the obvious two would have deleted - * every avatar on the device the first time it ran. - * - * Throws rather than returning a partial answer. A record that - * cannot be opened means this set is incomplete, and an incomplete set of - * "still in use" is exactly the input that turns a cleanup into data loss. - * The caller must treat a throw as "do not sweep". */ fun attachmentIdsInUse(): Set { val found = mutableSetOf() @@ -1741,19 +1402,18 @@ class Vault(private val storage: Storage, private val key: SecretKey) { } /** - * Depth-bounded. The walk is over data this device wrote, but a record - * is a file on disk and a cycle or a pathological nesting must not become - * a stack overflow inside a cleanup. + * Depth-bounded. */ - private fun collectAttachmentIds(node: Any?, into: MutableSet, depth: Int = 0) { + private fun collectAttachmentIds( + node: Any?, + into: MutableSet, + depth: Int = 0, + ) { if (depth > 24) return when (node) { is JSONObject -> { val id = node.optString("id") // The three together are what makes it a reference to bytes. - // A sticker carries `pack` + `id`; a story SET carries - // `id` + `i` + `n` and its id names the sequence, not a blob. - // Neither has a key or a digest, and neither is matched here. if (id.isNotEmpty() && node.optString("key").isNotEmpty() && node.optString("digest").isNotEmpty() @@ -1762,73 +1422,58 @@ class Vault(private val storage: Storage, private val key: SecretKey) { } for (key in node.keys()) collectAttachmentIds(node.opt(key), into, depth + 1) } - is JSONArray -> + + is JSONArray -> { for (i in 0 until node.length()) collectAttachmentIds(node.opt(i), into, depth + 1) + } } } /** Every thread this device holds, group or person. */ fun threads(): List = - storage.names().filter { it.startsWith(HISTORY_PREFIX) } + storage + .names() + .filter { it.startsWith(HISTORY_PREFIX) } .map { it.removePrefix(HISTORY_PREFIX) } private fun historyName(threadId: String) = HISTORY_PREFIX + threadId - // -- Forgetting ---------------------------------------------------------- + // -- Forgetting ----------------------------------------------------------. /** Forget one device's session. The identity and every other session survive. */ fun forget(peerDeviceId: String) = storage.delete(sessionName(peerDeviceId)) /** * Delete a thread's history. - * - * Deliberately does NOT delete the session. Clearing what was said is - * not the same as ending the conversation, and dropping the ratchet would - * make the next message from that person undecryptable, a "clear chat" - * that quietly breaks the chat. */ fun forgetHistory(threadId: String) { storage.delete(historyName(threadId)) - // The pin goes with the transcript. A banner pointing at a message - // that was just cleared is a reference to nothing, and it would survive - // "clear this conversation", which reads as the clear not having - // worked. + // The pin goes with the transcript. storage.delete(pinnedName(threadId)) } - // -- Wallet ------------------------------------------------------------- + // -- Wallet -------------------------------------------------------------. private val walletName = "wallet-record" /** * The wallet's ENTROPY, sealed at rest, or null if there is no wallet. - * - * The entropy, never the seed. The seed is a one-way function of the - * phrase, so a record holding it would open the wallet and be unable to - * produce the words, somebody who lost their phrase but still had their - * account could spend, and could never write the phrase down again or move - * to another wallet. Self-custody in name only. Keeping the entropy means - * the phrase can always be regenerated from the record. - * - * Sealed by the vault, which means erasing this device destroys it. - * Plain sign-out keeps it (and so does a password change, which signs out - * plainly); "erase this phone" and "delete account" do not. After those, the - * recovery phrase is the only copy that exists, which is the point of - * a recovery phrase, and has to be SAID rather than discovered. */ fun walletEntropy(): ByteArray? = - readJson(walletName)?.optString("entropy")?.takeIf { it.isNotEmpty() } + readJson(walletName) + ?.optString("entropy") + ?.takeIf { it.isNotEmpty() } ?.let { runCatching { Bytes.unb64(it) }.getOrNull() } fun saveWalletEntropy(entropy: ByteArray) { val existing = readJson(walletName) - writeJson(walletName, JSONObject() - .put("entropy", Bytes.b64(entropy)) - // The counter is carried across a rewrite rather than reset. A - // restore that zeroed it would hand out addresses somebody has - // already been paid at, undoing the one linkability the wallet can - // prevent. - .put("next", existing?.optInt("next", 0) ?: 0)) + writeJson( + walletName, + JSONObject() + .put("entropy", Bytes.b64(entropy)) + // The counter is carried across a rewrite rather than reset. + .put("next", existing?.optInt("next", 0) ?: 0), + ) } /** How many receive addresses have been handed out. */ @@ -1836,9 +1481,6 @@ class Vault(private val storage: Storage, private val key: SecretKey) { /** * Advance the receive counter. - * - * Never goes backwards, for the reason above: an index that repeated - * would show an address that has already been used. */ fun saveWalletNextIndex(next: Int) { val record = readJson(walletName) ?: return @@ -1849,102 +1491,85 @@ class Vault(private val storage: Storage, private val key: SecretKey) { /** Forget the wallet entirely. The phrase is then the only copy. */ fun forgetWallet() = storage.delete(walletName) - // -- The Files identity -------------------------------------------------- + // -- The Files identity --------------------------------------------------. private val filesIdentityName = "files-identity" /** * The passphrase-protected Files identity. - * - * Sealed twice, and the second seal is the one that matters. The - * vault around it is bound to this device's Keystore key, so the record does - * not leave the phone. The record ITSELF is encrypted under a key stretched - * from a passphrase this app deliberately does not keep, which is what - * makes an unlocked, borrowed phone still not open somebody's files. - * - * Dropping the inner seal for convenience would be a real reduction and - * not a refactor: everything about Files assumes the classification means - * something, and a vault that opens because the phone is open is a folder - * with a lock drawn on it. */ fun filesIdentityRecord(): FilesIdentity.Record? = readJson(filesIdentityName)?.let { runCatching { FilesIdentity.Record.parse(it.toString()) }.getOrNull() } - fun saveFilesIdentityRecord(record: FilesIdentity.Record) = - writeJson(filesIdentityName, JSONObject(record.json())) + fun saveFilesIdentityRecord(record: FilesIdentity.Record) = writeJson(filesIdentityName, JSONObject(record.json())) /** * Forgetting this makes every vaulted file permanently unreadable, the - * label keys are derived from the identity and exist nowhere else. Only - * "erase this phone" and "delete account" should ever reach it. + * label keys are derived from the identity and exist nowhere else. */ fun forgetFilesIdentity() = storage.delete(filesIdentityName) - // -- Home-record rollback floor (§20.4) ---------------------------------- + // -- Home-record rollback floor (§20.4) ----------------------------------. - private fun homeSerialName(fnid: String) = "home-serial-${Bytes.b64(Bytes.sha256(fnid.toByteArray(Charsets.UTF_8))).take(22).replace('/', '_')}" + private fun homeSerialName(fnid: String) = + "home-serial-${Bytes.b64(Bytes.sha256(fnid.toByteArray(Charsets.UTF_8))).take(22).replace('/', '_')}" /** * The highest home-record serial seen for an address, or null. - * - * This is what makes the serial mean anything on the reading side. An - * older home record is signed, published by the account holder - * before they added a second relay, so a relay serving it is handing out - * something that verifies. Remembering the highest seen is the only thing - * that turns "this signature is valid" into "this is not a rollback". - * - * Sealed like everything else here: which addresses this device has - * looked up is exactly the metadata the vault exists to keep off a stolen - * phone, and the name is a hash of the address for the same reason. */ - fun homeSerial(fnid: String): Int? = - readJson(homeSerialName(fnid))?.optInt("serial", -1)?.takeIf { it >= 0 } + fun homeSerial(fnid: String): Int? = readJson(homeSerialName(fnid))?.optInt("serial", -1)?.takeIf { it >= 0 } /** Remember a serial, never going backwards. */ - fun saveHomeSerial(fnid: String, serial: Int) { + fun saveHomeSerial( + fnid: String, + serial: Int, + ) { val seen = homeSerial(fnid) ?: -1 if (serial <= seen) return writeJson(homeSerialName(fnid), JSONObject().put("serial", serial)) } - // -- Pinned message (§6.19) ---------------------------------------------- + // -- Pinned message (§6.19) ----------------------------------------------. private fun pinnedName(threadId: String) = "pinned-$threadId" /** * The message pinned in a thread, or null. - * - * Encrypted at rest like the transcript, not a preference. Which - * message somebody pinned in which conversation is exactly the shape of - * metadata the vault exists to keep off a stolen phone, and it is stored - * as an ID, never a copy, so it cannot go stale against an edit or outlive - * a deletion. */ - fun pinnedMessage(threadId: String): String? = - readJson(pinnedName(threadId))?.optString("mid")?.takeIf { it.isNotEmpty() } + fun pinnedMessage(threadId: String): String? = readJson(pinnedName(threadId))?.optString("mid")?.takeIf { it.isNotEmpty() } /** Pin a message, or clear the pin with null. */ - fun savePinnedMessage(threadId: String, messageId: String?) { - if (messageId.isNullOrEmpty()) storage.delete(pinnedName(threadId)) - else writeJson(pinnedName(threadId), JSONObject().put("mid", messageId)) + fun savePinnedMessage( + threadId: String, + messageId: String?, + ) { + if (messageId.isNullOrEmpty()) { + storage.delete(pinnedName(threadId)) + } else { + writeJson(pinnedName(threadId), JSONObject().put("mid", messageId)) + } } /** * Delete one line from a thread's history (delete for me). - * - * Keyed by timestamp + side, the same identity the delivery-state - * rewriter uses, messages carry no stable id here. Text is included so - * two messages sent in the same millisecond can't take each other down. */ - fun deleteLine(threadId: String, at: Long, mine: Boolean, text: String) { + fun deleteLine( + threadId: String, + at: Long, + mine: Boolean, + text: String, + ) { val existing = history(threadId) val kept = org.json.JSONArray() for (i in 0 until existing.length()) { val m = existing.getJSONObject(i) if (m.optLong("at") == at && m.optBoolean("mine") == mine && - m.optString("text") == text) continue + m.optString("text") == text + ) { + continue + } kept.put(m) } replaceHistory(threadId, kept) @@ -1952,18 +1577,17 @@ class Vault(private val storage: Storage, private val key: SecretKey) { /** * Forget everything, identity included. - * - * This is sign-out and account deletion. Leaving the identity behind would - * mean the next person to use the phone inherits the device the relay still - * has published, and messages addressed to it. */ fun forgetEverything() = storage.names().forEach(storage::delete) /** Forget conversations but keep this device's identity. */ - /** Retired sessions too, they hold chain keys, and a sign-out that - * left them behind would keep exactly what it claims to erase. */ + + /** + * Retired sessions too, they hold chain keys, and a sign-out that. + */ fun forgetAllSessions() = - storage.names() + storage + .names() .filter { it.startsWith(SESSION_PREFIX) || it.startsWith(RETIRED_PREFIX) } .forEach(storage::delete) } diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Wallet.kt b/engine/src/main/kotlin/io/flamenet/e2e/Wallet.kt index 3b5b27d..5a8d14f 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/Wallet.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/Wallet.kt @@ -4,72 +4,59 @@ import java.security.SecureRandom /** * Creating, importing and reading back a wallet. - * - * Addresses are independent of who you are, by construction. The entropy - * is fresh randomness, never derived from the passphrase, the handle, or the - * Flamenet identity, so no address can be computed from an account and no - * account from an address. Deriving a wallet from the account passphrase would - * be *convenient*, would need no backup at all, and would tie every payment - * somebody ever received to their handle on a public ledger for ever. It is the - * one shortcut this file exists to refuse. - * - * The phrase is the only copy that survives erasing the device. The - * record is sealed by the vault, so plain sign-out keeps it and a password - * change keeps it, but "erase this phone" and "delete account" destroy it. - * That is what a recovery phrase is for, and it has to be said out loud at the - * moment the phrase is shown, not discovered afterwards. */ object Wallet { - /** - * A new wallet: the phrase to write down, and the entropy to store. - * - * 256 bits by default, 24 words. The extra length costs nothing to - * store and is what people expect from a wallet; 128 bits stays available - * for anybody who wants 12. - * - * `SecureRandom()` with no seeding call. Seeding it from anything, - * a timestamp, a device id, the account, would make the wallet - * reconstructible from things that are not secret, which is the same - * failure as deriving it from the passphrase. + * A new wallet: the phrase to write down, and the entropy to store. 256 + * bits by default, 24 words. */ - fun create(words: List, entropyBytes: Int = 32, random: SecureRandom = SecureRandom()): - Created { + fun create( + words: List, + entropyBytes: Int = 32, + random: SecureRandom = SecureRandom(), + ): Created { val entropy = ByteArray(entropyBytes).also(random::nextBytes) return Created(phrase = Bip39.phrase(entropy, words), entropy = entropy) } - data class Created(val phrase: String, val entropy: ByteArray) { - override fun equals(other: Any?) = other is Created && - phrase == other.phrase && entropy.contentEquals(other.entropy) + data class Created( + val phrase: String, + val entropy: ByteArray, + ) { + override fun equals(other: Any?) = + other is Created && + phrase == other.phrase && entropy.contentEquals(other.entropy) + override fun hashCode() = 31 * phrase.hashCode() + entropy.contentHashCode() } /** * Adopt a phrase that came from somewhere else. - * - * Validated before it is stored. A phrase that fails its checksum is - * refused here rather than saved and discovered later, when the wallet it - * names turns out to be empty and nobody can say why. */ - fun import(phrase: String, words: List): ByteArray = - Bip39.entropy(phrase, words) + fun import( + phrase: String, + words: List, + ): ByteArray = Bip39.entropy(phrase, words) /** The phrase again, from stored entropy, for showing it a second time. */ - fun phrase(entropy: ByteArray, words: List): String = - Bip39.phrase(entropy, words) + fun phrase( + entropy: ByteArray, + words: List, + ): String = Bip39.phrase(entropy, words) /** * The 64-byte seed everything derives from. - * - * [passphrase] is BIP-39's, not the account's. A different one silently - * yields a DIFFERENT valid wallet, the standard's design, and a sharp edge - * worth naming wherever it is offered to somebody. */ - fun seed(entropy: ByteArray, words: List, passphrase: String = ""): ByteArray = - Bip39.seed(phrase(entropy, words), passphrase) + fun seed( + entropy: ByteArray, + words: List, + passphrase: String = "", + ): ByteArray = Bip39.seed(phrase(entropy, words), passphrase) /** The account this wallet's addresses come from. */ - fun account(entropy: ByteArray, words: List, nextIndex: Long = 0): HdAccount = - HdAccount(seed(entropy, words), nextIndex = nextIndex) + fun account( + entropy: ByteArray, + words: List, + nextIndex: Long = 0, + ): HdAccount = HdAccount(seed(entropy, words), nextIndex = nextIndex) } diff --git a/engine/src/main/kotlin/io/flamenet/e2e/X25519.kt b/engine/src/main/kotlin/io/flamenet/e2e/X25519.kt index 293f275..d0dddae 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/X25519.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/X25519.kt @@ -9,58 +9,43 @@ import javax.crypto.KeyAgreement /** * X25519 Diffie-Hellman. - * - * The protocol moves raw 32-byte public keys, that is what the wire carries, - * what CryptoKit's `rawRepresentation` gives, and what the JavaScript engine - * exports. The JVM and Android both insist on X.509 `SubjectPublicKeyInfo` - * instead, so every crossing of that boundary needs the DER wrapper added or - * stripped. - * - * API 33+ on Android. `XDH` arrived in the platform with Android 13. On - * anything older this throws `NoSuchAlgorithmException` at the first call, and - * the app has to either bundle a provider or refuse to run, a decision that - * has not been made yet, so it is documented rather than papered over. - * - * These tests run on the JVM, where the provider is SunEC. Android routes - * the same calls through Conscrypt. Passing here says the encoding is right; - * it says nothing about the device. */ object X25519 { - const val KEY_LEN = 32 /** * The fixed prefix of an X.509-wrapped X25519 public key: SEQUENCE, the * id-X25519 OID (1.3.101.110), then a 32-bit BIT STRING. - * - * Written out rather than parsed because it is a constant twelve bytes and - * a DER parser here would be more code than the thing it parses, and one - * more place to get a length wrong. */ - private val SPKI_PREFIX = byteArrayOf( - 0x30, 0x2a, // SEQUENCE, 42 bytes - 0x30, 0x05, // SEQUENCE, 5 bytes - 0x06, 0x03, 0x2b, 0x65, 0x6e, // OID 1.3.101.110 - 0x03, 0x21, 0x00, // BIT STRING, 33 bytes, 0 unused - ) + private val SPKI_PREFIX = + byteArrayOf( + 0x30, + 0x2a, // SEQUENCE, 42 bytes + 0x30, + 0x05, // SEQUENCE, 5 bytes + 0x06, + 0x03, + 0x2b, + 0x65, + 0x6e, // OID 1.3.101.110 + 0x03, + 0x21, + 0x00, // BIT STRING, 33 bytes, 0 unused + ) fun generate(): KeyPair = KeyPairGenerator.getInstance("XDH").apply { initialize(255) }.generateKeyPair() /** Wrap a raw 32-byte public key so the JCA will accept it. */ fun publicKeyFromRaw(raw: ByteArray): java.security.PublicKey { require(raw.size == KEY_LEN) { "an X25519 public key is $KEY_LEN bytes, got ${raw.size}" } - return KeyFactory.getInstance("XDH") + return KeyFactory + .getInstance("XDH") .generatePublic(X509EncodedKeySpec(SPKI_PREFIX + raw)) } /** * A private key back from its PKCS#8 encoding, which is what the vault * stores. - * - * PKCS#8 rather than the raw 32 scalar bytes for the same reason public - * keys go through DER: Conscrypt will not build an XDH key from raw bytes. - * Storing raw and wrapping at load time would work on a laptop and throw on - * a phone, which is the exact bug this engine has already had once. */ fun privateKeyFromPkcs8(der: ByteArray): java.security.PrivateKey = KeyFactory.getInstance("XDH").generatePrivate(PKCS8EncodedKeySpec(der)) @@ -76,14 +61,11 @@ object X25519 { /** * The shared secret. - * - * Rejects an all-zero result. X25519 produces all zeros for a - * small-order public key, and a peer that sends one can force both sides to - * a secret it already knows. RFC 7748 says implementations MAY check; for a - * key-agreement protocol where the peer's key arrives over an untrusted - * relay, it is not optional. */ - fun agree(privateKey: java.security.PrivateKey, peerRaw: ByteArray): ByteArray { + fun agree( + privateKey: java.security.PrivateKey, + peerRaw: ByteArray, + ): ByteArray { val agreement = KeyAgreement.getInstance("XDH") agreement.init(privateKey) agreement.doPhase(publicKeyFromRaw(peerRaw), true) diff --git a/engine/src/main/kotlin/io/flamenet/e2e/X3DH.kt b/engine/src/main/kotlin/io/flamenet/e2e/X3DH.kt index 89f0157..67fd731 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/X3DH.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/X3DH.kt @@ -34,15 +34,11 @@ package io.flamenet.e2e * mistakes. */ object X3DH { - /** Domain separation, and a wire constant. Changing it strands every session. */ private val INFO_V1 = "FlamenetE2E_X3DH_v1".toByteArray(Charsets.UTF_8) /** - * The info string changes with the version, and it has to. Without - * that change, v1's `DH1‖DH2‖DH3‖DH4` and v2's `DH1‖DH2‖DH3‖SS` are both - * 128 bytes and are indistinguishable KDF inputs, so a downgrade would - * produce a key the other side accepts (spec §12.5). + * The info string changes with the version, and it has to. */ private val INFO_V2 = "FlamenetE2E_X3DH_v2".toByteArray(Charsets.UTF_8) @@ -50,8 +46,7 @@ object X3DH { private val ZERO_SALT = ByteArray(32) /** - * What Bob publishes and Alice fetches. `oneTimePreKey` is null once his - * pool is drained, which is a degraded but valid state. + * What Bob publishes and Alice fetches. */ data class Bundle( val identityKeyDH: ByteArray, @@ -60,12 +55,7 @@ object X3DH { ) /** - * Alice's side. Returns the shared secret and the ephemeral Bob will need. - * - * On a v2 handshake it also carries the KEM ciphertext, which travels in - * the prekey envelope and which BOTH sides fold into the session's - * associated data, so [associatedData] needs it and the caller has to keep - * it rather than send and forget. + * Alice's side. */ data class Initiated( val sharedSecret: ByteArray, @@ -77,40 +67,37 @@ object X3DH { /** * Which of Bob's two post-quantum prekeys a ciphertext was encapsulated to. - * - * `id` here and the classical `opk_id` are independent id spaces. - * They may coincide numerically and must never be assumed equal (§12.6). */ - data class PQPart(val kind: String, val id: Long, val ciphertext: ByteArray) { + data class PQPart( + val kind: String, + val id: Long, + val ciphertext: ByteArray, + ) { override fun equals(other: Any?): Boolean = other is PQPart && kind == other.kind && id == other.id && ciphertext.contentEquals(other.ciphertext) + override fun hashCode(): Int = ciphertext.contentHashCode() } /** * `AD = IK_sig_A ‖ IK_sig_B` on v1, plus `SHA-256(domain ‖ PQ_CT)` on v2. - * - * ML-KEM's implicit rejection already makes a tampered ciphertext yield a - * different key. Binding it here makes that explicit and permanent, because - * AD prefixes the AAD of every message for the life of the session, so the - * mismatch is discovered on the first message rather than depending on - * whatever the changed key happens to decrypt (§12.5). */ - fun associatedData(first: ByteArray, second: ByteArray, pqCiphertext: ByteArray?): ByteArray = - if (pqCiphertext == null) first + second - else first + second + Bytes.sha256(PQ_AD_DOMAIN + pqCiphertext) + fun associatedData( + first: ByteArray, + second: ByteArray, + pqCiphertext: ByteArray?, + ): ByteArray = + if (pqCiphertext == null) { + first + second + } else { + first + second + Bytes.sha256(PQ_AD_DOMAIN + pqCiphertext) + } private val PQ_AD_DOMAIN = "FlamenetE2E_PQAD_v2".toByteArray(Charsets.UTF_8) /** * Keys are JCA objects rather than raw bytes throughout. - * - * Not a style choice: Android's provider is Conscrypt and it will not build - * an XDH key from raw bytes, it wants a DER-encoded spec, which is what - * `X25519.publicKeyFromRaw` exists to produce. An implementation that passed - * raw bytes around would work on the JVM and throw on the first tap on a - * phone, which this engine has already been bitten by once. */ fun initiate( identityPrivateDH: java.security.PrivateKey, @@ -119,11 +106,6 @@ object X3DH { /** * Bob's chosen post-quantum prekey, already signature-verified by the * caller, or null for a v1 session. - * - * Verified by the CALLER rather than here, deliberately: this object - * holds no identity key to check a signature against, and taking one - * just for this would mean two places could disagree about what counts - * as a valid prekey. */ bobPQ: ByteArray? = null, ): Initiated { @@ -133,10 +115,7 @@ object X3DH { val dh4 = bundle.oneTimePreKey?.let { X25519.agree(ephemeral.private, it) } var ikm = if (dh4 != null) dh1 + dh2 + dh3 + dh4 else dh1 + dh2 + dh3 - // The KEM secret goes LAST. Order is protocol, and two engines - // that disagree about it agree about everything else, the failure - // arrives at the far end as a message that will not decrypt, pointing - // nowhere near here. + // The KEM secret goes LAST. var ciphertext: ByteArray? = null if (bobPQ != null) { val out = PQKEM.encapsulate(bobPQ) @@ -144,8 +123,8 @@ object X3DH { ciphertext = out.ciphertext } return Initiated( - sharedSecret = Hkdf.derive( - ikm, ZERO_SALT, if (ciphertext == null) INFO_V1 else INFO_V2, 32), + sharedSecret = + Hkdf.derive(ikm, ZERO_SALT, if (ciphertext == null) INFO_V1 else INFO_V2, 32), ephemeralPublic = X25519.rawFromPublicKey(ephemeral.public), pqCiphertext = ciphertext, version = if (ciphertext == null) 1 else 2, @@ -153,14 +132,7 @@ object X3DH { } /** - * Bob's side. The same four agreements, computed from the other direction, - * each DH is symmetric, so the private and public halves swap and the result - * is identical. - * - * `oneTimePrivate` must be the key matching the `oneTimePreKey` Alice used, - * and null when she used none. Passing the wrong one produces a shared - * secret that differs from Alice's, which surfaces as an undecryptable - * first message rather than as an error here. + * Bob's side. */ fun respond( identityPrivateDH: java.security.PrivateKey, @@ -171,12 +143,6 @@ object X3DH { /** * The seed of whichever post-quantum prekey Alice named, and the * ciphertext she sent, or null when she ran v1. - * - * Decapsulation never fails on a substituted ciphertext: ML-KEM's - * implicit rejection yields a well-formed but different secret. So a - * tampered one is not caught here; it surfaces as the first message - * failing to open, which is why the ciphertext is also bound into the - * associated data (§12.5). */ pq: Pair? = null, ): ByteArray { diff --git a/engine/src/test/kotlin/io/flamenet/e2e/AccountKdfTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/AccountKdfTest.kt index 8531936..5187892 100644 --- a/engine/src/test/kotlin/io/flamenet/e2e/AccountKdfTest.kt +++ b/engine/src/test/kotlin/io/flamenet/e2e/AccountKdfTest.kt @@ -9,30 +9,19 @@ import kotlin.test.assertTrue /** * The Kotlin account KDF against the vectors pinned from the enclave client. - * - * Not against the JavaScript or Swift engines: those were pinned from the same - * source, and an implementation checked only against our other implementations - * inherits whatever we all misread. The cases include a non-ASCII passphrase - * and an empty one, which is where a `CharArray` API and a UTF-8 wire format - * quietly disagree. - * - * Passing here says nothing about a phone. The provider on a device is - * Conscrypt, not the JDK's, and this engine already has one case. X25519, - * that passed twenty-one JVM tests and threw on the first tap on real hardware. - * The on-device suite in `:engine-android` is what settles Android. */ class AccountKdfTest { - private val vectors: JSONObject by lazy { - val candidates = listOfNotNull( - System.getenv("FLAMENET_ACCOUNT_VECTORS"), - "../../engine/test/vectors/account-kdf-v1.json", - "../../../flamenet-e2e/test/vectors/account-kdf-v1.json", - "../../flamenet-e2e/test/vectors/account-kdf-v1.json", - ).map { File(it) } + val candidates = + listOfNotNull( + System.getenv("FLAMENET_ACCOUNT_VECTORS"), + "../../engine/test/vectors/account-kdf-v1.json", + "../../../flamenet-e2e/test/vectors/account-kdf-v1.json", + "../../flamenet-e2e/test/vectors/account-kdf-v1.json", + ).map { File(it) } val file = candidates.firstOrNull { it.exists() } - // Fails rather than skips: a vector suite that quietly stops checking is - // worth less than none, because it still reports green. + // Fails rather than skips: a vector suite that quietly stops checking + // is worth less than none, because it still reports green. assertTrue( file != null, "account KDF vectors not found. Looked in: " + @@ -42,8 +31,8 @@ class AccountKdfTest { } private fun hex(bytes: ByteArray) = bytes.joinToString("") { "%02x".format(it) } - private fun unhex(s: String) = - ByteArray(s.length / 2) { s.substring(it * 2, it * 2 + 2).toInt(16).toByte() } + + private fun unhex(s: String) = ByteArray(s.length / 2) { s.substring(it * 2, it * 2 + 2).toInt(16).toByte() } @Test fun `derives exactly the published bytes for every case`() { @@ -57,12 +46,13 @@ class AccountKdfTest { for (i in 0 until cases.length()) { val c = cases.getJSONObject(i) - val out = AccountKdf.derive( - passphrase = c.getString("passphrase"), - salt = unhex(c.getString("saltHex")), - context = c.getString("context"), - params = params, - ) + val out = + AccountKdf.derive( + passphrase = c.getString("passphrase"), + salt = unhex(c.getString("saltHex")), + context = c.getString("context"), + params = params, + ) assertEquals(c.getString("expectedHex"), hex(out), "case: ${c.getString("name")}") assertEquals(c.getString("expectedB64"), Bytes.b64(out), "case: ${c.getString("name")}") } @@ -78,8 +68,6 @@ class AccountKdfTest { val account = AccountKdf.accountKeySeed(pass, salt) // Same passphrase, same salt, same stretch, three unrelated secrets. - // If any pair matched, a compromise of one product's key would hand over - // another's, which is the entire reason the contexts exist. assertFalse(auth.contentEquals(identity), "auth and identity keys collided") assertFalse(auth.contentEquals(account), "auth and account keys collided") assertFalse(identity.contentEquals(account), "identity and account keys collided") @@ -89,7 +77,5 @@ class AccountKdfTest { // The tie between this KDF and FNID.encode, that a correctly derived seed // also produces the same ADDRESS on every client, belongs here too, and // cannot be written yet: the Kotlin FNID lives on the multi-homing branch - // and is not on main. Add it when that lands, because a seed that derives - // correctly and encodes differently still gives one account a different - // name on every platform. + // and is not on main. } diff --git a/engine/src/test/kotlin/io/flamenet/e2e/AeadTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/AeadTest.kt index 22b4b5b..96f382b 100644 --- a/engine/src/test/kotlin/io/flamenet/e2e/AeadTest.kt +++ b/engine/src/test/kotlin/io/flamenet/e2e/AeadTest.kt @@ -9,16 +9,9 @@ import kotlin.test.assertTrue /** * AES-256-GCM against NIST's published vectors, plus the layout the protocol * depends on. - * - * The vectors are from NIST CAVP `gcmEncryptExtIV256`, not from our other - * engines, same reasoning as [HkdfTest]. The layout assertions ARE about our - * protocol, and they are the ones that would otherwise be discovered by two - * engines failing to talk to each other. */ class AeadTest { - - private fun hex(s: String): ByteArray = - if (s.isEmpty()) ByteArray(0) else s.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + private fun hex(s: String): ByteArray = if (s.isEmpty()) ByteArray(0) else s.chunked(2).map { it.toInt(16).toByte() }.toByteArray() private fun ByteArray.hex(): String = joinToString("") { "%02x".format(it) } @@ -55,9 +48,7 @@ class AeadTest { } /** - * The layout the other two engines assume. CryptoKit's `combined` emits - * nonce first; a box assembled any other way decrypts to garbage on iOS - * rather than failing, which is the worst kind of interop bug. + * The layout the other two engines assume. */ @Test fun `box is nonce then ciphertext then tag`() { @@ -74,8 +65,10 @@ class AeadTest { val key = ByteArray(32) { 4 } val nonce = ByteArray(12) { 5 } val box = Aead.seal(key, nonce, "hello".toByteArray(), aad = "context".toByteArray()) - assertContentEquals("hello".toByteArray(), - Aead.open(key, box, aad = "context".toByteArray())) + assertContentEquals( + "hello".toByteArray(), + Aead.open(key, box, aad = "context".toByteArray()), + ) assertFailsWith { Aead.open(key, box, aad = "different".toByteArray()) } @@ -127,10 +120,12 @@ class AeadTest { val key = ByteArray(32) { 12 } val box = Aead.seal(key, ByteArray(12) { 13 }, "x".toByteArray()) val badKey = runCatching { Aead.open(ByteArray(32) { 14 }, box) }.exceptionOrNull() - val badTag = runCatching { - val broken = box.copyOf(); broken[broken.size - 1] = 0 - Aead.open(key, broken) - }.exceptionOrNull() + val badTag = + runCatching { + val broken = box.copyOf() + broken[broken.size - 1] = 0 + Aead.open(key, broken) + }.exceptionOrNull() assertTrue(badKey is Aead.DecryptionFailed && badTag is Aead.DecryptionFailed) assertEquals(badKey.message, badTag.message, "the failures are distinguishable") } diff --git a/engine/src/test/kotlin/io/flamenet/e2e/AttachmentsInUseTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/AttachmentsInUseTest.kt index 8b712ae..a564bb6 100644 --- a/engine/src/test/kotlin/io/flamenet/e2e/AttachmentsInUseTest.kt +++ b/engine/src/test/kotlin/io/flamenet/e2e/AttachmentsInUseTest.kt @@ -11,84 +11,107 @@ import kotlin.test.assertTrue /** * Which attachment ids the device still has a reason to hold bytes for. - * - * This set is the input to a DELETE, so the dangerous error is the one - * that omits something. A missing id means a cleanup removes a file that is - * still in use, a worse failure than the leak it was written to fix, because - * the leak only wastes space and this loses somebody's photo. - * - * So the cases below are mostly about the stores an enumeration would forget. */ class AttachmentsInUseTest { - private class MapStorage : Vault.Storage { val records = LinkedHashMap() + override fun read(name: String) = records[name] - override fun write(name: String, bytes: ByteArray) { records[name] = bytes } - override fun delete(name: String) { records.remove(name) } + + override fun write( + name: String, + bytes: ByteArray, + ) { + records[name] = bytes + } + + override fun delete(name: String) { + records.remove(name) + } + override fun names() = records.keys.toList() } private fun key(): SecretKey = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() - private fun ref(id: String) = Attachment.Ref( - kind = Attachment.IMAGE, id = id, - key = ByteArray(32) { 7 }, digest = ByteArray(32) { 9 }, - bytes = 100, mime = "image/jpeg", width = 2, height = 2, + private fun ref(id: String) = + Attachment.Ref( + kind = Attachment.IMAGE, + id = id, + key = ByteArray(32) { 7 }, + digest = ByteArray(32) { 9 }, + bytes = 100, + mime = "image/jpeg", + width = 2, + height = 2, + ) + + private fun story( + id: String, + attId: String, + expiresAt: Double, + ) = Vault.Story( + id = id, + authorId = 1L, + mine = true, + ref = ref(attId), + caption = "", + postedAt = 0L, + seen = false, + expiresAt = expiresAt, ) - private fun story(id: String, attId: String, expiresAt: Double) = Vault.Story( - id = id, authorId = 1L, mine = true, ref = ref(attId), - caption = "", postedAt = 0L, seen = false, expiresAt = expiresAt, - ) - - // ---- The stores an enumeration forgets -------------------------------- + // ---- The stores an enumeration forgets --------------------------------. @Test fun `a message's attachment counts as in use`() { - val storage = MapStorage(); val k = key() + val storage = MapStorage() + val k = key() val v = Vault(storage, k) - v.append("user:1", JSONObject().put("text", "").put("at", 1L) - .put("att", ref("in-a-message").toJson())) + v.append( + "user:1", + JSONObject() + .put("text", "") + .put("at", 1L) + .put("att", ref("in-a-message").toJson()), + ) assertTrue("in-a-message" in Vault(storage, k).attachmentIdsInUse()) } @Test fun `a live story's attachment counts`() { - val storage = MapStorage(); val k = key() + val storage = MapStorage() + val k = key() Vault(storage, k).saveStories(listOf(story("s1", "in-a-story", 4_000_000_000.0)), 1.0) assertTrue("in-a-story" in Vault(storage, k).attachmentIdsInUse()) } /** * The archive is the case that makes a naive sweep destructive. - * An expired story is dropped from the live set AND folded into the private - * archive, which keeps its ref. Purging on expiry without looking here - * would leave somebody's own archive full of blank pictures, the exact - * thing the archive exists to prevent. */ @Test fun `an ARCHIVED story's attachment still counts, long after it expired`() { - val storage = MapStorage(); val k = key() + val storage = MapStorage() + val k = key() val v = Vault(storage, k) // Expired: gone from the live set, kept in the archive. v.saveStories(listOf(story("s1", "expired-blob", 1.0)), 1_000_000.0) v.saveStoryArchive(listOf(story("s1", "expired-blob", 1.0))) val inUse = Vault(storage, k).attachmentIdsInUse() - assertTrue("expired-blob" in inUse, - "an archived story's attachment was reported unused, the archive would go blank") + assertTrue( + "expired-blob" in inUse, + "an archived story's attachment was reported unused, the archive would go blank", + ) } /** - * Avatars are attachments too. They are fetched and cached by the - * same code path, and they live in a store nobody thinks of when listing - * "places messages are". A sweep that enumerated history and stories would - * have deleted every avatar on the device the first time it ran. + * Avatars are attachments too. */ @Test fun `avatars count, both a contact's and this account's own`() { - val storage = MapStorage(); val k = key() + val storage = MapStorage() + val k = key() val v = Vault(storage, k) v.saveAvatar(42L, ref("their-face")) v.saveMyAvatar(ref("my-face")) @@ -98,21 +121,25 @@ class AttachmentsInUseTest { assertTrue("my-face" in inUse, "this account's own avatar was reported unused") } - // ---- Things that look like refs and are not --------------------------- + // ---- Things that look like refs and are not ---------------------------. /** - * A sticker carries `pack` + `id`, and a story SET carries `id` + `i` + - * `n` where the id names the SEQUENCE, not a blob. Matching on `id` alone - * would pull both in, harmless for a set, but it would keep files alive - * forever under ids that name nothing. + * A sticker carries `pack` + `id`, and a story SET carries `id` + `i` + `n` + * where the id names the SEQUENCE, not a blob. */ @Test fun `ids that are not attachment references are not collected`() { - val storage = MapStorage(); val k = key() + val storage = MapStorage() + val k = key() val v = Vault(storage, k) - v.append("user:1", JSONObject().put("text", "").put("at", 1L) - .put("sticker", JSONObject().put("pack", "p").put("id", "sticker-id")) - .put("set", JSONObject().put("id", "sequence-id").put("i", 0).put("n", 3))) + v.append( + "user:1", + JSONObject() + .put("text", "") + .put("at", 1L) + .put("sticker", JSONObject().put("pack", "p").put("id", "sticker-id")) + .put("set", JSONObject().put("id", "sequence-id").put("i", 0).put("n", 3)), + ) val inUse = Vault(storage, k).attachmentIdsInUse() assertFalse("sticker-id" in inUse, "a sticker id was mistaken for an attachment") @@ -122,28 +149,65 @@ class AttachmentsInUseTest { /** The discriminator is all three fields, so two of them is not enough. */ @Test fun `an object with an id and a key but no digest is not a reference`() { - val storage = MapStorage(); val k = key() - Vault(storage, k).append("user:1", JSONObject().put("at", 1L) - .put("odd", JSONObject().put("id", "two-of-three").put("key", "x"))) + val storage = MapStorage() + val k = key() + Vault(storage, k).append( + "user:1", + JSONObject() + .put("at", 1L) + .put("odd", JSONObject().put("id", "two-of-three").put("key", "x")), + ) assertFalse("two-of-three" in Vault(storage, k).attachmentIdsInUse()) } - // ---- Failing safe ----------------------------------------------------- + // ---- Failing safe -----------------------------------------------------. /** - * An unreadable record must throw, not be skipped. Skipping it - * returns a set that is missing whatever it held, and a caller that deletes - * everything outside that set would then delete live attachments. The - * cleanup has to refuse to run rather than run on a partial view. + * An unreadable record must throw, not be skipped. */ @Test fun `a record that cannot be opened stops the whole answer`() { - val storage = MapStorage(); val k = key() - Vault(storage, k).append("user:1", JSONObject().put("at", 1L) - .put("att", ref("real").toJson())) - storage.records["history/user:2"] = byteArrayOf(1, 2, 3, 4, 5, 6, 7, 8, - 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, - 27, 28, 29, 30) + val storage = MapStorage() + val k = key() + Vault(storage, k).append( + "user:1", + JSONObject() + .put("at", 1L) + .put("att", ref("real").toJson()), + ) + storage.records["history/user:2"] = + byteArrayOf( + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + ) assertFailsWith { Vault(storage, k).attachmentIdsInUse() } } diff --git a/engine/src/test/kotlin/io/flamenet/e2e/AttentionStoreTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/AttentionStoreTest.kt index 29f8464..95ce949 100644 --- a/engine/src/test/kotlin/io/flamenet/e2e/AttentionStoreTest.kt +++ b/engine/src/test/kotlin/io/flamenet/e2e/AttentionStoreTest.kt @@ -8,29 +8,32 @@ import kotlin.test.assertTrue /** * Snooze and reply reminders, at the vault. - * - * Both live here and nowhere else. "I am ignoring this person until - * Tuesday" and "remind me if they have not answered" are statements about a - * relationship, and a server has no business holding either. The honest cost is - * that neither follows you to a new device, the alternative is telling the - * relay who you are avoiding. */ class AttentionStoreTest { - private class MapStorage : Vault.Storage { private val records = mutableMapOf() + override fun read(name: String) = records[name] - override fun write(name: String, bytes: ByteArray) { records[name] = bytes } - override fun delete(name: String) { records.remove(name) } + + override fun write( + name: String, + bytes: ByteArray, + ) { + records[name] = bytes + } + + override fun delete(name: String) { + records.remove(name) + } + override fun names() = records.keys.toList() } - private fun vault() = Vault( - MapStorage(), KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()) + private fun vault() = Vault(MapStorage(), KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()) private val now = 1_700_000_000_000L - // -- Snooze -------------------------------------------------------------- + // -- Snooze --------------------------------------------------------------. @Test fun `a thread is not snoozed by default`() { @@ -43,9 +46,7 @@ class AttentionStoreTest { val v = vault() v.saveSnoozedUntil("t", now + 60_000) assertTrue("t" in v.snoozedThreads(now)) - // Expiry is read at query time, not swept. A snooze that outlived - // its own deadline because nothing ran is a conversation the user - // silently stopped being told about. + // Expiry is read at query time, not swept. assertFalse("t" in v.snoozedThreads(now + 120_000)) } @@ -66,7 +67,7 @@ class AttentionStoreTest { assertEquals(0L, v.snoozedUntil("loud")) } - // -- Reply reminders ----------------------------------------------------- + // -- Reply reminders -----------------------------------------------------. @Test fun `a reminder is due only once its time has passed`() { @@ -78,8 +79,6 @@ class AttentionStoreTest { /** * A reminder must be clearable by a reply ARRIVING, not only by firing. - * "Remind me if they have not answered" that still fires after they did is - * worse than none, it teaches people to ignore the reminder. */ @Test fun `a reminder can be cancelled before it fires`() { diff --git a/engine/src/test/kotlin/io/flamenet/e2e/BridgeOfferTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/BridgeOfferTest.kt index f0eb5b9..adedd6b 100644 --- a/engine/src/test/kotlin/io/flamenet/e2e/BridgeOfferTest.kt +++ b/engine/src/test/kotlin/io/flamenet/e2e/BridgeOfferTest.kt @@ -9,20 +9,15 @@ import kotlin.test.assertTrue /** * Handing a contact the bridge you use (§6.19). - * - * The security property under test is that this is an OFFER. A bridge - * line routes every relay connection through a machine somebody else chose. A - * contact, or whoever has taken their account, could not read the traffic, - * because the ratchet does not care what carries it, but they would learn who - * is online and when, and could cut them off. So nothing in the engine or the - * transport may apply one; only the person it was sent to can. */ class BridgeOfferTest { - private val line = "obfs4 198.51.100.7:4444 0123456789ABCDEF0123456789ABCDEF01234567 " + - "cert=" + java.util.Base64.getEncoder() - .encodeToString(ByteArray(52) { 0xAB.toByte() }).trimEnd('=') + + "cert=" + + java.util.Base64 + .getEncoder() + .encodeToString(ByteArray(52) { 0xAB.toByte() }) + .trimEnd('=') + " iat-mode=0" @Test @@ -33,11 +28,10 @@ class BridgeOfferTest { } /** - * `1`, not `true`. iOS decodes `ctl` as a number, and a boolean does - * not merely fail to set the flag, it fails the whole envelope's decode - * and falls through to the plain-text path, which is how an Android user - * once sent an iPhone a bubble of raw JSON. Asserted on the wire rather - * than through the parser, because the parser here would accept both. + * `1`, not `true`. iOS decodes `ctl` as a number, and a boolean does not + * merely fail to set the flag, it fails the whole envelope's decode and + * falls through to the plain-text path, which is how an Android user once + * sent an iPhone a bubble of raw JSON. */ @Test fun `the control flag is the number one on the wire`() { @@ -47,9 +41,7 @@ class BridgeOfferTest { } /** - * What an older build does with this. A build that has never heard - * of this kind must swallow it, not draw it. `ctl` is what makes that true, - * and membership of CONTROL is what routes it to a handler here. + * What an older build does with this. */ @Test fun `an offer is control traffic and never renders`() { @@ -59,9 +51,9 @@ class BridgeOfferTest { assertTrue(Content.Kind.BRIDGE in Content.Kind.CONTROL) } - /** The line crosses whole. Rebuilding it from parsed fields at the far - * end would lose the `cert=` that makes it usable and the fingerprint - * somebody may want to check against another source. */ + /** + * The line crosses whole. + */ @Test fun `the line is carried verbatim`() { val awkward = "obfs4 [2001:db8::1]:9001 ABC cert=zz+/9 iat-mode=1 sni=x.example" @@ -76,8 +68,7 @@ class BridgeOfferTest { } /** - * An empty or whitespace line is not an offer. Storing one would put an - * un-actionable row on the settings screen with somebody's name against it. + * An empty or whitespace line is not an offer. */ @Test fun `an empty line is not an offer`() { @@ -85,10 +76,7 @@ class BridgeOfferTest { } /** - * The engine validates nothing, deliberately. Whether a line is - * usable is asked on the screen where a person decides, so a bad one is - * reported to them with a reason. Rejecting it here would drop it in - * silence, the exact failure the settings screen was built to end. + * The engine validates nothing, deliberately. */ @Test fun `a malformed line still arrives, to be judged where it can be explained`() { diff --git a/engine/src/test/kotlin/io/flamenet/e2e/BridgeOfferVaultTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/BridgeOfferVaultTest.kt index 4729a1e..6b113bd 100644 --- a/engine/src/test/kotlin/io/flamenet/e2e/BridgeOfferVaultTest.kt +++ b/engine/src/test/kotlin/io/flamenet/e2e/BridgeOfferVaultTest.kt @@ -8,29 +8,32 @@ import kotlin.test.assertTrue /** * Where a bridge somebody offered is kept, and what keeping it does not do. - * - * Storing an offer must change nothing about where traffic goes. That is - * the whole reason offers exist as a separate thing from the configured bridge: - * a contact, or whoever has taken their account, must not be able to move - * another person's traffic onto a host of their choosing by sending a message. */ class BridgeOfferVaultTest { - private class MapStorage : Vault.Storage { val records = LinkedHashMap() + override fun read(name: String) = records[name] - override fun write(name: String, bytes: ByteArray) { records[name] = bytes } - override fun delete(name: String) { records.remove(name) } + + override fun write( + name: String, + bytes: ByteArray, + ) { + records[name] = bytes + } + + override fun delete(name: String) { + records.remove(name) + } + override fun names() = records.keys.toList() } - private fun key(): SecretKey = - KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() + private fun key(): SecretKey = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() private fun vault() = Vault(MapStorage(), key()) - private fun line(host: String) = - "obfs4 $host:4444 0123456789ABCDEF cert=abc iat-mode=0" + private fun line(host: String) = "obfs4 $host:4444 0123456789ABCDEF cert=abc iat-mode=0" @Test fun `an offer survives being stored and read back`() { @@ -44,8 +47,7 @@ class BridgeOfferVaultTest { } /** - * One offer per contact. An operator who rotates a bridge sends a new - * line, and a list of every line they ever had is a menu of stale hosts. + * One offer per contact. */ @Test fun `a second offer from the same contact replaces the first`() { @@ -75,11 +77,7 @@ class BridgeOfferVaultTest { } /** - * The load-bearing one. Offers live in their own record and touch - * nothing else. If storing an offer ever wrote to whatever the app reads - * for its configured bridge, a message would become a route, and the whole - * point of the offer/accept split would be gone while every other test here - * still passed. + * The load-bearing one. */ @Test fun `storing an offer writes only the offers record`() { @@ -88,8 +86,11 @@ class BridgeOfferVaultTest { val before = storage.records.keys.toSet() v.saveBridgeOffer(42, line("198.51.100.7"), 1_700_000_000) val written = storage.records.keys.toSet() - before - assertEquals(setOf("bridge-offers"), written, - "an offer touched something other than the offers record") + assertEquals( + setOf("bridge-offers"), + written, + "an offer touched something other than the offers record", + ) } /** Reading offers on a fresh device is empty, not a crash. */ diff --git a/engine/src/test/kotlin/io/flamenet/e2e/ContactCardTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/ContactCardTest.kt index b8549e0..0037040 100644 --- a/engine/src/test/kotlin/io/flamenet/e2e/ContactCardTest.kt +++ b/engine/src/test/kotlin/io/flamenet/e2e/ContactCardTest.kt @@ -7,20 +7,8 @@ import kotlin.test.assertTrue /** * Parsing an address, and refusing everything that is nearly one. - * - * This runs on input nobody authenticated. An address arrives typed, or - * pasted, or scanned from a code somebody else printed, and, worst of all, - * from inside a sealed envelope written by a stranger on another relay. So the - * refusals matter more than the successes: accepting `fnid@https://x/y` would - * let a message point this client at a path on a host of the sender's choosing. - * - * The cases here deliberately mirror the Swift suite one for one. Two - * implementations that disagree about which strings are addresses open two - * threads for one person on one side and none on the other, and nothing about - * either engine's own tests would show it. */ class ContactCardTest { - @Test fun `a bare address with no scheme is accepted`() { val card = ContactCard.decode("fn1abc@relay1.flamenet.io") @@ -45,10 +33,7 @@ class ContactCardTest { } /** - * Kept only so an older card still parses. Nothing reads it and nothing - * should start to: sealing uses the recipient DEVICE's key, which the - * remote directory returns, and the federated send carries no delivery key - * at all. + * Kept only so an older card still parses. */ @Test fun `an older card carrying a delivery key still parses`() { @@ -62,9 +47,7 @@ class ContactCardTest { } /** - * A relay is a hostname. Accepting a URL here would let a card, or a - * message from a stranger, point this client at a path on somebody else's - * host. + * A relay is a hostname. */ @Test fun `a relay that is a URL is refused`() { @@ -83,8 +66,7 @@ class ContactCardTest { } /** - * A second `@` means this is not one address. Splitting on the first - * would silently address a different host than the string names. + * A second `@` means this is not one address. */ @Test fun `two at signs is not one address`() { @@ -97,12 +79,13 @@ class ContactCardTest { */ @Test fun `canonical gives one spelling for one address`() { - val spellings = listOf( - "fn1abc@relay1.flamenet.io", - "flamenet:fn1abc@relay1.flamenet.io", - " fn1abc@RELAY1.Flamenet.io ", - "flamenet:fn1abc@relay1.flamenet.io?k=" + "A".repeat(44), - ) + val spellings = + listOf( + "fn1abc@relay1.flamenet.io", + "flamenet:fn1abc@relay1.flamenet.io", + " fn1abc@RELAY1.Flamenet.io ", + "flamenet:fn1abc@relay1.flamenet.io?k=" + "A".repeat(44), + ) val canonical = spellings.map { ContactCard.canonical(it) }.toSet() assertEquals(setOf("fn1abc@relay1.flamenet.io"), canonical, "got $canonical") } diff --git a/engine/src/test/kotlin/io/flamenet/e2e/ContentTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/ContentTest.kt index 4168947..d4da313 100644 --- a/engine/src/test/kotlin/io/flamenet/e2e/ContentTest.kt +++ b/engine/src/test/kotlin/io/flamenet/e2e/ContentTest.kt @@ -9,40 +9,37 @@ import kotlin.test.assertNull import kotlin.test.assertTrue class ContentTest { - - private fun ref(kind: String = Attachment.IMAGE) = Attachment.Ref( - kind = kind, - id = "blob-1", - key = ByteArray(32) { it.toByte() }, - digest = ByteArray(32) { (it * 3).toByte() }, - bytes = 1234, - mime = "image/jpeg", - width = 800, - height = 600, - ) + private fun ref(kind: String = Attachment.IMAGE) = + Attachment.Ref( + kind = kind, + id = "blob-1", + key = ByteArray(32) { it.toByte() }, + digest = ByteArray(32) { (it * 3).toByte() }, + bytes = 1234, + mime = "image/jpeg", + width = 800, + height = 600, + ) /** - * The marker is a prefix test on the bytes, so `fnc` must be - * serialised first. A JSON library that sorts keys puts `att` first, every - * envelope it produces reads as plain text on the far side, and somebody's - * chat fills with raw JSON. + * The marker is a prefix test on the bytes, so `fnc` must be serialised + * first. */ @Test fun `every envelope starts with the marker`() { - val envelopes = listOf( - Content.text("hi", messageId = "m1"), - Content.groupText("hi", groupId = "g1"), - Content.attachment(ref(), caption = "look"), - Content.reaction("👍", Content.Reference("m1", 7, "hi")), - Content.callSignal(Content.CallSignal(Content.CallSignal.OFFER, "c1", sdp = "v=0")), - ) + val envelopes = + listOf( + Content.text("hi", messageId = "m1"), + Content.groupText("hi", groupId = "g1"), + Content.attachment(ref(), caption = "look"), + Content.reaction("👍", Content.Reference("m1", 7, "hi")), + Content.callSignal(Content.CallSignal(Content.CallSignal.OFFER, "c1", sdp = "v=0")), + ) envelopes.forEach { assertTrue(it.startsWith("{\"fnc\":1,"), "bad prefix: $it") } } /** - * Plain 1:1 text stays raw UTF-8. A client predating content envelopes - * shows an envelope as literal JSON, so the simplest message keeps the - * format every client has always understood. + * Plain 1:1 text stays raw UTF-8. */ @Test fun `plain text is not wrapped at all`() { @@ -89,15 +86,14 @@ class ContentTest { assertEquals("m1", parsed.about?.messageId) assertFalse(parsed.isRenderable) - // An empty emoji takes one back, a message, not an absence, because - // the other device has already rendered the first one. + // An empty emoji takes one back, a message, not an absence, because the + // other device has already rendered the first one. assertEquals("", Content.parse(Content.reaction("", about)).reaction) } /** - * The web client sends `delivery_key`, `kt_head` and `sent` down this - * same channel. A client that renders every envelope it does not recognise - * shows those to the user as a bubble full of JSON. + * The web client sends `delivery_key`, `kt_head` and `sent` down this same + * channel. */ @Test fun `control messages are consumed rather than shown`() { @@ -113,8 +109,8 @@ class ContentTest { @Test fun `call signals are not messages`() { - val encoded = Content.callSignal( - Content.CallSignal(Content.CallSignal.OFFER, "call-9", sdp = "v=0\r\n")) + val encoded = + Content.callSignal(Content.CallSignal(Content.CallSignal.OFFER, "call-9", sdp = "v=0\r\n")) val parsed = Content.parse(encoded) assertEquals("call-9", parsed.call?.callId) assertEquals("v=0\r\n", parsed.call?.sdp) @@ -139,14 +135,6 @@ class ContentTest { /** * Forward compatibility for control traffic. - * - * Control messages ride the same channel as chat and are recognised by - * NAME, an allowlist of kinds this build knows. A control kind from a - * newer peer is in no such set, so it fell through and became a - * `[unsupported attachment]` bubble: a message the user can neither read - * nor act on, for something never meant for them to see. - * - * `ctl: 1` is what lets every kind added from here on stay quiet. */ @Test fun `a flagged unknown control kind is consumed, not rendered`() { @@ -156,9 +144,7 @@ class ContentTest { } /** - * The opposite has to stay true. Unknown *content* is still shown, with - * a placeholder, silently dropping a real message is the one failure a - * messenger cannot make, so the flag is what decides, not the unknown-ness. + * The opposite has to stay true. */ @Test fun `an unknown kind without the flag is still shown`() { @@ -180,11 +166,6 @@ class ContentTest { /** * Disappearing-message timers (§6.11). - * - * The timer travels inside the ratchet. Sent any other way the relay - * would learn that a conversation is timed and for how long, metadata this - * feature exists to avoid producing, on a product whose claim is that the - * server knows as little as possible. */ @Test fun `a disappearing timer round-trips and is control traffic`() { @@ -203,9 +184,7 @@ class ContentTest { } /** - * `fnc` must stay the first key. The marker is a prefix test, so a - * reordered top level makes the envelope decode as a plain text message, - * raw JSON in somebody's conversation. + * `fnc` must stay the first key. */ @Test fun `the timer envelope starts with the marker`() { @@ -219,11 +198,6 @@ class ContentTest { /** * Link previews (§6.12). - * - * The SENDER generates these and they ride inside the ciphertext. A card - * each recipient fetched would tell the site the reader's IP, that they - * received a link, and roughly when they opened it, once per member on a - * group message. */ @Test fun `a link preview round-trips`() { @@ -235,21 +209,20 @@ class ContentTest { } /** - * The host comes from the URL, never the page. A card whose title claims - * one destination while the link points at another is a phishing primitive. + * The host comes from the URL, never the page. */ @Test fun `the host comes from the url not the title`() { - val preview = Content.LinkPreview.of( - "https://evil.example/login", "Sign in, yourbank.com", "") + val preview = + Content.LinkPreview.of("https://evil.example/login", "Sign in, yourbank.com", "") assertEquals("evil.example", preview.host) } /** Bounded on the way in, so no renderer has to remember a cap. */ @Test fun `title and description are bounded`() { - val preview = Content.LinkPreview.of( - "https://example.com", "A".repeat(5_000), "B".repeat(5_000)) + val preview = + Content.LinkPreview.of("https://example.com", "A".repeat(5_000), "B".repeat(5_000)) assertEquals(Content.LinkPreview.MAX_TITLE, preview.title.length) assertEquals(Content.LinkPreview.MAX_DESC, preview.desc.length) } @@ -257,7 +230,11 @@ class ContentTest { /** A preview with no title is not a preview, it would render as a blank card. */ @Test fun `a preview without a title is refused`() { - val json = org.json.JSONObject().put("url", "https://example.com").put("title", "") + val json = + org.json + .JSONObject() + .put("url", "https://example.com") + .put("title", "") assertNull(Content.LinkPreview.fromJson(json)) } @@ -289,7 +266,7 @@ class ContentTest { assertEquals(awkward, Content.parse(Content.groupText(awkward, "g1")).text) } - // -- Blobs --------------------------------------------------------------- + // -- Blobs ---------------------------------------------------------------. @Test fun `a blob round-trips`() { @@ -307,18 +284,16 @@ class ContentTest { } /** - * The digest is checked BEFORE decrypting. AES-GCM would catch this too, - * but failing on the digest names the problem, "the relay served different - * bytes", instead of reporting it as a decryption failure indistinguishable - * from a broken session. + * The digest is checked BEFORE decrypting. */ @Test fun `a substituted blob is refused by digest, not by the cipher`() { val sealed = Attachment.seal("secret".toByteArray()) val bent = sealed.blob.copyOf().also { it[it.size / 2] = (it[it.size / 2] + 1).toByte() } - val failure = assertFailsWith { - Attachment.open(bent, ref().copy(key = sealed.key, digest = sealed.digest)) - } + val failure = + assertFailsWith { + Attachment.open(bent, ref().copy(key = sealed.key, digest = sealed.digest)) + } assertTrue(failure.message!!.contains("digest")) } @@ -331,17 +306,17 @@ class ContentTest { } /** - * The vault persists refs through toJson and reads "exp" back out of - * that very JSON, a field toJson never wrote reloaded as 0, which both - * rendered every story as "no expiry" and (because 0 means immortal to - * isActive) meant expired story bytes were never dropped on write. + * The vault persists refs through toJson and reads "exp" back out of that + * very JSON, a field toJson never wrote reloaded as 0, which both rendered + * every story as "no expiry" and (because 0 means immortal to isActive) + * meant expired story bytes were never dropped on write. */ @Test fun `a story ref keeps its expiry through the json round trip`() { val posted = ref(kind = Attachment.STORY).copy(expires = 1_700_000_000.5) val json = posted.toJson() - val reloaded = Attachment.Ref.fromJson( - Attachment.STORY, json, json.optDouble("exp", 0.0))!! + val reloaded = + Attachment.Ref.fromJson(Attachment.STORY, json, json.optDouble("exp", 0.0))!! kotlin.test.assertEquals(posted.expires, reloaded.expires) } diff --git a/engine/src/test/kotlin/io/flamenet/e2e/ControlFlagInteropTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/ControlFlagInteropTest.kt index 3735324..03a0e23 100644 --- a/engine/src/test/kotlin/io/flamenet/e2e/ControlFlagInteropTest.kt +++ b/engine/src/test/kotlin/io/flamenet/e2e/ControlFlagInteropTest.kt @@ -7,23 +7,16 @@ import kotlin.test.assertTrue /** * The `ctl` flag, as all three engines actually write it. - * - * This engine wrote `"ctl":true` and the specification says `"ctl":1`. - * That was not a cosmetic disagreement. iOS decodes the field as a number - * through a synthesised `Codable`, so a boolean did not fail to set the flag, - * it failed the whole envelope's decode and dropped the message onto the - * plain-text path. An Android user turning on disappearing messages therefore - * sent an iPhone a bubble containing raw JSON, and the timer was never applied - * at that end. Nothing caught it because each engine parsed what it had written - * itself, and the piece-level tests on both sides passed. */ class ControlFlagInteropTest { - @Test fun `a disappearing envelope carries the number the spec asks for`() { val envelope = JSONObject(Content.disappearing(3600)) - assertEquals(1, envelope.get("ctl"), - "the spec says {\"fnc\":1,\"kind\":\"…\",\"ctl\":1}") + assertEquals( + 1, + envelope.get("ctl"), + "the spec says {\"fnc\":1,\"kind\":\"…\",\"ctl\":1}", + ) assertEquals(3600, envelope.getInt("secs")) assertEquals(1, envelope.getInt("fnc")) } @@ -37,25 +30,22 @@ class ControlFlagInteropTest { } /** - * Both shapes are accepted on the way IN. `org.json`'s `optBoolean` does - * not coerce a number, so reading only that missed every `"ctl":1` the - * other two engines send, and reading only the number would miss the - * `true` this engine itself wrote, which is on phones now and cannot be - * recalled. + * Both shapes are accepted on the way IN. */ @Test fun `both spellings of the flag are understood`() { for (spelling in listOf("1", "true")) { val raw = """{"fnc":1,"kind":"some_future_thing","ctl":$spelling}""" - assertTrue(Content.parse(raw).isControl, - "a control envelope written as ctl:$spelling was shown to the user") + assertTrue( + Content.parse(raw).isControl, + "a control envelope written as ctl:$spelling was shown to the user", + ) } } /** * The point of the flag: a control kind from a NEWER peer must be - * swallowed, not rendered. Without it, every control kind added after a - * given release shows in that release as a bubble the person cannot act on. + * swallowed, not rendered. */ @Test fun `an unknown control kind is swallowed rather than shown`() { diff --git a/engine/src/test/kotlin/io/flamenet/e2e/DeviceOwnerBindingTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/DeviceOwnerBindingTest.kt index f1732e1..41338fe 100644 --- a/engine/src/test/kotlin/io/flamenet/e2e/DeviceOwnerBindingTest.kt +++ b/engine/src/test/kotlin/io/flamenet/e2e/DeviceOwnerBindingTest.kt @@ -8,24 +8,28 @@ import kotlin.test.assertNull /** * Audit C1/C2: a device is bound to the user it was first proven to be, and a * later claim of a different user does not win. - * - * The ratchet proves the DEVICE; the user id inside a sealed envelope is a - * claim its author chose. Without this binding a contact could relabel their - * own messages as anyone, and poison that person's delivery key, which forces - * the next send UNSEALED and hands the relay the sender sealing exists to hide. */ class DeviceOwnerBindingTest { - private class MapStorage : Vault.Storage { val records = LinkedHashMap() + override fun read(name: String) = records[name] - override fun write(name: String, bytes: ByteArray) { records[name] = bytes } - override fun delete(name: String) { records.remove(name) } + + override fun write( + name: String, + bytes: ByteArray, + ) { + records[name] = bytes + } + + override fun delete(name: String) { + records.remove(name) + } + override fun names() = records.keys.toList() } - private fun vault() = Vault( - MapStorage(), KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()) + private fun vault() = Vault(MapStorage(), KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()) @Test fun `first use binds the device to its user`() { val v = vault() @@ -37,8 +41,11 @@ class DeviceOwnerBindingTest { @Test fun `a later different claim does not win`() { val v = vault() v.bindDeviceOwner(7L, "dev-A") - assertEquals(7L, v.bindDeviceOwner(42L, "dev-A"), - "a bound device must not be re-attributed by a later claim") + assertEquals( + 7L, + v.bindDeviceOwner(42L, "dev-A"), + "a bound device must not be re-attributed by a later claim", + ) assertEquals(7L, v.deviceOwner("dev-A")) } diff --git a/engine/src/test/kotlin/io/flamenet/e2e/DisappearingTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/DisappearingTest.kt index 9a15014..03d770e 100644 --- a/engine/src/test/kotlin/io/flamenet/e2e/DisappearingTest.kt +++ b/engine/src/test/kotlin/io/flamenet/e2e/DisappearingTest.kt @@ -1,34 +1,40 @@ package io.flamenet.e2e -import javax.crypto.KeyGenerator import org.json.JSONObject +import javax.crypto.KeyGenerator import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue /** * Disappearing messages (§6.11), at the vault. - * - * Expiry runs on read. A sweep on a timer leaves - * expired messages on disk between runs, and a "disappearing" message still - * sitting in the vault is a claim rather than a feature. Reading is the one - * path every caller shares. */ class DisappearingTest { - private class MapStorage : Vault.Storage { private val records = mutableMapOf() + override fun read(name: String) = records[name] - override fun write(name: String, bytes: ByteArray) { records[name] = bytes } - override fun delete(name: String) { records.remove(name) } + + override fun write( + name: String, + bytes: ByteArray, + ) { + records[name] = bytes + } + + override fun delete(name: String) { + records.remove(name) + } + override fun names() = records.keys.toList() } - private fun vault() = Vault( - MapStorage(), KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()) + private fun vault() = Vault(MapStorage(), KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()) - private fun line(text: String, at: Long) = - JSONObject().put("text", text).put("mine", true).put("at", at) + private fun line( + text: String, + at: Long, + ) = JSONObject().put("text", text).put("mine", true).put("at", at) private val now = 1_700_000_000_000L @@ -66,9 +72,7 @@ class DisappearingTest { } /** - * A message with no timestamp predates the field. An unknown age is not - * evidence of an old one, and deleting somebody's history on a guess cannot - * be undone. + * A message with no timestamp predates the field. */ @Test fun `a message with no timestamp is kept`() { diff --git a/engine/src/test/kotlin/io/flamenet/e2e/DoubleRatchetTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/DoubleRatchetTest.kt index 35d2255..da4516b 100644 --- a/engine/src/test/kotlin/io/flamenet/e2e/DoubleRatchetTest.kt +++ b/engine/src/test/kotlin/io/flamenet/e2e/DoubleRatchetTest.kt @@ -8,14 +8,8 @@ import kotlin.test.assertTrue /** * The ratchet, driven as a conversation. - * - * Single-step assertions would pass on a ratchet that is subtly wrong. What - * catches a counting error is a conversation that goes back and forth, drops - * messages, and delivers some out of order, because the divergence only shows - * up once the two sides have taken a different number of steps. */ class DoubleRatchetTest { - private val AD = "flamenet-test-ad".toByteArray() /** Alice and Bob, having already agreed a secret via X3DH. */ @@ -24,25 +18,27 @@ class DoubleRatchetTest { val aliceIdentity = X25519.generate() val bobIdentity = X25519.generate() - val initiated = X3DH.initiate( - aliceIdentity.private, - X3DH.Bundle( - identityKeyDH = X25519.rawFromPublicKey(bobIdentity.public), - signedPreKey = X25519.rawFromPublicKey(bobSigned.public), - oneTimePreKey = null, - ), - ) - val bobSecret = X3DH.respond( - identityPrivateDH = bobIdentity.private, - signedPreKeyPrivate = bobSigned.private, - oneTimePrivate = null, - initiatorIdentityDH = X25519.rawFromPublicKey(aliceIdentity.public), - initiatorEphemeral = initiated.ephemeralPublic, - ) + val initiated = + X3DH.initiate( + aliceIdentity.private, + X3DH.Bundle( + identityKeyDH = X25519.rawFromPublicKey(bobIdentity.public), + signedPreKey = X25519.rawFromPublicKey(bobSigned.public), + oneTimePreKey = null, + ), + ) + val bobSecret = + X3DH.respond( + identityPrivateDH = bobIdentity.private, + signedPreKeyPrivate = bobSigned.private, + oneTimePrivate = null, + initiatorIdentityDH = X25519.rawFromPublicKey(aliceIdentity.public), + initiatorEphemeral = initiated.ephemeralPublic, + ) assertContentEquals(initiated.sharedSecret, bobSecret, "X3DH disagreed before the ratchet started") - val alice = DoubleRatchet.initiator( - initiated.sharedSecret, X25519.rawFromPublicKey(bobSigned.public)) + val alice = + DoubleRatchet.initiator(initiated.sharedSecret, X25519.rawFromPublicKey(bobSigned.public)) val bob = DoubleRatchet.responder(bobSecret, bobSigned) return alice to bob } @@ -50,9 +46,6 @@ class DoubleRatchetTest { /** * The same pair, running protocol v3, a fresh ML-KEM secret mixed into * every ratchet step (§13). - * - * Bob's PQ prekey is the shared starting point: Alice encapsulates to it, - * and Bob holds its seed so he can decapsulate the first step. */ private fun pqPair(): Triple { val bobSigned = X25519.generate() @@ -60,27 +53,29 @@ class DoubleRatchetTest { val bobIdentity = X25519.generate() val bobPQ = PQKEM.generate() - val initiated = X3DH.initiate( - aliceIdentity.private, - X3DH.Bundle( - identityKeyDH = X25519.rawFromPublicKey(bobIdentity.public), - signedPreKey = X25519.rawFromPublicKey(bobSigned.public), - oneTimePreKey = null, - ), - bobPQ = bobPQ.publicKey, - ) - val bobSecret = X3DH.respond( - identityPrivateDH = bobIdentity.private, - signedPreKeyPrivate = bobSigned.private, - oneTimePrivate = null, - initiatorIdentityDH = X25519.rawFromPublicKey(aliceIdentity.public), - initiatorEphemeral = initiated.ephemeralPublic, - pq = bobPQ.seed to initiated.pqCiphertext!!, - ) + val initiated = + X3DH.initiate( + aliceIdentity.private, + X3DH.Bundle( + identityKeyDH = X25519.rawFromPublicKey(bobIdentity.public), + signedPreKey = X25519.rawFromPublicKey(bobSigned.public), + oneTimePreKey = null, + ), + bobPQ = bobPQ.publicKey, + ) + val bobSecret = + X3DH.respond( + identityPrivateDH = bobIdentity.private, + signedPreKeyPrivate = bobSigned.private, + oneTimePrivate = null, + initiatorIdentityDH = X25519.rawFromPublicKey(aliceIdentity.public), + initiatorEphemeral = initiated.ephemeralPublic, + pq = bobPQ.seed to initiated.pqCiphertext!!, + ) assertContentEquals(initiated.sharedSecret, bobSecret, "PQ X3DH disagreed") - val alice = DoubleRatchet.initiator( - initiated.sharedSecret, X25519.rawFromPublicKey(bobSigned.public), bobPQ.publicKey) + val alice = + DoubleRatchet.initiator(initiated.sharedSecret, X25519.rawFromPublicKey(bobSigned.public), bobPQ.publicKey) val bob = DoubleRatchet.responder(bobSecret, bobSigned, bobPQ.seed) return Triple(alice, bob, bobPQ) } @@ -89,15 +84,6 @@ class DoubleRatchetTest { /** * The cross-engine contract, pinned as bytes. - * - * The header goes into the AAD, so three implementations must produce - * byte-identical JSON or nothing opens across engines. Key order is - * `dh, pn, n, pqdh, pqct`, there are no spaces, and absent PQ fields are - * omitted ENTIRELY rather than written as null (§13.3). - * - * This is the assertion that would catch a JSON library being introduced - * here, or a field being reordered, neither of which any conversation - * test can see, because both sides would drift together. */ @Test fun `the canonical header is exactly the bytes the spec names`() { @@ -116,8 +102,7 @@ class DoubleRatchetTest { String(DoubleRatchet.Header(dh, 3, 7, pqdh, pqct).canonicalJson(), Charsets.UTF_8), "the v3 header changed shape", ) - // Half a PQ step is not a PQ step. One field without the other must - // fall back to the v2 shape rather than emit a malformed header. + // Half a PQ step is not a PQ step. assertEquals( "{\"dh\":\"" + Bytes.b64(dh) + "\",\"pn\":0,\"n\":0}", String(DoubleRatchet.Header(dh, 0, 0, pqdh, null).canonicalJson(), Charsets.UTF_8), @@ -137,20 +122,19 @@ class DoubleRatchetTest { } /** - * The property v3 exists for. v2 already protects a whole session - * against harvest-now-decrypt-later, because the root chain never recovers - * once the adversary has missed the start. What v2 lacks is post-quantum - * *healing*: after a compromise, recovery rests entirely on X25519 steps, - * which a quantum adversary breaks. So every step must carry NEW ML-KEM - * material, not merely the handshake's. + * The property v3 exists for. v2 already protects a whole session against + * harvest-now-decrypt-later, because the root chain never recovers once the + * adversary has missed the start. */ @Test fun `every ratchet step carries a fresh post-quantum key`() { val (alice, bob, bobPQ) = pqPair() val first = alice.encrypt("a1".toByteArray(), AD) - assertTrue(first.header.pqdh != null && first.header.pqct != null, - "the first sending chain carried no PQ step at all") + assertTrue( + first.header.pqdh != null && first.header.pqct != null, + "the first sending chain carried no PQ step at all", + ) // Encapsulated to Bob's PREKEY, which is what he can decapsulate with. val seen = mutableListOf(Bytes.b64(first.header.pqdh!!)) bob.decrypt(first, AD) @@ -165,17 +149,19 @@ class DoubleRatchetTest { seen += Bytes.b64(third.header.pqdh!!) bob.decrypt(third, AD) - assertEquals(seen.size, seen.toSet().size, - "a PQ key was reused across ratchet steps: $seen") - assertTrue(seen.none { it == Bytes.b64(bobPQ.publicKey) }, - "a ratchet step reused the handshake prekey instead of a fresh key") + assertEquals( + seen.size, + seen.toSet().size, + "a PQ key was reused across ratchet steps: $seen", + ) + assertTrue( + seen.none { it == Bytes.b64(bobPQ.publicKey) }, + "a ratchet step reused the handshake prekey instead of a fresh key", + ) } /** * Repeated on every message of a chain, not just the first (§13.3). - * Sending it once would mean losing that one message strands the entire - * chain: the receiver never gets the material to advance its root key, and - * nothing later in the chain can ever be decrypted. */ @Test fun `the post-quantum step repeats until the peer answers`() { @@ -187,34 +173,36 @@ class DoubleRatchetTest { for ((i, m) in listOf(first, second, third).withIndex()) { assertTrue(m.header.pqct != null, "message $i of the chain dropped the PQ step") } - assertContentEquals(first.header.pqdh, third.header.pqdh, - "the PQ key changed inside one sending chain") + assertContentEquals( + first.header.pqdh, + third.header.pqdh, + "the PQ key changed inside one sending chain", + ) - // And the whole chain still opens if the FIRST message is the one - // that goes missing, which is the loss the repetition exists for. + // And the whole chain still opens if the FIRST message is the one that + // goes missing, which is the loss the repetition exists for. assertContentEquals("a2".toByteArray(), bob.decrypt(second, AD)) assertContentEquals("a3".toByteArray(), bob.decrypt(third, AD)) } /** - * A stripped PQ step must not silently downgrade to a v1 root step. The - * header is bound into the AAD, so removing it fails the open, which is - * the difference between a downgrade attack and a broken message. + * A stripped PQ step must not silently downgrade to a v1 root step. */ @Test fun `a stripped post-quantum step does not open`() { val (alice, bob, _) = pqPair() val message = alice.encrypt("a1".toByteArray(), AD) - val stripped = DoubleRatchet.Message( - DoubleRatchet.Header(message.header.dh, message.header.pn, message.header.n), - message.ciphertext, - ) + val stripped = + DoubleRatchet.Message( + DoubleRatchet.Header(message.header.dh, message.header.pn, message.header.n), + message.ciphertext, + ) assertFailsWith { bob.decrypt(stripped, AD) } } /** - * A v3 session that lost its PQ state on a restart would keep working - * and silently fall back to X25519-only healing, nothing fails, and the + * A v3 session that lost its PQ state on a restart would keep working and + * silently fall back to X25519-only healing, nothing fails, and the * property is simply gone. */ @Test @@ -238,8 +226,10 @@ class DoubleRatchetTest { fun `a session without post-quantum material still runs v2`() { val (alice, bob) = pair() val message = alice.encrypt("hello".toByteArray(), AD) - assertTrue(message.header.pqdh == null && message.header.pqct == null, - "a v2 session emitted PQ header fields") + assertTrue( + message.header.pqdh == null && message.header.pqct == null, + "a v2 session emitted PQ header fields", + ) assertContentEquals("hello".toByteArray(), bob.decrypt(message, AD)) } @@ -252,8 +242,7 @@ class DoubleRatchetTest { @Test fun `a conversation going both ways stays in step`() { - // Each direction change turns the DH ratchet. Four turns is enough for a - // root-key or chain-key mistake to compound into a failure. + // Each direction change turns the DH ratchet. val (alice, bob) = pair() assertContentEquals("a1".toByteArray(), bob.decrypt(alice.encrypt("a1".toByteArray(), AD), AD)) assertContentEquals("b1".toByteArray(), alice.decrypt(bob.encrypt("b1".toByteArray(), AD), AD)) @@ -279,7 +268,7 @@ class DoubleRatchetTest { @Test fun `a message lost forever does not break the ones after it`() { val (alice, bob) = pair() - alice.encrypt("lost".toByteArray(), AD) // never delivered + alice.encrypt("lost".toByteArray(), AD) // never delivered val after = alice.encrypt("after".toByteArray(), AD) assertContentEquals("after".toByteArray(), bob.decrypt(after, AD)) } @@ -292,21 +281,23 @@ class DoubleRatchetTest { assertContentEquals("a1".toByteArray(), bob.decrypt(alice.encrypt("a1".toByteArray(), AD), AD)) assertContentEquals("b1".toByteArray(), alice.decrypt(bob.encrypt("b1".toByteArray(), AD), AD)) - alice.encrypt("dropped".toByteArray(), AD) // never delivered + alice.encrypt("dropped".toByteArray(), AD) // never delivered val next = alice.encrypt("next".toByteArray(), AD) assertContentEquals("next".toByteArray(), bob.decrypt(next, AD)) } @Test fun `the header is bound to the ciphertext`() { - // The AAD covers the canonical header, so editing the counter must break - // the tag rather than silently addressing a different message key. + // The AAD covers the canonical header, so editing the counter must + // break the tag rather than silently addressing a different message + // key. val (alice, bob) = pair() val msg = alice.encrypt("bound".toByteArray(), AD) - val tampered = DoubleRatchet.Message( - DoubleRatchet.Header(msg.header.dh, msg.header.pn, msg.header.n + 5), - msg.ciphertext, - ) + val tampered = + DoubleRatchet.Message( + DoubleRatchet.Header(msg.header.dh, msg.header.pn, msg.header.n + 5), + msg.ciphertext, + ) assertFailsWith { bob.decrypt(tampered, AD) } } @@ -323,10 +314,11 @@ class DoubleRatchetTest { // cost the receiver everything. val (alice, bob) = pair() val msg = alice.encrypt("x".toByteArray(), AD) - val absurd = DoubleRatchet.Message( - DoubleRatchet.Header(msg.header.dh, msg.header.pn, 500_000), - msg.ciphertext, - ) + val absurd = + DoubleRatchet.Message( + DoubleRatchet.Header(msg.header.dh, msg.header.pn, 500_000), + msg.ciphertext, + ) assertFailsWith { bob.decrypt(absurd, AD) } } diff --git a/engine/src/test/kotlin/io/flamenet/e2e/DraftsThatSurviveTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/DraftsThatSurviveTest.kt index c66c0d4..c10ead9 100644 --- a/engine/src/test/kotlin/io/flamenet/e2e/DraftsThatSurviveTest.kt +++ b/engine/src/test/kotlin/io/flamenet/e2e/DraftsThatSurviveTest.kt @@ -11,29 +11,31 @@ import kotlin.test.assertTrue /** * Half-written messages, kept across a relaunch. - * - * The reason this is not a two-line SharedPreferences change is where a - * draft has to live. It is plaintext the person has not decided to send, very - * often the most sensitive text in the app, because it is the thing they are - * still weighing. `SharedPreferences` writes readable XML that survives into - * backups. In the vault it is sealed under the same key as the conversation, - * with the record's own name bound into the AAD, and `forgetEverything` takes - * it on sign-out. */ class DraftsThatSurviveTest { - private class MapStorage : Vault.Storage { val records = LinkedHashMap() + override fun read(name: String) = records[name] - override fun write(name: String, bytes: ByteArray) { records[name] = bytes } - override fun delete(name: String) { records.remove(name) } + + override fun write( + name: String, + bytes: ByteArray, + ) { + records[name] = bytes + } + + override fun delete(name: String) { + records.remove(name) + } + override fun names() = records.keys.toList() } - private fun key(): SecretKey = - KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() + private fun key(): SecretKey = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() - // ---------------------------------------------------------------- surviving + // ---------------------------------------------------------------- + // surviving. @Test fun `a draft survives being stored and read back`() { @@ -46,20 +48,21 @@ class DraftsThatSurviveTest { } /** - * On disk it must be ciphertext. This is the assertion the whole - * ticket is about: the same feature built on SharedPreferences would put - * the text in readable XML, and no other test here would notice. + * On disk it must be ciphertext. */ @Test fun `a draft is not readable on disk`() { val storage = MapStorage() Vault(storage, key()).saveDraft(Vault.threadForUser(7), "the thing I have not sent") - val onDisk = storage.records.values.joinToString("") { bytes -> - bytes.joinToString("") { byte -> (byte.toInt() and 0xFF).toChar().toString() } - } - assertFalse(onDisk.contains("the thing I have not sent"), - "the draft was written in the clear") + val onDisk = + storage.records.values.joinToString("") { bytes -> + bytes.joinToString("") { byte -> (byte.toInt() and 0xFF).toChar().toString() } + } + assertFalse( + onDisk.contains("the thing I have not sent"), + "the draft was written in the clear", + ) } /** Multi-line text and awkward characters survive the round trip. */ @@ -73,12 +76,11 @@ class DraftsThatSurviveTest { assertEquals(awkward, Vault(storage, secret).draft(Vault.threadForUser(1))) } - // ---------------------------------------------------------------- forgetting + // ---------------------------------------------------------------- + // forgetting. /** - * Clearing the composer has to clear what was stored. A draft that - * outlives the message it became comes back as a draft of something already - * sent, which is worse than not having drafts at all. + * Clearing the composer has to clear what was stored. */ @Test fun `clearing the composer forgets the draft`() { @@ -92,9 +94,7 @@ class DraftsThatSurviveTest { } /** - * Blank is not a draft. Storing " " would restore a composer that - * looks empty and reports itself as having content, which on this screen - * means an enabled send button that sends nothing. + * Blank is not a draft. */ @Test fun `whitespace is not a draft`() { @@ -105,16 +105,15 @@ class DraftsThatSurviveTest { for (blank in listOf("", " ", " ", "\n", "\t\n ")) { Vault(storage, secret).saveDraft(thread, "something") Vault(storage, secret).saveDraft(thread, blank) - assertNull(Vault(storage, secret).draft(thread), - "kept \"$blank\", which would enable send on an empty composer") + assertNull( + Vault(storage, secret).draft(thread), + "kept \"$blank\", which would enable send on an empty composer", + ) } } /** - * But stored as typed, not trimmed. Somebody who left a trailing space - * was mid-word, and handing back a subtly different string than the one - * they left is the kind of small wrongness that makes an app feel - * untrustworthy. + * But stored as typed, not trimmed. */ @Test fun `text is kept exactly as it was typed`() { @@ -124,13 +123,12 @@ class DraftsThatSurviveTest { assertEquals("meet at ", Vault(storage, secret).draft(Vault.threadForUser(1))) } - // ---------------------------------------------------------------- which thread + // ---------------------------------------------------------------- which + // thread. /** * Three kinds of conversation share one record, so the ids must not - * collide. A group called "7", the person who is user 7, and an address - * "7" are three different threads, and the transcript already names them - * apart for exactly this reason. + * collide. */ @Test fun `three kinds of conversation cannot share a draft`() { @@ -166,13 +164,11 @@ class DraftsThatSurviveTest { assertEquals("second", Vault(storage, secret).draft(Vault.threadForUser(2))) } - // ---------------------------------------------------------------- sign-out + // ---------------------------------------------------------------- sign- + // out. /** - * Signing out must take drafts with it. Leaving the one piece of - * plaintext the person never chose to send behind, after the credential - * that justified it is gone, is exactly the residue the vault exists to - * avoid. + * Signing out must take drafts with it. */ @Test fun `signing out forgets drafts`() { diff --git a/engine/src/test/kotlin/io/flamenet/e2e/E2ESessionTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/E2ESessionTest.kt index 3c72fd7..28ed7a0 100644 --- a/engine/src/test/kotlin/io/flamenet/e2e/E2ESessionTest.kt +++ b/engine/src/test/kotlin/io/flamenet/e2e/E2ESessionTest.kt @@ -9,17 +9,24 @@ import kotlin.test.assertNull import kotlin.test.assertTrue class E2ESessionTest { - /** A bundle for [identity], as its relay would serve one. */ - private fun bundleFor(identity: DeviceIdentity, withOneTime: Boolean = true): PeerBundle { + private fun bundleFor( + identity: DeviceIdentity, + withOneTime: Boolean = true, + ): PeerBundle { val oneTime = if (withOneTime) identity.generateOneTimePreKeys(1).first() else null - return PeerBundle.fromWire(identity.registrationWire().also { wire -> - if (oneTime != null) { - wire.put("opk", JSONObject() - .put("id", oneTime.id) - .put("pub", Bytes.b64(oneTime.publicKey))) - } - }) + return PeerBundle.fromWire( + identity.registrationWire().also { wire -> + if (oneTime != null) { + wire.put( + "opk", + JSONObject() + .put("id", oneTime.id) + .put("pub", Bytes.b64(oneTime.publicKey)), + ) + } + }, + ) } @Test @@ -37,10 +44,8 @@ class E2ESessionTest { } /** - * The handshake has to ride EVERY message until one is answered, not - * just the first. Any message may be the one that arrives first, and a - * conversation where the peer never received the handshake looks, from the - * outside, like a contact who has stopped replying. + * The handshake has to ride EVERY message until one is answered, not just + * the first. */ @Test fun `the handshake repeats until the peer answers`() { @@ -51,7 +56,7 @@ class E2ESessionTest { val queued = (1..3).map { a.encrypt("m$it") } assertTrue(queued.all { it.type == "prekey" && it.handshake != null }) - // Bob receives the THIRD one first. It must be enough on its own. + // Bob receives the THIRD one first. val b = E2ESession.respond(bob, queued[2]) assertEquals("m3", b.decrypt(queued[2])) assertEquals("m1", b.decrypt(queued[0])) @@ -64,9 +69,7 @@ class E2ESessionTest { } /** - * The bug this exists to stop: sessions that lived only in memory. A client - * that loses them re-registers a device, and every contact is shown - * "security keys changed", the warning that is supposed to mean an attack. + * The bug this exists to stop: sessions that lived only in memory. */ @Test fun `a session survives being archived mid-conversation`() { @@ -88,20 +91,6 @@ class E2ESessionTest { /** * Both sides open the chat at once. - * - * Each runs X3DH before either has seen the other's prekey, so each holds - * its own chain and each is about to be handed a message on the other's. - * This is ordinary behaviour, two people tapping a conversation at the - * same moment, not an edge case. - * - * The failure it guards is not the first message. Both first messages open - * fine, because `respond` builds the peer's chain on demand. What breaks is - * everything AFTER the swap: each side now sends on the chain the other - * threw away, and a client that kept only the newest session cannot open - * any of it. Silently, an envelope that will not decrypt is skipped, not - * reported. - * - * So this asserts the second exchange, which is the one that used to die. */ @Test fun `simultaneous initiation does not strand the conversation`() { @@ -121,8 +110,7 @@ class E2ESessionTest { val bAdopted = E2ESession.respond(bob, aFirst) assertEquals("from alice", bAdopted.decrypt(aFirst)) - // The second exchange. Alice replies on the chain she adopted; Bob has - // that chain only as the one he started, so he needs the retired copy. + // The second exchange. val aSecond = aAdopted.encrypt("alice again") assertEquals("alice again", bOwn.decrypt(aSecond)) @@ -141,7 +129,7 @@ class E2ESessionTest { b.decrypt(first) val later = (1..3).map { a.encrypt("m$it") } - b.decrypt(later[2]) // arrives early; 1 and 2 are now skipped keys + b.decrypt(later[2]) // arrives early; 1 and 2 are now skipped keys b = E2ESession.fromArchive(JSONObject(b.archive().toString())) assertEquals("m1", b.decrypt(later[0])) assertEquals("m2", b.decrypt(later[1])) @@ -154,11 +142,11 @@ class E2ESessionTest { val impostor = DeviceIdentity.create() // A relay substituting its own prekey, signed by a key it controls. - // Nothing else in the protocol catches this. - val tampered = bundleFor(bob).copy( - signedPreKey = X25519.rawFromPublicKey(impostor.signedPreKey.public), - signedPreKeySignature = impostor.signedPreKeySignature(), - ) + val tampered = + bundleFor(bob).copy( + signedPreKey = X25519.rawFromPublicKey(impostor.signedPreKey.public), + signedPreKeySignature = impostor.signedPreKeySignature(), + ) assertFailsWith { E2ESession.initiate(alice, tampered) } } @@ -173,8 +161,7 @@ class E2ESessionTest { } /** - * Rotation without retention is message loss. A sender that fetched a - * bundle before the rotation is still entitled to be understood. + * Rotation without retention is message loss. */ @Test fun `an envelope built against a retired signed prekey still opens`() { @@ -198,8 +185,7 @@ class E2ESessionTest { val bob = DeviceIdentity.create() val queued = E2ESession.initiate(alice, bundleFor(bob)).encrypt("far too late") - // Rotate, then let the retention window pass. Guessing at the current - // key here would derive a garbage secret and report it as tampering. + // Rotate, then let the retention window pass. bob.rotateSignedPreKey() bob.pruneSignedPreKeys(System.currentTimeMillis() + DeviceIdentity.RETAIN_AFTER_MS + 1) assertFailsWith { E2ESession.respond(bob, queued) } @@ -250,6 +236,8 @@ class E2ESessionTest { assertEquals("after a reload", E2ESession.respond(restored, first).decrypt(first)) } - private fun assertContentEqualsB64(expected: ByteArray, actual: ByteArray) = - assertEquals(Bytes.b64(expected), Bytes.b64(actual)) + private fun assertContentEqualsB64( + expected: ByteArray, + actual: ByteArray, + ) = assertEquals(Bytes.b64(expected), Bytes.b64(actual)) } diff --git a/engine/src/test/kotlin/io/flamenet/e2e/Ed25519Test.kt b/engine/src/test/kotlin/io/flamenet/e2e/Ed25519Test.kt index d969e5a..1d6e9ba 100644 --- a/engine/src/test/kotlin/io/flamenet/e2e/Ed25519Test.kt +++ b/engine/src/test/kotlin/io/flamenet/e2e/Ed25519Test.kt @@ -7,43 +7,47 @@ import kotlin.test.assertFalse import kotlin.test.assertTrue /** - * The seed→public derivation is checked against the RFC 8032 §7.1 - * vectors, not against a round-trip through our own code. A round-trip proves - * only that this file agrees with itself, and every wrong clamp, endianness and - * sign convention agrees with itself perfectly. These vectors are what say the - * address a user gets here is the address they get on iOS and on the web. + * The seed→public derivation is checked against the RFC 8032 §7.1 vectors, not + * against a round-trip through our own code. */ class Ed25519Test { - - private fun hex(s: String) = ByteArray(s.length / 2) { - s.substring(it * 2, it * 2 + 2).toInt(16).toByte() - } + private fun hex(s: String) = + ByteArray(s.length / 2) { + s.substring(it * 2, it * 2 + 2).toInt(16).toByte() + } /** seed, expected public key, message, expected signature. */ - private val vectors = listOf( - Triple( - "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60", - "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a", - "" to "e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e065224901555fb8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b", - ), - Triple( - "4ccd089b28ff96da9db6c346ec114e0f5b8a319f35aba624da8cf6ed4fb8a6fb", - "3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c", - "72" to "92a009a9f0d4cab8720e820b5f642540a2b27b5416503f8fb3762223ebdb69da085ac1e43e15996e458f3613d0f11d8c387b2eaeb4302aeeb00d291612bb0c00", - ), - Triple( - "c5aa8df43f9f837bedb7442f31dcb7b166d38535076f094b85ce3a2e0b4458f7", - "fc51cd8e6218a1a38da47ed00230f0580816ed13ba3303ac5deb911548908025", - "af82" to "6291d657deec24024827e69c3abe01a30ce548a284743a445e3680d7db5ac3ac18ff9b538d16f290ae67f760984dc6594a7c15e9716ed28dc027beceea1ec40a", - ), - ) + private val vectors = + listOf( + Triple( + "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60", + "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a", + "" to + "e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e065224901555fb8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b", + ), + Triple( + "4ccd089b28ff96da9db6c346ec114e0f5b8a319f35aba624da8cf6ed4fb8a6fb", + "3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c", + "72" to + "92a009a9f0d4cab8720e820b5f642540a2b27b5416503f8fb3762223ebdb69da085ac1e43e15996e458f3613d0f11d8c387b2eaeb4302aeeb00d291612bb0c00", + ), + Triple( + "c5aa8df43f9f837bedb7442f31dcb7b166d38535076f094b85ce3a2e0b4458f7", + "fc51cd8e6218a1a38da47ed00230f0580816ed13ba3303ac5deb911548908025", + "af82" to + "6291d657deec24024827e69c3abe01a30ce548a284743a445e3680d7db5ac3ac18ff9b538d16f290ae67f760984dc6594a7c15e9716ed28dc027beceea1ec40a", + ), + ) @Test fun `a seed derives the public key RFC 8032 says it must`() { for ((seed, expected, _) in vectors) { val pair = Ed25519.fromSeed(hex(seed)) - assertContentEquals(hex(expected), pair.publicKey, - "seed $seed derived the wrong public key") + assertContentEquals( + hex(expected), + pair.publicKey, + "seed $seed derived the wrong public key", + ) } } @@ -88,9 +92,7 @@ class Ed25519Test { } /** - * Garbage from the wire must be a `false`, not an exception. A caller that - * has to catch around every verification eventually catches one badly, and - * a swallowed throw reads as a pass. + * Garbage from the wire must be a `false`, not an exception. */ @Test fun `malformed input verifies as false rather than throwing`() { diff --git a/engine/src/test/kotlin/io/flamenet/e2e/EditEnvelopeTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/EditEnvelopeTest.kt index 98d4d35..266bdd8 100644 --- a/engine/src/test/kotlin/io/flamenet/e2e/EditEnvelopeTest.kt +++ b/engine/src/test/kotlin/io/flamenet/e2e/EditEnvelopeTest.kt @@ -1,28 +1,15 @@ package io.flamenet.e2e +import org.json.JSONObject import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull import kotlin.test.assertTrue -import org.json.JSONObject /** * Replacing the wording of a message already sent (§6.15). - * - * Without this branch an edit renders as a duplicate message. An unknown - * kind with a caption and no attachment falls through to the last case in - * `parse`, which returns the caption as text, so an iPhone correcting a typo - * would appear on Android as a second bubble beside the original, with no - * indication that either replaced the other. That is the bug this fixes today, - * before any of the applying half exists. - * - * This is the codec only. `Session.Line` carries no `mid`, so nothing in - * an Android transcript can be named by an edit and the app swallows them for - * now, the same place reactions have been sitting (PARITY.md: "codec done, no - * UI"). One transcript change unlocks replies, reactions and edits together. */ class EditEnvelopeTest { - @Test fun `an edit names the message it replaces and carries the new wording`() { val wire = Content.edit("meet at 9", of = "abc") @@ -34,9 +21,7 @@ class EditEnvelopeTest { } /** - * The bug this exists to prevent. Before the branch, an edit fell - * through to the attachment path and came back as renderable text, a - * second bubble the sender never sent. + * The bug this exists to prevent. */ @Test fun `an edit is not a renderable message`() { @@ -44,21 +29,22 @@ class EditEnvelopeTest { assertNull(content.attachment, "an edit came back carrying an attachment") assertNull(content.reaction) assertNull(content.sticker) - assertTrue(content.editOf != null, - "an edit that is not recognisable as one will be drawn as a message") + assertTrue( + content.editOf != null, + "an edit that is not recognisable as one will be drawn as a message", + ) } /** - * It must not be swallowed as generic control traffic either. A build - * that consumed it silently would look like it worked while dropping every - * correction, which is worse than the duplicate bubble, because nobody - * would notice. + * It must not be swallowed as generic control traffic either. */ @Test fun `an edit is a known kind, not unknown control`() { assertTrue(Content.Kind.EDIT in Content.Kind.KNOWN) - assertTrue(Content.Kind.EDIT !in Content.Kind.CONTROL, - "an edit swallowed as control would drop corrections silently") + assertTrue( + Content.Kind.EDIT !in Content.Kind.CONTROL, + "an edit swallowed as control would drop corrections silently", + ) } /** Group routing survives, for when a group thread can name a message. */ @@ -70,32 +56,36 @@ class EditEnvelopeTest { } /** - * `re.author` is a claim, not evidence, anyone can put any user id - * there. The codec must not present it as authorship, and the encoder must - * not fill it in with something that looks like one. + * `re.author` is a claim, not evidence, anyone can put any user id there. */ @Test fun `the envelope makes no claim about who wrote the original`() { val json = JSONObject(Content.edit("meet at 9", of = "abc")) - assertEquals(0L, json.getJSONObject("re").getLong("author") as Long, - "the encoder asserted an author, which a receiver might believe") + assertEquals( + 0L, + json.getJSONObject("re").getLong("author") as Long, + "the encoder asserted an author, which a receiver might believe", + ) - // And a forged one parses identically, because nothing downstream reads it. - val forged = Content.edit("meet at 9", of = "abc") - .replace("\"author\":0", "\"author\":99") + // And a forged one parses identically, because nothing downstream reads + // it. + val forged = + Content + .edit("meet at 9", of = "abc") + .replace("\"author\":0", "\"author\":99") assertEquals("abc", Content.parse(forged).editOf) } - /** `fnc` first, like every other envelope, the receive side's marker - * check matches the serialized prefix. */ + /** + * `fnc` first, like every other envelope, the receive side's marker. + */ @Test fun `the envelope starts with the marker`() { assertTrue(Content.edit("hi", of = "abc").startsWith("{\"fnc\":1,")) } /** - * An edit with no target is not an edit. It must fall out of the branch - * rather than become one naming nothing. + * An edit with no target is not an edit. */ @Test fun `an edit with no target is not treated as an edit`() { @@ -105,7 +95,7 @@ class EditEnvelopeTest { /** * Both clients must agree byte-for-byte on the shape, since one encodes - * what the other parses. This is the shape iOS emits. + * what the other parses. */ @Test fun `the wire shape matches what iOS sends`() { @@ -119,19 +109,15 @@ class EditEnvelopeTest { /** * The single decision that keeps an edit out of the transcript. - * - * `Session` filters inbound envelopes on `isRenderable`, so this property is - * the whole of what stops a correction being filed as a new message. It is - * tested apart from the codec because it is the part that would still be wrong - * if the parse branch were right. */ class EditIsNotRenderableTest { - @Test fun `an edit is not filed as a message`() { val content = Content.parse(Content.edit("meet at 9", of = "abc")) - assertTrue(!content.isRenderable, - "a correction would be filed as a second bubble beside the original") + assertTrue( + !content.isRenderable, + "a correction would be filed as a second bubble beside the original", + ) } /** And ordinary text still is, or this fix has broken conversation. */ @@ -144,8 +130,8 @@ class EditIsNotRenderableTest { /** A reaction and a call signal stay excluded for their own reasons. */ @Test fun `the other non-messages are still excluded`() { - val reaction = Content.parse( - Content.reaction("👍", about = Content.Reference("abc", 7L, ""))) + val reaction = + Content.parse(Content.reaction("👍", about = Content.Reference("abc", 7L, ""))) assertTrue(!reaction.isRenderable) } } diff --git a/engine/src/test/kotlin/io/flamenet/e2e/FileEnvelopeInteropTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/FileEnvelopeInteropTest.kt index c411b6f..d9419fb 100644 --- a/engine/src/test/kotlin/io/flamenet/e2e/FileEnvelopeInteropTest.kt +++ b/engine/src/test/kotlin/io/flamenet/e2e/FileEnvelopeInteropTest.kt @@ -3,32 +3,26 @@ package io.flamenet.e2e import org.json.JSONObject import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertTrue -import kotlin.test.assertFalse /** * Documents sent between the two apps. - * - * The envelope in [IOS_FILE] was produced by the iOS build, not by this - * engine. Two implementations of one format that each only test against - * themselves agree right up to the day somebody sends a real file, and the - * symptom is a message that arrives as "[unsupported attachment]" with no clue - * why. This is the test that catches a drift in the field names before then. */ class FileEnvelopeInteropTest { - // Standard base64 with padding, which is what Bytes.unb64 decodes. private val key = "ERERERERERERERERERERERERERERERERERERERERERE=" private val digest = "IiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiI=" /** As iOS assembles it: `fn`, `cls`, `cmp` as additive top-level keys. */ - private val iosFile = """ + private val iosFile = + """ {"fnc":1,"kind":"file","caption":"📎 Lease agreement.pdf", "att":{"id":"${"a".repeat(64)}","key":"$key","digest":"$digest", "bytes":4096,"mime":"application/pdf","w":0,"h":0}, "fn":"Lease agreement.pdf","cls":2,"cmp":[0,1]} - """.trimIndent().replace("\n", "") + """.trimIndent().replace("\n", "") @Test fun `a file envelope from iOS parses with its name and label`() { @@ -52,17 +46,18 @@ class FileEnvelopeInteropTest { @Test fun `this engine assembles the same shape it parses`() { - val ref = Attachment.Ref( - kind = Attachment.FILE, - id = "b".repeat(64), - key = ByteArray(32) { 0x11 }, - digest = ByteArray(32) { 0x22 }, - bytes = 2048, - mime = "application/pdf", - filename = "Settlement draft.docx", - classification = 3, - compartments = listOf(2), - ) + val ref = + Attachment.Ref( + kind = Attachment.FILE, + id = "b".repeat(64), + key = ByteArray(32) { 0x11 }, + digest = ByteArray(32) { 0x22 }, + bytes = 2048, + mime = "application/pdf", + filename = "Settlement draft.docx", + classification = 3, + compartments = listOf(2), + ) val json = Content.attachment(ref) val parsed = JSONObject(json) assertEquals("file", parsed.getString("kind")) @@ -77,16 +72,21 @@ class FileEnvelopeInteropTest { } /** - * The compatibility rule, on this side too: never captionless, because - * a build that predates this kind shows only the caption. + * The compatibility rule, on this side too: never captionless, because a + * build that predates this kind shows only the caption. */ @Test fun `a file envelope always carries a caption naming the document`() { - val ref = Attachment.Ref( - kind = Attachment.FILE, id = "c".repeat(64), - key = ByteArray(32) { 0x11 }, digest = ByteArray(32) { 0x22 }, - bytes = 10, mime = "application/pdf", filename = "notes.txt", - ) + val ref = + Attachment.Ref( + kind = Attachment.FILE, + id = "c".repeat(64), + key = ByteArray(32) { 0x11 }, + digest = ByteArray(32) { 0x22 }, + bytes = 10, + mime = "application/pdf", + filename = "notes.txt", + ) val json = Content.attachment(ref) assertTrue(json.contains("\"caption\""), "a file envelope went out captionless") assertEquals("📎 notes.txt", Content.parse(json).text) @@ -94,22 +94,28 @@ class FileEnvelopeInteropTest { @Test fun `a caption the sender wrote is not replaced`() { - val ref = Attachment.Ref( - kind = Attachment.FILE, id = "d".repeat(64), - key = ByteArray(32) { 0x11 }, digest = ByteArray(32) { 0x22 }, - bytes = 10, mime = "application/pdf", filename = "notes.txt", - ) + val ref = + Attachment.Ref( + kind = Attachment.FILE, + id = "d".repeat(64), + key = ByteArray(32) { 0x11 }, + digest = ByteArray(32) { 0x22 }, + bytes = 10, + mime = "application/pdf", + filename = "notes.txt", + ) assertEquals("signed copy", Content.parse(Content.attachment(ref, "signed copy")).text) } /** An older peer sends no `fn`/`cls`/`cmp`. It must parse, not throw. */ @Test fun `an envelope without the new fields still parses`() { - val older = """ + val older = + """ {"fnc":1,"kind":"file","caption":"📎 old.pdf", "att":{"id":"${"e".repeat(64)}","key":"$key","digest":"$digest", "bytes":10,"mime":"application/pdf","w":0,"h":0}} - """.trimIndent().replace("\n", "") + """.trimIndent().replace("\n", "") val ref = assertNotNull(Content.parse(older).attachment) assertEquals("", ref.filename) assertEquals(0, ref.classification) @@ -117,17 +123,23 @@ class FileEnvelopeInteropTest { } /** - * The additive property that makes any of this safe: a photo envelope - * from this engine still carries none of the file keys, so a peer that has - * never heard of them reads it exactly as before. + * The additive property that makes any of this safe: a photo envelope from + * this engine still carries none of the file keys, so a peer that has never + * heard of them reads it exactly as before. */ @Test fun `a photo envelope carries none of the file keys`() { - val photo = Attachment.Ref( - kind = Attachment.IMAGE, id = "f".repeat(64), - key = ByteArray(32) { 0x11 }, digest = ByteArray(32) { 0x22 }, - bytes = 1000, mime = "image/jpeg", width = 100, height = 200, - ) + val photo = + Attachment.Ref( + kind = Attachment.IMAGE, + id = "f".repeat(64), + key = ByteArray(32) { 0x11 }, + digest = ByteArray(32) { 0x22 }, + bytes = 1000, + mime = "image/jpeg", + width = 100, + height = 200, + ) val json = Content.attachment(photo, "hi") assertFalse(json.contains("\"fn\""), "a photo envelope carried file fields") assertFalse(json.contains("\"cls\"")) @@ -136,27 +148,34 @@ class FileEnvelopeInteropTest { /** * A filename from a peer is display text and a name, never a path. - * Carried verbatim, and nothing here should ever join it onto a directory. */ @Test fun `a hostile filename survives as plain text`() { - val hostile = """ + val hostile = + """ {"fnc":1,"kind":"file","caption":"x", "att":{"id":"${"a".repeat(64)}","key":"$key","digest":"$digest", "bytes":1,"mime":"application/pdf","w":0,"h":0}, "fn":"../../../etc/passwd","cls":0,"cmp":[]} - """.trimIndent().replace("\n", "") - assertEquals("../../../etc/passwd", - assertNotNull(Content.parse(hostile).attachment).filename) + """.trimIndent().replace("\n", "") + assertEquals( + "../../../etc/passwd", + assertNotNull(Content.parse(hostile).attachment).filename, + ) } @Test fun `a file in a group keeps its routing`() { - val ref = Attachment.Ref( - kind = Attachment.FILE, id = "a".repeat(64), - key = ByteArray(32) { 0x11 }, digest = ByteArray(32) { 0x22 }, - bytes = 1, mime = "application/pdf", filename = "g.pdf", - ) + val ref = + Attachment.Ref( + kind = Attachment.FILE, + id = "a".repeat(64), + key = ByteArray(32) { 0x11 }, + digest = ByteArray(32) { 0x22 }, + bytes = 1, + mime = "application/pdf", + filename = "g.pdf", + ) val content = Content.parse(Content.attachment(ref, groupId = "g-123")) assertEquals("g-123", content.groupId) assertEquals(Attachment.FILE, content.attachment?.kind) diff --git a/engine/src/test/kotlin/io/flamenet/e2e/FilesInteropTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/FilesInteropTest.kt index db46788..fc4af60 100644 --- a/engine/src/test/kotlin/io/flamenet/e2e/FilesInteropTest.kt +++ b/engine/src/test/kotlin/io/flamenet/e2e/FilesInteropTest.kt @@ -1,46 +1,28 @@ package io.flamenet.e2e +import org.json.JSONObject import java.util.Base64 import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertTrue -import org.json.JSONObject /** * Interop for the Files vault, against the published reference vectors. - * - * These vectors were produced by the JavaScript engine that runs in the - * browser, a third implementation, not the Swift one. Nothing here checks - * that this code is self-consistent. It checks that a vault written by the - * software people already use opens on an Android phone, which is the only - * question that matters: a framing, authenticated-data or key-hierarchy mistake - * fails here, and nowhere else until somebody's files will not open. - * - * The file is `vectors/files-v1.json`, copied byte for byte from - * `Packages/FlamenetFiles/Tests/FilesEngineTests/vectors/v1.json` in the iOS - * repository. Copied rather than regenerated, deliberately: a vector file - * regenerated by the implementation under test proves only that it agrees with - * itself. - * - * Two of these vectors were additionally recomputed from scratch in Python, - * `struct.pack('>hQ')` for the label encoding, `hmac.new(...).digest()` for the - * name digests, before any Kotlin was run against them. The `café` case - * matches under NFC and NOT under NFD, which is what makes the normalisation - * form pinned rather than assumed. */ class FilesInteropTest { - - private val vectors: JSONObject = JSONObject( - requireNotNull(javaClass.getResourceAsStream("/vectors/files-v1.json")) { - "the interop vectors are missing from the test resources" - }.reader().readText() - ) + private val vectors: JSONObject = + JSONObject( + requireNotNull(javaClass.getResourceAsStream("/vectors/files-v1.json")) { + "the interop vectors are missing from the test resources" + }.reader().readText(), + ) private val cases: JSONObject get() = vectors.getJSONObject("cases") private val hierarchy: JSONObject get() = cases.getJSONObject("hierarchy") private fun b64(s: String): ByteArray = Base64.getDecoder().decode(s) + private fun hex(b: ByteArray) = b.joinToString("") { "%02x".format(it) } private fun labelOf(o: JSONObject): Label { @@ -56,10 +38,9 @@ class FilesInteropTest { /** The passphrase the vector identity was written under. */ private val passphrase: String get() = cases.getJSONObject("kdf").getString("passphrase") - private fun identityRecord(): FilesIdentity.Record = - FilesIdentity.Record.parse(hierarchy.getJSONObject("identityRecord").toString()) + private fun identityRecord(): FilesIdentity.Record = FilesIdentity.Record.parse(hierarchy.getJSONObject("identityRecord").toString()) - // ---- §1 The label encoding ------------------------------------------- + // ---- §1 The label encoding -------------------------------------------. @Test fun `the label encoding matches the reference implementation`() { @@ -76,29 +57,27 @@ class FilesInteropTest { } } - // ---- §2 Key derivation ----------------------------------------------- + // ---- §2 Key derivation -----------------------------------------------. /** - * The whole PBKDF2-then-HKDF chain, against a value the browser - * published. A wrong iteration count, a wrong salt position, a wrong - * context string or a password encoded as UTF-16 all land here. + * The whole PBKDF2-then-HKDF chain, against a value the browser published. */ @Test fun `the auth secret derives to the reference value`() { val kdf = cases.getJSONObject("kdf") - val derived = FilesKdf.authSecret( - passphrase = kdf.getString("passphrase"), - salt = b64(kdf.getString("salt")), - params = FilesKdf.Params(id = 1, iterations = kdf.getInt("iterations")), - ) + val derived = + FilesKdf.authSecret( + passphrase = kdf.getString("passphrase"), + salt = b64(kdf.getString("salt")), + params = FilesKdf.Params(id = 1, iterations = kdf.getInt("iterations")), + ) assertEquals(hex(b64(kdf.getString("authSecret"))), hex(derived)) } - // ---- §3 The identity record ------------------------------------------ + // ---- §3 The identity record ------------------------------------------. /** - * An identity written by the browser, opened here. This is the one - * that decides whether somebody can move to an Android phone at all. + * An identity written by the browser, opened here. */ @Test fun `an identity record written by the browser unlocks`() { @@ -112,9 +91,7 @@ class FilesInteropTest { /** * The public key above is recovered by agreeing against the X25519 base - * point, because the JCA will not derive one from an XDH private key. That - * it returns 32 bytes proves nothing; that they are *these* 32 bytes is the - * proof, and this states it separately so the reason survives a refactor. + * point, because the JCA will not derive one from an XDH private key. */ @Test fun `the base-point trick recovers the right public key`() { @@ -133,10 +110,9 @@ class FilesInteropTest { } /** - * The context is authenticated data as well as a KDF separator, so a - * record made for Files must not open as a Messenger one even with the - * right passphrase. Without this, one product's compromise would be all of - * them. + * The context is authenticated data as well as a KDF separator, so a record + * made for Files must not open as a Messenger one even with the right + * passphrase. */ @Test fun `a record does not open under another product's context`() { @@ -149,24 +125,21 @@ class FilesInteropTest { } } - // ---- §4 The asymmetric grant ----------------------------------------- + // ---- §4 The asymmetric grant -----------------------------------------. /** - * A label key granted by the browser, accepted here. This is how an - * area is shared between two people, and the salt is both public keys in a - * fixed order, the mistake that reads as working is swapping them, which - * produces a key that is simply different, with no error until the file - * fails to open. + * A label key granted by the browser, accepted here. */ @Test fun `a label key granted by the browser is accepted`() { val opened = FilesIdentity.unlock(identityRecord(), passphrase) - val labelKey = FileKeys.acceptLabelKey( - blob = b64(hierarchy.getString("labelKeyGrant")), - label = hierarchyLabel, - privateKey = opened.privateKey, - ownPublicRaw = opened.publicKeyRaw, - ) + val labelKey = + FileKeys.acceptLabelKey( + blob = b64(hierarchy.getString("labelKeyGrant")), + label = hierarchyLabel, + privateKey = opened.privateKey, + ownPublicRaw = opened.publicKeyRaw, + ) assertEquals(hex(b64(hierarchy.getString("labelKey"))), hex(labelKey)) } @@ -184,25 +157,22 @@ class FilesInteropTest { } } - // ---- §5 The key hierarchy -------------------------------------------- + // ---- §5 The key hierarchy --------------------------------------------. @Test fun `the key hierarchy unwraps as the browser wrapped it`() { val labelKey = b64(hierarchy.getString("labelKey")) - val nodeKey = FileKeys.unwrapNodeKey( - b64(hierarchy.getString("wrappedNodeKey")), labelKey, hierarchyLabel) + val nodeKey = + FileKeys.unwrapNodeKey(b64(hierarchy.getString("wrappedNodeKey")), labelKey, hierarchyLabel) assertEquals(hex(b64(hierarchy.getString("nodeKey"))), hex(nodeKey)) - val contentKey = FileKeys.unwrapContentKey( - b64(hierarchy.getString("wrappedContentKey")), nodeKey, hierarchyLabel) + val contentKey = + FileKeys.unwrapContentKey(b64(hierarchy.getString("wrappedContentKey")), nodeKey, hierarchyLabel) assertEquals(hex(b64(hierarchy.getString("contentKey"))), hex(contentKey)) } /** - * Reclassification must break the key, not succeed quietly. Edit the - * stored classification and the node key no longer unwraps. Without the - * label in the authenticated data, anything that could write to the index - * could move a file down a level and it would still open. + * Reclassification must break the key, not succeed quietly. */ @Test fun `a relabelled row does not unwrap`() { @@ -215,7 +185,7 @@ class FilesInteropTest { } } - // ---- §6 Names --------------------------------------------------------- + // ---- §6 Names ---------------------------------------------------------. /** * The `café` case is the one that matters: it matches under NFC and not @@ -242,16 +212,11 @@ class FilesInteropTest { /** * The composed and decomposed spellings of one name are one file. - * - * Written as escapes, not as two literals that look alike. Two source - * literals both typed as "cafe-with-an-accent" are the SAME bytes in this - * file, so the test would pass with normalisation removed entirely - which - * is precisely the failure it exists to catch. */ @Test fun `a decomposed name digests the same as a composed one`() { - val composed = "caf\u00e9" // e-acute, one code point - val decomposed = "cafe\u0301" // e, then a combining acute + val composed = "caf\u00e9" // e-acute, one code point + val decomposed = "cafe\u0301" // e, then a combining acute assertTrue( composed.toByteArray().size != decomposed.toByteArray().size, "these two spellings are meant to differ before normalisation", @@ -273,12 +238,13 @@ class FilesInteropTest { @Test fun `a filename written by the browser decrypts`() { - val name = FileNames.decrypt( - folderKey = b64(hierarchy.getString("nodeKey")), - blob = b64(hierarchy.getString("nameCiphertext")), - digest = b64(hierarchy.getString("nameDigest")), - label = hierarchyLabel, - ) + val name = + FileNames.decrypt( + folderKey = b64(hierarchy.getString("nodeKey")), + blob = b64(hierarchy.getString("nameCiphertext")), + digest = b64(hierarchy.getString("nameDigest")), + label = hierarchyLabel, + ) assertEquals(hierarchy.getString("expectedName"), name) } @@ -299,20 +265,20 @@ class FilesInteropTest { } } - // ---- §7 Content ------------------------------------------------------- + // ---- §7 Content -------------------------------------------------------. /** - * A file written by the browser, in more than one chunk. The vector - * uses a 16-byte chunk size precisely so the multi-chunk path is the one - * being tested, a single-chunk file would pass with the index and the - * final-chunk flag both wrong. + * A file written by the browser, in more than one chunk. */ @Test fun `content written by the browser decrypts, across chunks`() { val blob = b64(hierarchy.getString("content")) val chunkSize = hierarchy.getInt("contentChunkSize") - assertEquals(chunkSize.toLong(), FileContent.readU32be(blob, 5), - "the blob's own header disagrees with the vector's chunk size") + assertEquals( + chunkSize.toLong(), + FileContent.readU32be(blob, 5), + "the blob's own header disagrees with the vector's chunk size", + ) val plaintext = b64(hierarchy.getString("expectedContent")) assertTrue(plaintext.size > chunkSize, "this vector is not exercising the multi-chunk path") @@ -335,17 +301,7 @@ class FilesInteropTest { } /** - * Truncation, on a real blob. Drop the final chunk and the file - * would simply look shorter - except that the chunk before it says it is - * not last. - * - * The second half of this test is the important half. Rejecting a - * truncated file proves nothing on its own: a corrupted length, a wrong - * offset, any old bug would also reject it. What makes the final-chunk flag - * the thing doing the work is that the SAME bytes still open perfectly - * under their original `isLast = false` - so the ciphertext is authentic, - * intact and in the right place, and the only thing refusing it is the - * claim about where the file ends. + * Truncation, on a real blob. */ @Test fun `a truncated file is refused rather than silently shortened`() { @@ -359,14 +315,15 @@ class FilesInteropTest { assertFailsWith { FileContent.decrypt(truncated, key, hierarchyLabel) } // The same surviving bytes, opened as what they actually are: chunk 0 - // of a longer file. If this fails, the test above proved nothing. + // of a longer file. val baseNonce = blob.copyOfRange(9, FileContent.HEADER_LEN) val chunkZero = truncated.copyOfRange(FileContent.HEADER_LEN, truncated.size) - val plaintext = Aead.open( - key = key, - box = FileContent.nonce(baseNonce, 0) + chunkZero, - aad = FileContent.aad(hierarchyLabel, index = 0, isLast = false), - ) + val plaintext = + Aead.open( + key = key, + box = FileContent.nonce(baseNonce, 0) + chunkZero, + aad = FileContent.aad(hierarchyLabel, index = 0, isLast = false), + ) assertEquals( hex(b64(hierarchy.getString("expectedContent")).copyOfRange(0, plaintext.size)), hex(plaintext), @@ -375,12 +332,7 @@ class FilesInteropTest { } /** - * Reordering. Swap two chunks and every byte is still authentic - - * for a different index. - * - * Same discipline as truncation: the moved chunk is shown to open under - * the index it was written at and to fail under the one it was moved to. - * Otherwise "it threw" is all this would establish. + * Reordering. */ @Test fun `swapped chunks are refused`() { @@ -399,11 +351,12 @@ class FilesInteropTest { // The moved chunk is intact: it opens at the index it was written at. val baseNonce = blob.copyOfRange(9, FileContent.HEADER_LEN) - val opened = Aead.open( - key = key, - box = FileContent.nonce(baseNonce, 1) + last, - aad = FileContent.aad(hierarchyLabel, index = 1, isLast = true), - ) + val opened = + Aead.open( + key = key, + box = FileContent.nonce(baseNonce, 1) + last, + aad = FileContent.aad(hierarchyLabel, index = 1, isLast = true), + ) assertTrue(opened.isNotEmpty(), "chunk 1 should hold the tail of the file") // ...and does not open at the index it was moved to. assertFailsWith { diff --git a/engine/src/test/kotlin/io/flamenet/e2e/FilesVaultCryptoTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/FilesVaultCryptoTest.kt index 555f41b..819a3a0 100644 --- a/engine/src/test/kotlin/io/flamenet/e2e/FilesVaultCryptoTest.kt +++ b/engine/src/test/kotlin/io/flamenet/e2e/FilesVaultCryptoTest.kt @@ -7,43 +7,32 @@ import kotlin.test.assertTrue /** * The Files crypto, on the cases the reference vectors do not reach. - * - * The vectors in `FilesInteropTest` pin agreement with the browser, which is - * the question that matters most, but a vector file only covers the shapes - * somebody thought to write down. The boundaries below are where an - * implementation that matches every vector still breaks: an empty file, a file - * exactly one chunk long, a chunk size that overflows an `Int`. */ class FilesVaultCryptoTest { - private val label = Label(3, setOf(0, 1)) private val key = ByteArray(32) { (it * 7 + 1).toByte() } private fun hex(b: ByteArray) = b.joinToString("") { "%02x".format(it) } - // ---- Content boundaries ---------------------------------------------- + // ---- Content boundaries ----------------------------------------------. /** - * An empty file is one chunk, not zero. Zero chunks would leave a - * file with no authenticated end marker at all, indistinguishable from one - * truncated to nothing, which is precisely the attack the flag exists to - * stop. The round trip alone would pass either way, so the chunk count is - * asserted directly. + * An empty file is one chunk, not zero. */ @Test fun `an empty file still carries an authenticated end marker`() { val blob = FileContent.encrypt(ByteArray(0), key, label, chunkSize = 16) assertEquals( - FileContent.HEADER_LEN + FileContent.TAG_LEN, blob.size, + FileContent.HEADER_LEN + FileContent.TAG_LEN, + blob.size, "an empty file should be a header and exactly one empty, tagged chunk", ) assertEquals(0, FileContent.decrypt(blob, key, label).size) } /** - * The off-by-one that a round trip hides: a file whose length is an - * exact multiple of the chunk size. Written as N chunks, it must not be - * read back as N+1, nor as N-1 with the tail treated as padding. + * The off-by-one that a round trip hides: a file whose length is an exact + * multiple of the chunk size. */ @Test fun `a file exactly filling its chunks round-trips`() { @@ -51,7 +40,8 @@ class FilesVaultCryptoTest { val plaintext = ByteArray(16 * chunks) { (it % 251).toByte() } val blob = FileContent.encrypt(plaintext, key, label, chunkSize = 16) assertEquals( - FileContent.HEADER_LEN + chunks * (16 + FileContent.TAG_LEN), blob.size, + FileContent.HEADER_LEN + chunks * (16 + FileContent.TAG_LEN), + blob.size, "$chunks-chunk file has the wrong framed length", ) assertEquals(hex(plaintext), hex(FileContent.decrypt(blob, key, label))) @@ -61,30 +51,32 @@ class FilesVaultCryptoTest { @Test fun `a file one byte over a chunk boundary round-trips`() { val plaintext = ByteArray(17) { it.toByte() } - assertEquals(hex(plaintext), - hex(FileContent.decrypt(FileContent.encrypt(plaintext, key, label, 16), key, label))) + assertEquals( + hex(plaintext), + hex(FileContent.decrypt(FileContent.encrypt(plaintext, key, label, 16), key, label)), + ) } /** Larger than one default chunk, so the real path is exercised too. */ @Test fun `a file larger than the default chunk size round-trips`() { val plaintext = ByteArray(FileContent.DEFAULT_CHUNK_SIZE + 1_000) { (it % 256).toByte() } - assertEquals(hex(plaintext), - hex(FileContent.decrypt(FileContent.encrypt(plaintext, key, label), key, label))) + assertEquals( + hex(plaintext), + hex(FileContent.decrypt(FileContent.encrypt(plaintext, key, label), key, label)), + ) } /** - * A hostile chunk size in the header. `0xFFFFFFFF` read as a signed - * `Int` is -1, which passes a `> 0` check written the obvious way and then - * allocates nonsense. Read as a `Long`, it is refused. + * A hostile chunk size in the header. */ @Test fun `a chunk size that overflows an Int is refused`() { val blob = FileContent.encrypt(byteArrayOf(1, 2, 3), key, label, chunkSize = 16) for (bytes in listOf( - byteArrayOf(-1, -1, -1, -1), // 0xFFFFFFFF - byteArrayOf(-128, 0, 0, 0), // 0x80000000 - byteArrayOf(0, 0, 0, 0), // zero + byteArrayOf(-1, -1, -1, -1), // 0xFFFFFFFF + byteArrayOf(-128, 0, 0, 0), // 0x80000000 + byteArrayOf(0, 0, 0, 0), // zero )) { val hostile = blob.copyOf() bytes.copyInto(hostile, 5) @@ -105,9 +97,10 @@ class FilesVaultCryptoTest { fun `a newer format version is named, not treated as damage`() { val blob = FileContent.encrypt(byteArrayOf(1), key, label) val newer = blob.copyOf().also { it[4] = 9 } - val thrown = assertFailsWith { - FileContent.decrypt(newer, key, label) - } + val thrown = + assertFailsWith { + FileContent.decrypt(newer, key, label) + } assertTrue( thrown.message!!.contains("9"), "the version somebody needs in order to act on this was thrown away", @@ -115,34 +108,33 @@ class FilesVaultCryptoTest { } /** - * Every file gets a fresh base nonce. Reusing one across two files under - * one content key would repeat a nonce, and GCM does not degrade there, it - * hands over the authentication subkey. + * Every file gets a fresh base nonce. */ @Test fun `two encryptions of the same bytes use different nonces`() { val a = FileContent.encrypt(byteArrayOf(1, 2, 3), key, label) val b = FileContent.encrypt(byteArrayOf(1, 2, 3), key, label) assertTrue( - !a.copyOfRange(9, FileContent.HEADER_LEN).contentEquals( - b.copyOfRange(9, FileContent.HEADER_LEN)), + !a.copyOfRange(9, FileContent.HEADER_LEN).contentEquals(b.copyOfRange(9, FileContent.HEADER_LEN)), "the base nonce is being reused across files", ) } - // ---- Names ----------------------------------------------------------- + // ---- Names -----------------------------------------------------------. @Test fun `a name round-trips through encryption`() { val sealed = FileNames.encrypt(key, "Q3 Forecast.txt", label) - assertEquals("Q3 Forecast.txt", - FileNames.decrypt(key, sealed.ciphertext, sealed.digest, label)) + assertEquals( + "Q3 Forecast.txt", + FileNames.decrypt(key, sealed.ciphertext, sealed.digest, label), + ) } /** - * Two encryptions of one name differ, and that is required, not - * incidental: identical ciphertexts would let anything holding the rows see - * that two files share a name. + * Two encryptions of one name differ, and that is required, not incidental. + * identical ciphertexts would let anything holding the rows see that two + * files share a name. */ @Test fun `two encryptions of one name are not equal, but both open`() { @@ -164,8 +156,8 @@ class FilesVaultCryptoTest { } /** - * Per-folder keying is what stops anything holding the rows noticing - * that two folders contain a file of the same name. + * Per-folder keying is what stops anything holding the rows noticing that + * two folders contain a file of the same name. */ @Test fun `the same name under two folder keys digests differently`() { @@ -188,7 +180,7 @@ class FilesVaultCryptoTest { } } - // ---- Wrapping --------------------------------------------------------- + // ---- Wrapping ---------------------------------------------------------. @Test fun `a symmetric wrap round-trips and is bound to its label`() { @@ -201,10 +193,8 @@ class FilesVaultCryptoTest { } /** - * The two wrap forms carry different version bytes, and neither will - * open the other's. Without that, a symmetric blob handed to the asymmetric - * opener would be parsed as a public key plus a box and fail somewhere far - * less obvious. + * The two wrap forms carry different version bytes, and neither will open + * the other's. */ @Test fun `the two wrap forms do not open each other`() { @@ -235,23 +225,21 @@ class FilesVaultCryptoTest { } /** - * A grant made for one person does not open for another, even though - * the ciphertext is well-formed and the label is right. Both public keys go - * into the salt for exactly this. + * A grant made for one person does not open for another, even though the + * ciphertext is well-formed and the label is right. */ @Test fun `a grant addressed to somebody else does not open`() { val intended = X25519.generate() val other = X25519.generate() - val grant = FileKeys.grantLabelKey( - ByteArray(32), label, X25519.rawFromPublicKey(intended.public)) + val grant = + FileKeys.grantLabelKey(ByteArray(32), label, X25519.rawFromPublicKey(intended.public)) assertFailsWith { - FileKeys.acceptLabelKey( - grant, label, other.private, X25519.rawFromPublicKey(other.public)) + FileKeys.acceptLabelKey(grant, label, other.private, X25519.rawFromPublicKey(other.public)) } } - // ---- The identity record --------------------------------------------- + // ---- The identity record ---------------------------------------------. /** A fast parameter set: this asserts the format, not the work factor. */ private val fast = FilesKdf.Params(id = 1, iterations = 1_000) @@ -273,9 +261,7 @@ class FilesVaultCryptoTest { /** * The record holds `ciphertext || tag` WITHOUT the nonce, which is what - * WebCrypto emits and what every other client expects. Storing the combined - * box would produce a record this engine reads back perfectly and no other - * client can open at all, so the length is asserted rather than trusted. + * WebCrypto emits and what every other client expects. */ @Test fun `the stored private key is the box without its nonce`() { @@ -291,9 +277,10 @@ class FilesVaultCryptoTest { @Test fun `the wrong passphrase is refused with a sentence`() { val record = FilesIdentity.protect(ByteArray(32), "one", fast) - val thrown = assertFailsWith { - FilesIdentity.unlock(record, "two") - } + val thrown = + assertFailsWith { + FilesIdentity.unlock(record, "two") + } assertTrue( thrown.message!!.length > 20 && thrown.message!!.contains("passphrase"), "a person reading this cannot tell what went wrong: ${thrown.message}", @@ -301,9 +288,8 @@ class FilesVaultCryptoTest { } /** - * PKCS#8 in, raw 32 out, and a wrapper that is not ours is refused - * rather than silently sliced. A 48-byte value whose prefix belongs to some - * other curve would otherwise yield 32 bytes of something else entirely. + * PKCS#8 in, raw 32 out, and a wrapper that is not ours is refused rather + * than silently sliced. */ @Test fun `a foreign PKCS8 wrapper is refused`() { @@ -312,18 +298,17 @@ class FilesVaultCryptoTest { assertEquals(hex(ByteArray(32) { 4 }), hex(FilesIdentity.rawFromPkcs8(good))) assertFailsWith { - FilesIdentity.rawFromPkcs8(good.copyOf().also { it[9] = 0x2C }) // a different OID + FilesIdentity.rawFromPkcs8(good.copyOf().also { it[9] = 0x2C }) // a different OID } assertFailsWith { FilesIdentity.rawFromPkcs8(good.copyOfRange(0, 47)) } } - // ---- Derivation ------------------------------------------------------- + // ---- Derivation -------------------------------------------------------. /** - * One passphrase, one salt, two contexts, two unrelated secrets. This is - * the whole reason a Files compromise is not a Messenger compromise. + * One passphrase, one salt, two contexts, two unrelated secrets. */ @Test fun `two contexts derive unrelated secrets from one passphrase`() { @@ -335,21 +320,22 @@ class FilesVaultCryptoTest { } /** - * The passphrase reaches PBKDF2 as UTF-8 bytes, and this is the case - * that would differ if a provider were deciding: an astral character is one - * code point, two Kotlin chars, and four UTF-8 bytes. + * The passphrase reaches PBKDF2 as UTF-8 bytes, and this is the case that + * would differ if a provider were deciding: an astral character is one code + * point, two Kotlin chars, and four UTF-8 bytes. */ @Test fun `a passphrase outside the basic plane derives from its UTF-8 bytes`() { val salt = ByteArray(16) { 7 } val emoji = "correct horse 🐎" - val direct = Hkdf.derive( - ikm = FilesKdf.pbkdf2HmacSha256( - emoji.toByteArray(Charsets.UTF_8), salt, fast.iterations, 32), - salt = ByteArray(0), - info = FilesKdf.Context.FILES_IDENTITY.toByteArray(Charsets.UTF_8), - length = 32, - ) + val direct = + Hkdf.derive( + ikm = + FilesKdf.pbkdf2HmacSha256(emoji.toByteArray(Charsets.UTF_8), salt, fast.iterations, 32), + salt = ByteArray(0), + info = FilesKdf.Context.FILES_IDENTITY.toByteArray(Charsets.UTF_8), + length = 32, + ) assertEquals( hex(direct), hex(FilesKdf.derive(emoji, salt, FilesKdf.Context.FILES_IDENTITY, fast)), diff --git a/engine/src/test/kotlin/io/flamenet/e2e/ForwardWithoutProvenanceTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/ForwardWithoutProvenanceTest.kt index 548c77d..080e793 100644 --- a/engine/src/test/kotlin/io/flamenet/e2e/ForwardWithoutProvenanceTest.kt +++ b/engine/src/test/kotlin/io/flamenet/e2e/ForwardWithoutProvenanceTest.kt @@ -1,22 +1,16 @@ package io.flamenet.e2e +import org.json.JSONObject import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue -import org.json.JSONObject /** * Passing a message on without passing on where it came from (§6.16). - * - * The codec half. The Android app cannot forward yet, that needs the - * transcript work in #10, but it must already understand an envelope the - * iPhone app sends, so a forwarded message arrives as a message and not as - * something the reader is told nothing about. */ class ForwardWithoutProvenanceTest { - @Test fun `a forwarded message is marked as forwarded`() { val content = Content.parse(Content.text("meet at 6", forwarded = true)) @@ -30,8 +24,8 @@ class ForwardWithoutProvenanceTest { } /** - *the envelope says THAT it was forwarded and nothing - * about who wrote it or what it answered. + * the envelope says THAT it was forwarded and nothing about who wrote it or + * what it answered. */ @Test fun `the envelope carries no author and no reply chain`() { @@ -42,9 +36,7 @@ class ForwardWithoutProvenanceTest { } /** - * `1`, not `true`, §6.10's shape. `org.json`'s optBoolean does not - * coerce a number, so an engine that read only one shape would silently - * miss every forward the other two engines send. + * `1`, not `true`, §6.10's shape. */ @Test fun `both flag shapes are accepted`() { @@ -53,8 +45,10 @@ class ForwardWithoutProvenanceTest { assertTrue(Content.parse(asNumber).isForwarded) val asBool = asNumber.replace("\"fwd\":1", "\"fwd\":true") - assertTrue(Content.parse(asBool).isForwarded, - "an engine writing `true` would have its forwards read as ordinary messages") + assertTrue( + Content.parse(asBool).isForwarded, + "an engine writing `true` would have its forwards read as ordinary messages", + ) } /** A forward is still a message: it renders, unlike control traffic. */ @@ -66,14 +60,21 @@ class ForwardWithoutProvenanceTest { /** The flag rides an attachment too, a forwarded photo is still a forward. */ @Test fun `a forwarded attachment keeps the flag`() { - val ref = Attachment.Ref( - kind = Attachment.IMAGE, id = "a".repeat(64), - key = ByteArray(32) { 1 }, digest = ByteArray(32) { 2 }, - bytes = 1024, mime = "image/jpeg", width = 100, height = 100, - duration = 0.0, expires = 0.0, - ) - val content = Content.parse( - Content.attachment(ref, caption = "look", forwarded = true)) + val ref = + Attachment.Ref( + kind = Attachment.IMAGE, + id = "a".repeat(64), + key = ByteArray(32) { 1 }, + digest = ByteArray(32) { 2 }, + bytes = 1024, + mime = "image/jpeg", + width = 100, + height = 100, + duration = 0.0, + expires = 0.0, + ) + val content = + Content.parse(Content.attachment(ref, caption = "look", forwarded = true)) assertTrue(content.isForwarded) assertEquals("look", content.text) } diff --git a/engine/src/test/kotlin/io/flamenet/e2e/GroupMessageIdentityTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/GroupMessageIdentityTest.kt index de2555d..30a295f 100644 --- a/engine/src/test/kotlin/io/flamenet/e2e/GroupMessageIdentityTest.kt +++ b/engine/src/test/kotlin/io/flamenet/e2e/GroupMessageIdentityTest.kt @@ -1,22 +1,15 @@ package io.flamenet.e2e +import org.json.JSONObject import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertNull -import org.json.JSONObject /** * Group messages carrying an id of their own (§6.9). - * - * The wire always carried one and the transcript never stored it. - * `Content.groupText` has taken a `messageId` since §6.9 shipped and `parse` - * reads it back, `Session.Line` was where it stopped. So nothing in a group - * could name an earlier message: no reply to resolve, no reaction to fold, no - * edit to apply. All three were "codec done" and none of them worked. */ class GroupMessageIdentityTest { - @Test fun `a group message round trips its id`() { val content = Content.parse(Content.groupText("meet at 6", "g1", messageId = "abc")) @@ -25,26 +18,25 @@ class GroupMessageIdentityTest { } /** - * One id for the whole fan-out. A group message is ONE message: a reply - * or a reaction from any member has to name the same thing, so - * per-recipient ids would make that impossible. + * One id for the whole fan-out. */ - fun bodiesFor(memberCount: Int, messageId: String) = - (1..memberCount).map { Content.groupText("meet at 6", "g1", messageId = messageId) } + fun bodiesFor( + memberCount: Int, + messageId: String, + ) = (1..memberCount).map { Content.groupText("meet at 6", "g1", messageId = messageId) } @Test fun `every copy of a group message carries the same id`() { val ids = bodiesFor(3, "shared").map { Content.parse(it).messageId } - assertEquals(setOf("shared"), ids.toSet(), - "members received different ids for one message") + assertEquals( + setOf("shared"), + ids.toSet(), + "members received different ids for one message", + ) } /** - * The queued retry has to be the same message. `sendGroup` builds - * the envelope twice, once to send and once for the outbox, so an id - * minted inside either would differ between them, and a member who was - * offline would eventually receive a *different* message from everybody - * else, which nothing could reconcile with a reply or a reaction. + * The queued retry has to be the same message. */ @Test fun `the queued copy carries the same id as the sent one`() { @@ -57,15 +49,15 @@ class GroupMessageIdentityTest { @Test fun `a group reply round trips its quote`() { val about = Content.Reference("earlier", 9L, "what they said") - val content = Content.parse( - Content.groupText("yes", "g1", messageId = "m", about = about)) + val content = + Content.parse(Content.groupText("yes", "g1", messageId = "m", about = about)) assertEquals("earlier", content.about?.messageId) assertEquals("what they said", content.about?.preview) } /** - * A reference survives the transcript's JSON round trip, which is the - * part that was missing, the codec was always fine. + * A reference survives the transcript's JSON round trip, which is the part + * that was missing, the codec was always fine. */ @Test fun `a reference survives being stored and read back`() { @@ -91,8 +83,12 @@ class GroupMessageIdentityTest { @Test fun `a message stored with an id reads it back`() { - val stored = JSONObject().put("text", "hi").put("mine", false).put("at", 1L) - .put("mid", "abc") + val stored = + JSONObject() + .put("text", "hi") + .put("mine", false) + .put("at", 1L) + .put("mid", "abc") assertNotNull(stored.optString("mid").takeIf { it.isNotEmpty() }) assertEquals("abc", stored.optString("mid")) } diff --git a/engine/src/test/kotlin/io/flamenet/e2e/GroupStoryTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/GroupStoryTest.kt index 2b29db8..99226e4 100644 --- a/engine/src/test/kotlin/io/flamenet/e2e/GroupStoryTest.kt +++ b/engine/src/test/kotlin/io/flamenet/e2e/GroupStoryTest.kt @@ -6,19 +6,17 @@ import kotlin.test.assertTrue /** * Stories posted to a group (spec §6.24, ios#55). - * - * The group's roster is the audience, and the sender's story audience does - * not apply. Two different lists making two different statements, who may - * see this account's stories, and who is in a conversation. They will disagree, - * and the roster wins. */ class GroupStoryTest { - @Test fun `every member but yourself is a recipient`() { - val reach = Vault.groupStoryReach( - members = listOf(1, 2, 3), me = 1, - allowedByAudience = setOf(2, 3), contacts = listOf(2, 3)) + val reach = + Vault.groupStoryReach( + members = listOf(1, 2, 3), + me = 1, + allowedByAudience = setOf(2, 3), + contacts = listOf(2, 3), + ) assertEquals(listOf(2L, 3L), reach.recipients) } @@ -28,9 +26,13 @@ class GroupStoryTest { */ @Test fun `a member excluded from your stories still receives a group story`() { - val reach = Vault.groupStoryReach( - members = listOf(1, 2, 3), me = 1, - allowedByAudience = setOf(2), contacts = listOf(2, 3)) + val reach = + Vault.groupStoryReach( + members = listOf(1, 2, 3), + me = 1, + allowedByAudience = setOf(2), + contacts = listOf(2, 3), + ) assertEquals(listOf(2L, 3L), reach.recipients) assertEquals(listOf(3L), reach.excludedByAudience) } @@ -38,58 +40,106 @@ class GroupStoryTest { /** The other half: a member who is not a contact at all. */ @Test fun `a member who is not a contact is named separately`() { - val reach = Vault.groupStoryReach( - members = listOf(1, 2, 9), me = 1, - allowedByAudience = setOf(2), contacts = listOf(2)) + val reach = + Vault.groupStoryReach( + members = listOf(1, 2, 9), + me = 1, + allowedByAudience = setOf(2), + contacts = listOf(2), + ) assertEquals(listOf(9L), reach.notContacts) assertTrue(reach.excludedByAudience.isEmpty()) } /** - * The categories must not double-count. Somebody who is not a contact - * is not "excluded from your stories", they were never on that list. + * The categories must not double-count. */ @Test fun `a non-contact is not also counted as excluded`() { - val reach = Vault.groupStoryReach( - members = listOf(1, 9), me = 1, - allowedByAudience = emptySet(), contacts = emptyList()) + val reach = + Vault.groupStoryReach( + members = listOf(1, 9), + me = 1, + allowedByAudience = emptySet(), + contacts = emptyList(), + ) assertEquals(listOf(9L), reach.notContacts) assertTrue(reach.excludedByAudience.isEmpty()) } @Test fun `nothing is disclosed when the lists agree`() { - val reach = Vault.groupStoryReach( - members = listOf(1, 2), me = 1, - allowedByAudience = setOf(2), contacts = listOf(2)) + val reach = + Vault.groupStoryReach( + members = listOf(1, 2), + me = 1, + allowedByAudience = setOf(2), + contacts = listOf(2), + ) assertTrue(reach.excludedByAudience.isEmpty()) assertTrue(reach.notContacts.isEmpty()) } - // -- Storage -------------------------------------------------------------- + // -- Storage + // --------------------------------------------------------------. private class MapStorage : Vault.Storage { val records = LinkedHashMap() + override fun read(name: String) = records[name] - override fun write(name: String, bytes: ByteArray) { records[name] = bytes } - override fun delete(name: String) { records.remove(name) } + + override fun write( + name: String, + bytes: ByteArray, + ) { + records[name] = bytes + } + + override fun delete(name: String) { + records.remove(name) + } + override fun names() = records.keys.toList() } /** Without persisting `gid`, a reload forgets which story went where. */ @Test fun `the group survives the vault round trip`() { - val vault = Vault(MapStorage(), - javax.crypto.KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()) + val vault = + Vault( + MapStorage(), + javax.crypto.KeyGenerator + .getInstance("AES") + .apply { init(256) } + .generateKey(), + ) val now = System.currentTimeMillis() / 1000.0 - vault.saveStories(listOf(Vault.Story( - id = "s1", authorId = 7, mine = false, - ref = Attachment.Ref( - kind = Attachment.STORY, id = "b1", key = ByteArray(32), - digest = ByteArray(32), bytes = 1, mime = "image/jpeg", - width = 1, height = 1, duration = 0.0, expires = now + 600), - caption = "", postedAt = 1, gid = "g1")), now) + vault.saveStories( + listOf( + Vault.Story( + id = "s1", + authorId = 7, + mine = false, + ref = + Attachment.Ref( + kind = Attachment.STORY, + id = "b1", + key = ByteArray(32), + digest = ByteArray(32), + bytes = 1, + mime = "image/jpeg", + width = 1, + height = 1, + duration = 0.0, + expires = now + 600, + ), + caption = "", + postedAt = 1, + gid = "g1", + ), + ), + now, + ) assertEquals("g1", vault.loadStories().single().gid) } } diff --git a/engine/src/test/kotlin/io/flamenet/e2e/HdAccountTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/HdAccountTest.kt index 2d4d8b3..ca330e6 100644 --- a/engine/src/test/kotlin/io/flamenet/e2e/HdAccountTest.kt +++ b/engine/src/test/kotlin/io/flamenet/e2e/HdAccountTest.kt @@ -9,27 +9,19 @@ import kotlin.test.assertTrue /** * From a recovery phrase to an address somebody can be paid at. - * - * The property that matters most here is that the signing key and the - * displayed address come from the same derivation. If they ever disagreed, - * the wallet would sign with a key that does not own the address it showed. The - * transaction being rejected is the GOOD outcome; the bad one is signing for an - * address somebody else controls. */ class HdAccountTest { - private val words by lazy { Bip39.englishWordlist() } private val phrase by lazy { Bip39.phrase(ByteArray(16), words) } private val seed by lazy { Bip39.seed(phrase) } private fun account() = HdAccount(seed) - // ---- The path -------------------------------------------------------- + // ---- The path --------------------------------------------------------. /** - * BIP-44 with every level hardened, which is what this curve requires - * and what every other Solana wallet uses. A different shape here derives - * addresses nobody else can reproduce from the same phrase. + * BIP-44 with every level hardened, which is what this curve requires and + * what every other Solana wallet uses. */ @Test fun `the derivation path is the standard Solana shape`() { @@ -38,11 +30,10 @@ class HdAccountTest { assertEquals("m/44H/501H/3H/0H/0H", HdAccount(seed, account = 3).path(0)) } - // ---- The property that guards the money ------------------------------ + // ---- The property that guards the money ------------------------------. /** - * The signing key owns the address that was displayed. Everything else - * in the wallet rests on this one line being true. + * The signing key owns the address that was displayed. */ @Test fun `the signing key owns the address that was shown`() { @@ -51,8 +42,11 @@ class HdAccountTest { val shown = account.receive(index) val signing = account.signingSeed(index) val fromSigningKey = Base58.encode(Ed25519.fromSeed(signing).publicKey) - assertEquals(shown.address, fromSigningKey, - "index $index would sign with a key that does not own its address") + assertEquals( + shown.address, + fromSigningKey, + "index $index would sign with a key that does not own its address", + ) } } @@ -65,20 +59,17 @@ class HdAccountTest { val message = "a transaction".toByteArray() val signature = Ed25519.sign(seedAt0, message) val publicKey = Base58.decode(shown.address)!! - // `(publicKey, signature, message)`. All three are ByteArray, so - // getting the order wrong compiles perfectly and fails at runtime, - // which is what happened writing this test, and is a good argument for - // the assertion existing at all. - assertTrue(Ed25519.verify(publicKey, signature, message), - "the signature does not verify under the address it was made for") + // `(publicKey, signature, message)`. + assertTrue( + Ed25519.verify(publicKey, signature, message), + "the signature does not verify under the address it was made for", + ) } - // ---- Fresh addresses ------------------------------------------------- + // ---- Fresh addresses -------------------------------------------------. /** - * A new address per receive is the point. Reusing one lets anybody - * watching a public chain group every payment somebody ever received, the - * one linkability a wallet can prevent, since the chain will not forget. + * A new address per receive is the point. */ @Test fun `every index gives a different address`() { @@ -109,11 +100,11 @@ class HdAccountTest { ) } - // ---- Restoring ------------------------------------------------------- + // ---- Restoring -------------------------------------------------------. /** - * The whole promise of a recovery phrase: the same words give back the - * same addresses, on any device and any implementation. + * The whole promise of a recovery phrase: the same words give back the same + * addresses, on any device and any implementation. */ @Test fun `the same phrase restores the same addresses`() { @@ -129,7 +120,7 @@ class HdAccountTest { assertNotEquals(account().receive(0).address, other.receive(0).address) } - // ---- What an address is, and is not ---------------------------------- + // ---- What an address is, and is not ----------------------------------. /** An address is Base58 of 32 bytes, so it decodes back to the key. */ @Test @@ -160,25 +151,19 @@ class HdAccountTest { } /** - * The limit, asserted as a passing test so nobody reads - * `looksLikeAddress` as stronger than it is. - * - * A Solana address carries NO checksum. A mistyped character that still - * decodes to 32 bytes is a perfectly valid address that nobody holds the - * key for, money sent there is gone, and neither this function nor the - * network will catch it. Bitcoin's Base58Check would; this cannot. - * - * The only real defences are pasting rather than typing, and showing the - * address back to be compared. + * The limit, asserted as a passing test so nobody reads `looksLikeAddress` + * as stronger than it is. */ @Test fun `a corrupted address can still look valid, and that is not fixable here`() { val real = account().receive(0).address // Walk the alphabet at one position until the result still decodes to - // 32 bytes. On a checksummed format this search would fail. - val nearMiss = Base58.ALPHABET.asSequence() - .map { real.take(3) + it + real.drop(4) } - .firstOrNull { it != real && HdAccount.looksLikeAddress(it) } + // 32 bytes. + val nearMiss = + Base58.ALPHABET + .asSequence() + .map { real.take(3) + it + real.drop(4) } + .firstOrNull { it != real && HdAccount.looksLikeAddress(it) } assertTrue( nearMiss != null, "no single-character change produced a still-valid address, if this " + diff --git a/engine/src/test/kotlin/io/flamenet/e2e/HistoryExportTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/HistoryExportTest.kt index b74baea..dbe2a4b 100644 --- a/engine/src/test/kotlin/io/flamenet/e2e/HistoryExportTest.kt +++ b/engine/src/test/kotlin/io/flamenet/e2e/HistoryExportTest.kt @@ -11,43 +11,62 @@ import kotlin.test.assertTrue /** * The history backup opens only with the right words, and merging never loses - * what a device already has. The dedupe rules are the part worth guarding: - * the same backup imported twice must add nothing the second time. - * - * These run on the JVM. The crypto here (PBKDF2, AES-GCM via the platform - * `Cipher`) is deliberately the same JCA surface the vault already exercises - * on Android, but per the Conscrypt rule, the on-device instrumentation - * suite is what proves the Android side. + * what a device already has. */ class HistoryExportTest { - private class MapStorage : Vault.Storage { val records = LinkedHashMap() + override fun read(name: String) = records[name] - override fun write(name: String, bytes: ByteArray) { records[name] = bytes } - override fun delete(name: String) { records.remove(name) } + + override fun write( + name: String, + bytes: ByteArray, + ) { + records[name] = bytes + } + + override fun delete(name: String) { + records.remove(name) + } + override fun names() = records.keys.toList() } - private fun key(): SecretKey = - KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() + private fun key(): SecretKey = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() private fun vault() = Vault(MapStorage(), key()) - private fun line(text: String, mine: Boolean, at: Long): JSONObject = - JSONObject().put("author", 0).put("text", text).put("mine", mine).put("at", at) + private fun line( + text: String, + mine: Boolean, + at: Long, + ): JSONObject = + JSONObject() + .put("author", 0) + .put("text", text) + .put("mine", mine) + .put("at", at) private fun populated(): Vault { val v = vault() v.append("user:42", line("packing now", true, 1_756_400_000_000)) v.append("user:42", line("bring the charger", false, 1_756_400_060_000)) v.append("group:g1", line("meeting moved", false, 1_756_400_120_000)) - v.saveCallLog(JSONArray().put(JSONObject() - .put("peer", 42).put("in", true).put("ok", true).put("at", 1_756_400_500_000))) + v.saveCallLog( + JSONArray().put( + JSONObject() + .put("peer", 42) + .put("in", true) + .put("ok", true) + .put("at", 1_756_400_500_000), + ), + ) return v } - // -- Sealing -------------------------------------------------------------- + // -- Sealing + // --------------------------------------------------------------. @Test fun `a backup round trips with the right passphrase`() { @@ -89,7 +108,8 @@ class HistoryExportTest { assertTrue(!v.exportPayload().has("outbox")) } - // -- Merging -------------------------------------------------------------- + // -- Merging + // --------------------------------------------------------------. @Test fun `merging into an empty device takes everything`() { @@ -105,9 +125,10 @@ class HistoryExportTest { val device = vault() device.append("user:42", line("sent after the export", false, 1_756_500_000_000)) device.mergeImportedPayload(populated().exportPayload(), nowSeconds = 0.0) - val texts = device.history("user:42").let { arr -> - (0 until arr.length()).map { arr.getJSONObject(it).getString("text") } - } + val texts = + device.history("user:42").let { arr -> + (0 until arr.length()).map { arr.getJSONObject(it).getString("text") } + } assertEquals(listOf("packing now", "bring the charger", "sent after the export"), texts) } @@ -126,11 +147,21 @@ class HistoryExportTest { @Test fun `an expired story does not sneak back in through a backup`() { val source = vault() - val items = JSONArray() - .put(JSONObject().put("id", "story-live").put("author", 42).put("at", 5) - .put("ref", JSONObject().put("exp", 0.0))) - .put(JSONObject().put("id", "story-dead").put("author", 42).put("at", 6) - .put("ref", JSONObject().put("exp", 100.0))) + val items = + JSONArray() + .put( + JSONObject() + .put("id", "story-live") + .put("author", 42) + .put("at", 5) + .put("ref", JSONObject().put("exp", 0.0)), + ).put( + JSONObject() + .put("id", "story-dead") + .put("author", 42) + .put("at", 6) + .put("ref", JSONObject().put("exp", 100.0)), + ) // Write through the raw record shape the vault itself uses. val payload = source.exportPayload().put("stories", JSONObject().put("items", items)) diff --git a/engine/src/test/kotlin/io/flamenet/e2e/HkdfTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/HkdfTest.kt index a434cd4..ea493a0 100644 --- a/engine/src/test/kotlin/io/flamenet/e2e/HkdfTest.kt +++ b/engine/src/test/kotlin/io/flamenet/e2e/HkdfTest.kt @@ -7,16 +7,9 @@ import kotlin.test.assertTrue /** * HKDF-SHA256 against RFC 5869's published test vectors. - * - * Deliberately the RFC's numbers and not ours. A third engine checked only - * against the first two learns whatever they both got wrong; checked against - * the document, it can disagree with them, which is the only way it is worth - * writing. */ class HkdfTest { - - private fun hex(s: String): ByteArray = - s.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + private fun hex(s: String): ByteArray = s.chunked(2).map { it.toInt(16).toByte() }.toByteArray() private fun ByteArray.hex(): String = joinToString("") { "%02x".format(it) } @@ -73,10 +66,7 @@ class HkdfTest { } /** - * An empty salt must mean HashLen zeros, not "no HMAC key". The distinction - * matters because `SecretKeySpec` throws on a zero-LENGTH key, so the naive - * version of this crashes on exactly the case §12 of the spec uses, the - * app derives its per-context secrets with an empty HKDF salt. + * An empty salt must mean HashLen zeros, not "no HMAC key". */ @Test fun `empty salt is hashlen zeros, not an error`() { @@ -98,9 +88,7 @@ class HkdfTest { } /** - * The counter is one byte starting at 1, so 255 blocks is the ceiling. A - * naive loop wraps at 256 and starts returning key material it has already - * returned, silently. + * The counter is one byte starting at 1, so 255 blocks is the ceiling. */ @Test fun `refuses more than 255 blocks rather than repeating itself`() { diff --git a/engine/src/test/kotlin/io/flamenet/e2e/HybridSessionTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/HybridSessionTest.kt index 0638e18..2e26eb1 100644 --- a/engine/src/test/kotlin/io/flamenet/e2e/HybridSessionTest.kt +++ b/engine/src/test/kotlin/io/flamenet/e2e/HybridSessionTest.kt @@ -11,71 +11,65 @@ import kotlin.test.assertTrue /** * Two devices negotiating the hybrid handshake, end to end. - * - * A session is post-quantum only when both ends are. The primitive - * working proves nothing on its own: what matters is that a real registration - * publishes usable material, that a real peer picks it up, that the ciphertext - * survives the envelope, and that the responder finds the seed again. Any one - * of those failing leaves a device that advertises `proto: 2` and derives a - * different key from everybody, which surfaces as messages that will not - * decrypt and points nowhere near the cause. - * - * The other half of these tests is about what happens when the relay lies, - * because everything in a bundle passes through one. */ class HybridSessionTest { - /** A device that has published post-quantum material, as a peer sees it. */ - private fun published(device: DeviceIdentity, withOneTime: Boolean = true): PeerBundle { + private fun published( + device: DeviceIdentity, + withOneTime: Boolean = true, + ): PeerBundle { val opk = device.generateOneTimePreKeys(1).first() val wire = device.registrationWire() val pqOtp = if (withOneTime) device.generatePQOneTimePreKeys(1).first() else null - val bundle = JSONObject() - .put("device_id", wire.getString("device_id")) - .put("ik_dh", wire.getString("ik_dh")) - .put("ik_sig", wire.getString("ik_sig")) - .put("spk_id", wire.getInt("spk_id")) - .put("spk", wire.getString("spk")) - .put("spk_sig", wire.getString("spk_sig")) - .put("proto", wire.getInt("proto")) - .put("caps_sig", wire.getString("caps_sig")) - .put("pqspk_id", wire.getInt("pqspk_id")) - .put("pqspk", wire.getString("pqspk")) - .put("pqspk_sig", wire.getString("pqspk_sig")) - .put("opk", JSONObject().put("id", opk.id).put("pub", Bytes.b64(opk.publicKey))) + val bundle = + JSONObject() + .put("device_id", wire.getString("device_id")) + .put("ik_dh", wire.getString("ik_dh")) + .put("ik_sig", wire.getString("ik_sig")) + .put("spk_id", wire.getInt("spk_id")) + .put("spk", wire.getString("spk")) + .put("spk_sig", wire.getString("spk_sig")) + .put("proto", wire.getInt("proto")) + .put("caps_sig", wire.getString("caps_sig")) + .put("pqspk_id", wire.getInt("pqspk_id")) + .put("pqspk", wire.getString("pqspk")) + .put("pqspk_sig", wire.getString("pqspk_sig")) + .put("opk", JSONObject().put("id", opk.id).put("pub", Bytes.b64(opk.publicKey))) if (pqOtp != null) { - bundle.put("pqopk", JSONObject() - .put("id", pqOtp.id) - .put("pub", Bytes.b64(pqOtp.publicKey)) - .put("sig", Bytes.b64(pqOtp.signature))) + bundle.put( + "pqopk", + JSONObject() + .put("id", pqOtp.id) + .put("pub", Bytes.b64(pqOtp.publicKey)) + .put("sig", Bytes.b64(pqOtp.signature)), + ) } return PeerBundle.fromWire(bundle) } /** * A peer that has proven v2 and only v2, the released fleet. - * - * `copy(proto = 2)` alone would NOT do: the capability signature commits - * to the version, so a bundle whose proto was edited reads as v1 to every - * peer. It has to be re-signed for 2 to be a v2 device. */ - private fun publishedV2(device: DeviceIdentity, withOneTime: Boolean = true): PeerBundle = - published(device, withOneTime).copy( - proto = 2, capabilitySignature = device.capabilitySignature(2)) + private fun publishedV2( + device: DeviceIdentity, + withOneTime: Boolean = true, + ): PeerBundle = published(device, withOneTime).copy(proto = 2, capabilitySignature = device.capabilitySignature(2)) /** A v1 device: exactly what this engine published before ML-KEM existed. */ private fun publishedV1(device: DeviceIdentity): PeerBundle { val opk = device.generateOneTimePreKeys(1).first() val wire = device.registrationWire(postQuantum = false) - return PeerBundle.fromWire(JSONObject() - .put("device_id", wire.getString("device_id")) - .put("ik_dh", wire.getString("ik_dh")) - .put("ik_sig", wire.getString("ik_sig")) - .put("spk_id", wire.getInt("spk_id")) - .put("spk", wire.getString("spk")) - .put("spk_sig", wire.getString("spk_sig")) - .put("proto", wire.getInt("proto")) - .put("opk", JSONObject().put("id", opk.id).put("pub", Bytes.b64(opk.publicKey)))) + return PeerBundle.fromWire( + JSONObject() + .put("device_id", wire.getString("device_id")) + .put("ik_dh", wire.getString("ik_dh")) + .put("ik_sig", wire.getString("ik_sig")) + .put("spk_id", wire.getInt("spk_id")) + .put("spk", wire.getString("spk")) + .put("spk_sig", wire.getString("spk_sig")) + .put("proto", wire.getInt("proto")) + .put("opk", JSONObject().put("id", opk.id).put("pub", Bytes.b64(opk.publicKey))), + ) } // MARK: - The happy path @@ -86,8 +80,11 @@ class HybridSessionTest { val bob = DeviceIdentity.create() val session = E2ESession.initiate(alice, published(bob)) - assertEquals(3, session.version, - "both ends implement the ratchet, so this must be v3") + assertEquals( + 3, + session.version, + "both ends implement the ratchet, so this must be v3", + ) val envelope = session.encrypt("post-quantum hello") assertNotNull(envelope.pq, "the KEM ciphertext has to travel with the handshake") @@ -99,21 +96,17 @@ class HybridSessionTest { val bobSide = E2ESession.respond(bob, delivered) // 3, and asserting 2 here is what hid a real bug for weeks. - // The initiator is v3 two assertions above; a responder that answers - // the same handshake as v2 disagrees with its own peer about which - // protocol the session runs. Kotlin-to-Kotlin never noticed, because - // both ends drive the ratchet off their own PQ state and ignore the - // label, so this test round-tripped happily while encoding the defect. - // - // A v3 peer that does enforce it does notice: iOS refuses a ratchet - // step with no post-quantum material as a downgrade, so every message - // Android sent into an iOS-initiated session was dropped on arrival and - // the conversation ran one way only. - assertEquals(3, bobSide.version, - "answering a v3 handshake makes a v3 session, not a v2 one") + assertEquals( + 3, + bobSide.version, + "answering a v3 handshake makes a v3 session, not a v2 one", + ) assertEquals("post-quantum hello", bobSide.decrypt(delivered)) - assertContentEquals(session.associatedData, bobSide.associatedData, - "both sides must bind the same ciphertext digest") + assertContentEquals( + session.associatedData, + bobSide.associatedData, + "both sides must bind the same ciphertext digest", + ) // And the reply comes back on the same session. val reply = bobSide.encrypt("and hello back") @@ -121,10 +114,7 @@ class HybridSessionTest { } /** - * The last-resort key is a fallback, not a choice. Every session - * then shares one KEM key, which is a real weakening, but it is far better - * than dropping to v1 silently, which is what a device with no last-resort - * key would force. + * The last-resort key is a fallback, not a choice. */ @Test fun `a drained one-time pool still gives a post-quantum session`() { @@ -169,11 +159,7 @@ class HybridSessionTest { // MARK: - What the relay is not allowed to do /** - * The substitution this whole layer exists to prevent. A relay that - * can swap in an ML-KEM key of its own reads the post-quantum half of every - * new session, exactly what `spk_sig` stops classically. A key whose - * signature does not check out is treated as absent, so the session drops - * to v1 rather than to a key the relay chose. + * The substitution this whole layer exists to prevent. */ @Test fun `a substituted post-quantum prekey is ignored, not used`() { @@ -182,21 +168,25 @@ class HybridSessionTest { val honest = published(bob) val relaysOwnKey = PQKEM.generate() - val tampered = honest.copy( - pqOneTimePreKey = relaysOwnKey.publicKey, - pqSignedPreKey = relaysOwnKey.publicKey, + val tampered = + honest.copy( + pqOneTimePreKey = relaysOwnKey.publicKey, + pqSignedPreKey = relaysOwnKey.publicKey, + ) + assertNull( + tampered.provenPostQuantumPreKey(), + "an unsigned KEM key must not be encapsulated to", + ) + assertEquals( + 1, + E2ESession.initiate(alice, tampered).version, + "and the session falls back to v1, not to the relay's key", ) - assertNull(tampered.provenPostQuantumPreKey(), - "an unsigned KEM key must not be encapsulated to") - assertEquals(1, E2ESession.initiate(alice, tampered).version, - "and the session falls back to v1, not to the relay's key") } /** * A relay can also lie the other way, advertise a v2 device as v1 and - * quietly strip the post-quantum half off every new session. `caps_sig` is - * what makes the claim checkable; where the signature is missing the peer - * reads as v1, which is the safe reading of "we cannot tell". + * quietly strip the post-quantum half off every new session. */ @Test fun `a bundle with no capability signature is treated as v1`() { @@ -209,18 +199,17 @@ class HybridSessionTest { fun `a capability signature does not transfer between versions`() { val bob = DeviceIdentity.create() // A version this engine does not claim, so the edit cannot be - // accidentally valid. This read `proto = 3` while the engine published - // 2; the constant moving is what makes a hardcoded neighbour like that - // stop testing anything. + // accidentally valid. val forAnotherVersion = published(bob).copy(proto = DeviceIdentity.PROTOCOL_VERSION + 1) - assertNull(forAnotherVersion.provenPostQuantumPreKey(), - "the signature commits to the version it claims") + assertNull( + forAnotherVersion.provenPostQuantumPreKey(), + "the signature commits to the version it claims", + ) } /** - * The one-time id is signed big-endian, and the id is inside the - * signature so a relay cannot renumber a key to make a peer spend the wrong - * one. + * The one-time id is signed big-endian, and the id is inside the signature + * so a relay cannot renumber a key to make a peer spend the wrong one. */ @Test fun `a renumbered one-time prekey does not verify`() { @@ -233,12 +222,6 @@ class HybridSessionTest { /** * The advertised version and the capability signature must agree. - * - * A peer verifies `Ed25519(ik_sig, "FlamenetE2E_Caps_v2" ‖ uint8(proto))` - * against the proto in the same bundle. Bumping the constant without the - * signature following it produces a bundle every peer silently reads as v1 - * `provenPostQuantumPreKey()` returns null and the whole post-quantum - * path disappears, with nothing anywhere reporting an error. */ @Test fun `the published bundle claims v3 and proves it`() { @@ -250,24 +233,14 @@ class HybridSessionTest { // that matters: it re-verifies the signature over the claimed version. val seen = published(device) assertEquals(3, seen.proto) - assertNotNull(seen.provenPostQuantumPreKey(), - "the capability signature does not cover the version claimed") + assertNotNull( + seen.provenPostQuantumPreKey(), + "the capability signature does not cover the version claimed", + ) } /** * A v2 peer must not be sent a v3 ratchet step. - * - * The released fleet advertises `proto: 2`: it does post-quantum key - * AGREEMENT and has no post-quantum ratchet. Attaching `pqdh`/`pqct` to a - * message for one of those devices breaks the conversation twice over, - * their root step derives without `ss` and under the `_v1` info string - * while ours used `_v3`, and their canonical header omits the two fields - * entirely so the AAD does not match either. The first message never opens - * and never will. - * - * Offering a PQ prekey is NOT the same claim as implementing §13. Every - * v2 device offers one. The version has to be what gates this, which is - * what §13.4 says and what the iOS engine already does. */ @Test fun `a peer that only claims v2 is not sent a post-quantum ratchet step`() { @@ -276,10 +249,14 @@ class HybridSessionTest { // A real v2 bundle: PQ prekeys published, capability signed for 2. val session = E2ESession.initiate(alice, publishedV2(bob)) val envelope = session.encrypt("hello") - assertNull(envelope.header.pqdh, - "a v2 peer was sent a v3 ratchet step it cannot read") - assertNull(envelope.header.pqct, - "a v2 peer was sent a PQ ciphertext it cannot decapsulate") + assertNull( + envelope.header.pqdh, + "a v2 peer was sent a v3 ratchet step it cannot read", + ) + assertNull( + envelope.header.pqct, + "a v2 peer was sent a PQ ciphertext it cannot decapsulate", + ) } // MARK: - Persistence @@ -302,14 +279,15 @@ class HybridSessionTest { /** * A device archived before this engine had ML-KEM has no such block, and - * every existing install is one. It must load as a v1 device rather than - * fail, and publish post-quantum material the next time it registers. + * every existing install is one. */ @Test fun `an archive from before post-quantum still loads`() { val old = DeviceIdentity.create() val archive = old.archive() - archive.remove("pqSpk"); archive.remove("pqOtp"); archive.remove("nextPQId") + archive.remove("pqSpk") + archive.remove("pqOtp") + archive.remove("nextPQId") val loaded = DeviceIdentity.fromArchive(JSONObject(archive.toString())) assertTrue(!loaded.hasPQMaterial) @@ -319,11 +297,7 @@ class HybridSessionTest { } /** - * A prekey we already spent is not an error. A duplicate delivery of - * a v2 prekey envelope names a one-time key this device has consumed, and - * throwing there would turn a retransmission into a dead conversation. It - * simply cannot be v2, and then fails to decrypt, which the caller's three - * chains already handle. + * A prekey we already spent is not an error. */ @Test fun `a replayed prekey envelope does not throw`() { @@ -332,8 +306,7 @@ class HybridSessionTest { val envelope = E2ESession.initiate(alice, published(bob)).encrypt("once") assertEquals("once", E2ESession.respond(bob, envelope).decrypt(envelope)) - // The one-time key is gone now. The second attempt must fail as a - // decrypt, not as an exception out of the handshake. + // The one-time key is gone now. val second = E2ESession.respond(bob, envelope) assertEquals(1, second.version, "no PQ seed left, so this cannot be v2") assertFailsWith { second.decrypt(envelope) } @@ -357,9 +330,7 @@ class HybridSessionTest { // MARK: - The wire /** - * The v2 block is all-or-nothing, and the relay refuses a partial - * one. A device advertising `proto: 2` without a usable last-resort prekey - * would be picked for v2 by every peer and then fail every handshake. + * The v2 block is all-or-nothing, and the relay refuses a partial one. */ @Test fun `a post-quantum registration publishes the whole block`() { @@ -382,11 +353,7 @@ class HybridSessionTest { } /** - * The KEM ciphertext rides the prekey envelope and nothing after it. Every - * later message is byte-identical to v1, because the secret is already in - * the root key and the associated data binds the ciphertext for the life of - * the session (§12.6), republishing 1088 bytes per message would be pure - * cost. + * The KEM ciphertext rides the prekey envelope and nothing after it. */ @Test fun `the ciphertext travels once, not on every message`() { @@ -401,9 +368,7 @@ class HybridSessionTest { assertNotNull(session.encrypt("two").pq) val bobSide = E2ESession.respond(bob, first) - // Read it before replying. A responder that answers without - // decrypting has no receiving chain to step from, the same mistake a - // caller would make, so the test makes it once and not again. + // Read it before replying. bobSide.decrypt(first) session.decrypt(bobSide.encrypt("answered")) assertNull(session.encrypt("three").pq, "answered, so the handshake stops repeating") diff --git a/engine/src/test/kotlin/io/flamenet/e2e/KeyLogHeadSignatureTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/KeyLogHeadSignatureTest.kt index d9c9d2a..960ee47 100644 --- a/engine/src/test/kotlin/io/flamenet/e2e/KeyLogHeadSignatureTest.kt +++ b/engine/src/test/kotlin/io/flamenet/e2e/KeyLogHeadSignatureTest.kt @@ -6,16 +6,15 @@ import kotlin.test.assertTrue /** * Audit M1: a log head is only evidence if its signature verifies under the - * PINNED issuer key. These pin the exact string the relay signs - * (`fnkt-v1||`) so the two implementations cannot drift apart, - * a mismatch there would silently turn every head into "unverifiable". + * PINNED issuer key. */ class KeyLogHeadSignatureTest { - private val issuer = Ed25519.generate() - private fun sign(size: Int, root: String): ByteArray = - Ed25519.sign(issuer.seed, "fnkt-v1|$size|$root".toByteArray(Charsets.UTF_8)) + private fun sign( + size: Int, + root: String, + ): ByteArray = Ed25519.sign(issuer.seed, "fnkt-v1|$size|$root".toByteArray(Charsets.UTF_8)) @Test fun `a genuine head verifies`() { val root = Bytes.b64(ByteArray(32) { 7 }) @@ -24,23 +23,29 @@ class KeyLogHeadSignatureTest { @Test fun `a head signed for a different size is refused`() { val root = Bytes.b64(ByteArray(32) { 7 }) - assertFalse(KeyLog.verifyHead(13, root, sign(12, root), issuer.publicKey), - "a relay must not be able to replay a signature onto another size") + assertFalse( + KeyLog.verifyHead(13, root, sign(12, root), issuer.publicKey), + "a relay must not be able to replay a signature onto another size", + ) } @Test fun `a head signed for a different root is refused`() { val real = Bytes.b64(ByteArray(32) { 7 }) val forged = Bytes.b64(ByteArray(32) { 9 }) - assertFalse(KeyLog.verifyHead(12, forged, sign(12, real), issuer.publicKey), - "swapping the root is exactly the fork this signature exists to catch") + assertFalse( + KeyLog.verifyHead(12, forged, sign(12, real), issuer.publicKey), + "swapping the root is exactly the fork this signature exists to catch", + ) } @Test fun `a head signed by another key is refused`() { val other = Ed25519.generate() val root = Bytes.b64(ByteArray(32) { 7 }) - val bySomeoneElse = Ed25519.sign( - other.seed, "fnkt-v1|12|$root".toByteArray(Charsets.UTF_8)) - assertFalse(KeyLog.verifyHead(12, root, bySomeoneElse, issuer.publicKey), - "the PIN is what decides; a freshly served key must not substitute") + val bySomeoneElse = + Ed25519.sign(other.seed, "fnkt-v1|12|$root".toByteArray(Charsets.UTF_8)) + assertFalse( + KeyLog.verifyHead(12, root, bySomeoneElse, issuer.publicKey), + "the PIN is what decides; a freshly served key must not substitute", + ) } } diff --git a/engine/src/test/kotlin/io/flamenet/e2e/LabelTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/LabelTest.kt index 2247f15..180e6e2 100644 --- a/engine/src/test/kotlin/io/flamenet/e2e/LabelTest.kt +++ b/engine/src/test/kotlin/io/flamenet/e2e/LabelTest.kt @@ -8,19 +8,11 @@ import kotlin.test.assertTrue /** * The security lattice, and the two Bell–LaPadula rules it exists to enforce. - * - * The encoding vectors were computed independently, not read off this - * implementation. The ten bytes are authenticated data for every construction - * that uses a label, so an encoding that differs between platforms does not warn - * it produces a decryption failure, or plaintext that quietly belongs to the - * wrong sensitivity. A vector taken from the code under test would pin whatever - * the code happens to do. */ class LabelTest { - private fun hex(bytes: ByteArray) = bytes.joinToString("") { "%02x".format(it) } - // ---- The encoding ---------------------------------------------------- + // ---- The encoding ----------------------------------------------------. /** `int16be classification || uint64be compartment mask`. */ @Test @@ -32,10 +24,8 @@ class LabelTest { } /** - * A negative classification and a full compartment mask together, the - * case where a sign-extension mistake or an `Int` in place of a `Long` - * shows up. Both halves are all-ones, so a wrong shift produces something - * that still looks plausible. + * A negative classification and a full compartment mask together, the case + * where a sign-extension mistake or an `Int` in place of a `Long` shows up. */ @Test fun `the extremes encode without sign-extension damage`() { @@ -43,10 +33,7 @@ class LabelTest { } /** - * Bit 63 is the sign bit of a Long. Building the mask with an `Int` - * shift, or reading it back as one, loses it, and the compartment that - * vanishes is the one nobody notices until somebody sees a file they are - * not cleared for. + * Bit 63 is the sign bit of a Long. */ @Test fun `the top compartment bit survives`() { @@ -66,7 +53,7 @@ class LabelTest { assertFailsWith { Label(0, setOf(-1)) } } - // ---- Domination ------------------------------------------------------ + // ---- Domination ------------------------------------------------------. @Test fun `a higher classification dominates a lower one`() { @@ -76,10 +63,7 @@ class LabelTest { } /** - * The failure that reads as working. An implementation that compares - * the classification and forgets the compartments behaves correctly on - * every test built from levels alone, right up until somebody sees a - * compartment they are not in. + * The failure that reads as working. */ @Test fun `a higher level does NOT dominate a compartment it is not in`() { @@ -96,8 +80,10 @@ class LabelTest { val holder = Label(5, setOf(1, 2)) assertTrue(holder.dominates(Label(5, setOf(1)))) assertTrue(holder.dominates(Label(5, setOf(1, 2)))) - assertFalse(holder.dominates(Label(5, setOf(1, 3))), - "an overlapping but incomplete set was treated as domination") + assertFalse( + holder.dominates(Label(5, setOf(1, 3))), + "an overlapping but incomplete set was treated as domination", + ) } /** Two labels can be incomparable, neither dominates the other. */ @@ -115,7 +101,7 @@ class LabelTest { assertTrue(Label(9, setOf(3)).dominates(Label.bottom)) } - // ---- Clearance: no read up ------------------------------------------- + // ---- Clearance: no read up -------------------------------------------. @Test fun `a clearance reads what it dominates`() { @@ -128,15 +114,14 @@ class LabelTest { fun `a clearance cannot read above itself`() { val cleared = Clearance(maximum = Label(5)) assertFalse(cleared.mayRead(Label(6))) - assertFalse(cleared.mayRead(Label(5, setOf(1))), - "a compartment outside the clearance was readable") + assertFalse( + cleared.mayRead(Label(5, setOf(1))), + "a compartment outside the clearance was readable", + ) } /** - * The minimum is a real bound, not decoration. A label must sit - * INSIDE the range. Somebody cleared for sensitive work is deliberately not - * also holding the unclassified pile, which sounds like a convenience to - * remove until it is the reason an audit can say who could have seen what. + * The minimum is a real bound, not decoration. */ @Test fun `a clearance with a floor cannot read below it`() { @@ -145,26 +130,11 @@ class LabelTest { assertFalse(cleared.mayRead(Label(1)), "a label below the floor was readable") } - // ---- Writing --------------------------------------------------------- + // ---- Writing ---------------------------------------------------------. /** - * Writing is gated by the same range check as reading, and the - * -property is NOT enforced, on either platform. - * - * Bell–LaPadula says somebody cleared to read sensitive material must not - * write *below* their clearance, because they would carry what they read - * down with them. iOS gates `ensureLabelKey` and `use` on - * `clearance.permits` and nothing else, so this matches it deliberately. - * - * Implementing the rule on Android alone would produce a file that saves on - * one phone and is refused on the other, with neither person able to tell - * which rule they had met, worse than the gap itself. Filed as android#15 / - * ios#65 so it is decided once, for both. - * - * This asserts the CURRENT behaviour, including the part that is a gap. - * It should fail loudly on the day somebody implements the -property, - * which is the point of writing the absence down rather than leaving it - * implicit. + * Writing is gated by the same range check as reading, and the -property is + * NOT enforced, on either platform. */ @Test fun `writing uses the same range check as reading, star-property not enforced`() { @@ -190,7 +160,9 @@ class LabelTest { fun `a compartment outside the clearance cannot be written either`() { val cleared = Clearance(maximum = Label(5, setOf(1))) assertTrue(cleared.mayWrite(Label(5, setOf(1)))) - assertFalse(cleared.mayWrite(Label(5, setOf(1, 2))), - "a compartment outside the clearance was writable") + assertFalse( + cleared.mayWrite(Label(5, setOf(1, 2))), + "a compartment outside the clearance was writable", + ) } } diff --git a/engine/src/test/kotlin/io/flamenet/e2e/LocalAccountSaltTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/LocalAccountSaltTest.kt index 172300d..e13f09d 100644 --- a/engine/src/test/kotlin/io/flamenet/e2e/LocalAccountSaltTest.kt +++ b/engine/src/test/kotlin/io/flamenet/e2e/LocalAccountSaltTest.kt @@ -5,20 +5,8 @@ import kotlin.test.assertEquals /** * The salt for an account on a relay that keeps its own accounts. - * - * Pinned against the JavaScript engine, which is normative, the value - * below was produced by `engine/src/fnid.js`'s `localAccountSalt`, not by this - * implementation. A Kotlin test that asserts what Kotlin computes proves only - * that Kotlin is self-consistent; the failure this guards against is the three - * engines disagreeing, which surfaces as one person having two addresses - * depending on which phone they signed in from. - * - * Changing the expected value here is never the fix. It is a wire constant: - * a different salt is a different account key, and a different account key is - * a different person to everyone who pinned the old one. */ class LocalAccountSaltTest { - @Test fun `matches the javascript engine`() { assertEquals( diff --git a/engine/src/test/kotlin/io/flamenet/e2e/MailboxStateTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/MailboxStateTest.kt index 905aa7b..4e9115e 100644 --- a/engine/src/test/kotlin/io/flamenet/e2e/MailboxStateTest.kt +++ b/engine/src/test/kotlin/io/flamenet/e2e/MailboxStateTest.kt @@ -8,27 +8,31 @@ import kotlin.test.assertTrue /** * What the vault remembers about several mailboxes. - * - * Two of these keys are chosen by whoever writes to you. An envelope id - * comes from the sender, so an unbounded list of them is storage anybody on the - * federation can grow, and a relay name comes from a signed record, so a - * per-relay entry can be created by anyone who can get a record published. Both - * are bounded or scoped deliberately, and that is worth asserting rather than - * trusting to a comment. */ class MailboxStateTest { - private class MapStorage : Vault.Storage { val records = LinkedHashMap() + override fun read(name: String) = records[name] - override fun write(name: String, bytes: ByteArray) { records[name] = bytes } - override fun delete(name: String) { records.remove(name) } + + override fun write( + name: String, + bytes: ByteArray, + ) { + records[name] = bytes + } + + override fun delete(name: String) { + records.remove(name) + } + override fun names() = records.keys.toList() } private val storage = MapStorage() - /// Held so a SECOND vault can be opened over the same storage, which is - /// what a restart is, and the only way to test that anything persisted. + + // / Held so a SECOND vault can be opened over the same storage, which is + // / what a restart is, and the only way to test that anything persisted. private val key = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() private val vault = Vault(storage, key) @@ -45,11 +49,6 @@ class MailboxStateTest { /** * It survives a restart, or a copy at a backup home is read twice on the * first poll after every launch. - * - * Written with one vault and read with a SECOND over the same storage, - * which is what a restart actually is. The first version of this test - * asserted that two objects were not the same object, which is true of any - * two objects and proves nothing about persistence. */ @Test fun `what was seen survives reopening the vault`() { @@ -74,9 +73,7 @@ class MailboxStateTest { } /** - * The key comes from whoever wrote to us. Oldest first, so a - * forgotten id can at worst show a very old message twice, far better than - * a list anybody can grow without limit. + * The key comes from whoever wrote to us. */ @Test fun `the list of seen envelopes is bounded`() { @@ -98,8 +95,7 @@ class MailboxStateTest { /** * Envelope ids come from each relay's own database, so id 40 there says - * nothing about id 40 here. One shared cursor would let the first mailbox's - * progress silently skip the second's mail. + * nothing about id 40 here. */ @Test fun `each relay keeps its own watermark`() { @@ -110,9 +106,9 @@ class MailboxStateTest { } /** - * A single global flag meant that signing in to a second relay which - * keeps no log read as the FIRST going quiet, a fork warning against an - * honest deployment, and every contact on it unreachable. + * A single global flag meant that signing in to a second relay which keeps + * no log read as the FIRST going quiet, a fork warning against an honest + * deployment, and every contact on it unreachable. */ @Test fun `the served flag is per relay`() { @@ -123,9 +119,9 @@ class MailboxStateTest { } /** - * The pin falls back to the pre-2026-08-30 global entry, so a phone - * already holding one keeps it rather than starting trust-on-first-use - * again, and a per-relay entry, once written, wins. + * The pin falls back to the pre-2026-08-30 global entry, so a phone already + * holding one keeps it rather than starting trust-on-first-use again, and a + * per-relay entry, once written, wins. */ @Test fun `an existing global pin is inherited until a per-relay one is written`() { @@ -134,24 +130,26 @@ class MailboxStateTest { vault.saveKeyLogPin(Vault.KeyLogPin(9, "new-root"), "a.example.com") assertEquals("new-root", vault.loadKeyLogPin("a.example.com")?.root) - assertEquals("old-root", vault.loadKeyLogPin("b.example.com")?.root, - "another relay still inherits the legacy pin, not this one") + assertEquals( + "old-root", + vault.loadKeyLogPin("b.example.com")?.root, + "another relay still inherits the legacy pin, not this one", + ) } // MARK: - Extra mailboxes /** - * A credential per relay, not one shared. An app password is minted - * by the relay that issued it and refused everywhere else, so a single - * stored credential authenticates at exactly one home and silently fails at - * the rest, which reads as "the backup mailbox never has anything in it". + * A credential per relay, not one shared. */ @Test fun `each mailbox keeps its own credential and dial url`() { - vault.saveExtraHomes(listOf( - Vault.ExtraHome("a.example.com", "me", "pw-a"), - Vault.ExtraHome("b.example.com", "me", "pw-b", "http://127.0.0.1:9"), - )) + vault.saveExtraHomes( + listOf( + Vault.ExtraHome("a.example.com", "me", "pw-a"), + Vault.ExtraHome("b.example.com", "me", "pw-b", "http://127.0.0.1:9"), + ), + ) val homes = vault.extraHomes() assertEquals(listOf("a.example.com", "b.example.com"), homes.map { it.relay }) @@ -171,8 +169,11 @@ class MailboxStateTest { vault.forgetExtraHome("a.example.com") assertTrue(vault.extraHomes().isEmpty()) - assertEquals(0, vault.watermarkFor("a.example.com"), - "a re-added mailbox would resume mid-inbox and skip everything before it") + assertEquals( + 0, + vault.watermarkFor("a.example.com"), + "a re-added mailbox would resume mid-inbox and skip everything before it", + ) } @Test diff --git a/engine/src/test/kotlin/io/flamenet/e2e/MessageSearchTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/MessageSearchTest.kt index 13fb932..4eb0bf5 100644 --- a/engine/src/test/kotlin/io/flamenet/e2e/MessageSearchTest.kt +++ b/engine/src/test/kotlin/io/flamenet/e2e/MessageSearchTest.kt @@ -1,54 +1,63 @@ package io.flamenet.e2e +import org.json.JSONObject import javax.crypto.KeyGenerator import javax.crypto.SecretKey import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue -import org.json.JSONObject /** * Finding a word somebody said, across every conversation on this device. - * - * There is no index, and that is the design. An index derived from - * decrypted history is plaintext by another name: it would have to be sealed as - * carefully as the vault, taken by `forgetEverything`, and kept consistent with - * the threads. This reads what already exists and throws the plaintext away, - * so nothing new comes to rest. */ class MessageSearchTest { - private class MapStorage : Vault.Storage { val records = LinkedHashMap() + override fun read(name: String) = records[name] - override fun write(name: String, bytes: ByteArray) { records[name] = bytes } - override fun delete(name: String) { records.remove(name) } + + override fun write( + name: String, + bytes: ByteArray, + ) { + records[name] = bytes + } + + override fun delete(name: String) { + records.remove(name) + } + override fun names() = records.keys.toList() } - private fun key(): SecretKey = - KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() + private fun key(): SecretKey = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() private fun vaultWith(vararg lines: Triple): Vault { val vault = Vault(MapStorage(), key()) for ((threadId, text, at) in lines) { - vault.append(threadId, JSONObject() - .put("text", text).put("mine", false).put("at", at)) + vault.append( + threadId, + JSONObject() + .put("text", text) + .put("mine", false) + .put("at", at), + ) } return vault } private val now = 1_700_000_000_000L - // ---------------------------------------------------------------- finding + // ---------------------------------------------------------------- finding. @Test fun `it finds a message by its words`() { - val vault = vaultWith( - Triple(Vault.threadForUser(7), "meet at the cafe at six", now - 3000), - Triple(Vault.threadForUser(7), "bring the keys", now - 2000), - ) + val vault = + vaultWith( + Triple(Vault.threadForUser(7), "meet at the cafe at six", now - 3000), + Triple(Vault.threadForUser(7), "bring the keys", now - 2000), + ) val hits = MessageSearch.hits("keys", vault) assertEquals(1, hits.size) assertEquals("bring the keys", hits.first().text) @@ -56,17 +65,16 @@ class MessageSearchTest { @Test fun `matching is case insensitive`() { - val vault = vaultWith( - Triple(Vault.threadForUser(7), "CAFE is closed", now), - Triple(Vault.threadForUser(7), "the cafe again", now - 1000), - ) + val vault = + vaultWith( + Triple(Vault.threadForUser(7), "CAFE is closed", now), + Triple(Vault.threadForUser(7), "the cafe again", now - 1000), + ) assertEquals(2, MessageSearch.hits("cafe", vault).size) } /** - * Somebody typing `cafe` means `café`. A search that knows the - * difference quietly fails for every language with accents in it, which is - * most of them, and this app ships in Spanish next. + * Somebody typing `cafe` means `café`. */ @Test fun `matching ignores accents`() { @@ -76,15 +84,15 @@ class MessageSearchTest { } /** - * A plain substring, never a regular expression. A stray bracket typed - * into a search box is a crash or a hang, and nobody meant a pattern. + * A plain substring, never a regular expression. */ @Test fun `the query is not a pattern`() { - val vault = vaultWith( - Triple(Vault.threadForUser(7), "the answer is (42)", now), - Triple(Vault.threadForUser(7), "unrelated", now - 1000), - ) + val vault = + vaultWith( + Triple(Vault.threadForUser(7), "the answer is (42)", now), + Triple(Vault.threadForUser(7), "unrelated", now - 1000), + ) assertEquals(1, MessageSearch.hits("(42)", vault).size) // `.*` matches everything as a regex and nothing as a substring. assertTrue(MessageSearch.hits(".*", vault).isEmpty()) @@ -93,32 +101,37 @@ class MessageSearchTest { /** It reaches across every conversation, not just the open one. */ @Test fun `it searches every thread`() { - val vault = vaultWith( - Triple(Vault.threadForUser(7), "hello there", now - 3000), - Triple(Vault.threadForGroup("g1"), "hello all", now - 2000), - Triple(Vault.threadForAddress("ada@other.example"), "hello afar", now - 1000), - ) + val vault = + vaultWith( + Triple(Vault.threadForUser(7), "hello there", now - 3000), + Triple(Vault.threadForGroup("g1"), "hello all", now - 2000), + Triple(Vault.threadForAddress("ada@other.example"), "hello afar", now - 1000), + ) assertEquals(3, MessageSearch.hits("hello", vault).size) assertEquals( - setOf(Vault.threadForUser(7), Vault.threadForGroup("g1"), - Vault.threadForAddress("ada@other.example")), - MessageSearch.hits("hello", vault).map { it.threadId }.toSet()) + setOf( + Vault.threadForUser(7), + Vault.threadForGroup("g1"), + Vault.threadForAddress("ada@other.example"), + ), + MessageSearch.hits("hello", vault).map { it.threadId }.toSet(), + ) } - // ---------------------------------------------------------------- what it must not do + // ---------------------------------------------------------------- what it + // must not do. /** - * A short query finds NOTHING, never everything. Returning the whole - * history the moment somebody focuses the search box would put every - * message they have on one screen, and here it would decrypt every thread - * on the device to do it. + * A short query finds NOTHING, never everything. */ @Test fun `a query below the minimum finds nothing`() { val vault = vaultWith(Triple(Vault.threadForUser(7), "anything at all", now)) for (tooShort in listOf("", " ", "a", " x ")) { - assertTrue(MessageSearch.hits(tooShort, vault).isEmpty(), - "\"$tooShort\" searched anyway") + assertTrue( + MessageSearch.hits(tooShort, vault).isEmpty(), + "\"$tooShort\" searched anyway", + ) } } @@ -134,20 +147,24 @@ class MessageSearchTest { fun `results are bounded and keep the newest`() { val vault = Vault(MapStorage(), key()) for (i in 1..200) { - vault.append(Vault.threadForUser(7), JSONObject() - .put("text", "needle $i").put("mine", false).put("at", now - i * 1000L)) + vault.append( + Vault.threadForUser(7), + JSONObject() + .put("text", "needle $i") + .put("mine", false) + .put("at", now - i * 1000L), + ) } val hits = MessageSearch.hits("needle", vault, limit = 3) assertEquals(3, hits.size) assertEquals(listOf("needle 1", "needle 2", "needle 3"), hits.map { it.text }) } - // ---------------------------------------------------------------- the snippet + // ---------------------------------------------------------------- the + // snippet. /** - * Bounded on both sides. A whole message means one long one pushes every - * other result off the screen; only the start means a match near the end - * looks like no match at all. + * Bounded on both sides. */ @Test fun `the snippet is centred on the match`() { @@ -167,15 +184,15 @@ class MessageSearchTest { /** * Folding has to be length-preserving, because the snippet slices the - * ORIGINAL string at an index found in the folded one. A fold that dropped - * or added characters would cut in the wrong place, and for an accented - * character, mid-character. + * ORIGINAL string at an index found in the folded one. */ @Test fun `the snippet is correct when the text has accents before the match`() { val text = "café café café needle here" val snippet = MessageSearch.snippet(text, "needle") - assertTrue(snippet.contains("needle"), - "accents shifted the slice off the match: $snippet") + assertTrue( + snippet.contains("needle"), + "accents shifted the slice off the match: $snippet", + ) } } diff --git a/engine/src/test/kotlin/io/flamenet/e2e/PQKEMTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/PQKEMTest.kt index 5dcabaf..d31eaa7 100644 --- a/engine/src/test/kotlin/io/flamenet/e2e/PQKEMTest.kt +++ b/engine/src/test/kotlin/io/flamenet/e2e/PQKEMTest.kt @@ -9,16 +9,8 @@ import kotlin.test.assertTrue /** * ML-KEM-768, the half of the hybrid handshake Android did not have. - * - * A session is post-quantum only when both ends are. Every Android - * device registered `proto: 1`, so iPhone's implementation protected no real - * conversation, the property was true of the code and false of the product. - * These tests are about the things that would let that keep being true while - * looking fixed: a seed that round-trips to a different key, a size that is - * wrong in a way nothing notices until a peer cannot decrypt. */ class PQKEMTest { - @Test fun `a fresh keypair has the sizes FIPS 203 fixes`() { val pair = PQKEM.generate() @@ -37,12 +29,7 @@ class PQKEMTest { } /** - * The seed is the whole persistence story. Only 64 bytes go into the - * vault, and both the private key and the published public half are - * re-derived from them on every load, so a seed that produced a different - * public key on the second derivation would mean a device publishing a key - * it cannot decapsulate with, and every peer who used it silently - * unreachable. + * The seed is the whole persistence story. */ @Test fun `a seed derives the same keypair every time`() { @@ -57,11 +44,6 @@ class PQKEMTest { /** * The seed path and the library's own generator must build the same key. - * - * Not a tautology. `fromSeed` reconstructs a private key from `d ‖ z` by - * a different route than `generateKeyPair` takes, and a disagreement - * between the two would show up only as a peer on another implementation - * being unable to talk to us. */ @Test fun `the seed constructor matches the library's own generator`() { @@ -76,12 +58,9 @@ class PQKEMTest { } /** - * Decapsulation does not fail on a tampered ciphertext, by design: - * ML-KEM's implicit rejection returns a well-formed but different secret - * rather than an error. Asserted rather than assumed, because every caller - * has to be written for it, the mismatch surfaces as the first message - * failing to decrypt, which is why §12.5 also binds the ciphertext into the - * session's associated data. + * Decapsulation does not fail on a tampered ciphertext, by design: ML-KEM's + * implicit rejection returns a well-formed but different secret rather than + * an error. */ @Test fun `a tampered ciphertext yields a different secret, not an error`() { @@ -98,14 +77,15 @@ class PQKEMTest { fun `the wrong seed opens nothing`() { val bob = PQKEM.generate() val out = PQKEM.encapsulate(bob.publicKey) - assertFalse(PQKEM.decapsulate(out.ciphertext, PQKEM.generate().seed) - .contentEquals(out.sharedSecret)) + assertFalse( + PQKEM + .decapsulate(out.ciphertext, PQKEM.generate().seed) + .contentEquals(out.sharedSecret), + ) } /** - * Sizes are checked at the door. A wrong length can only ever fail on every - * peer, and it fails far away from here, so it is named where it happens - * rather than discovered as a contact who stopped working. + * Sizes are checked at the door. */ @Test fun `wrong sizes are refused where they can still be explained`() { diff --git a/engine/src/test/kotlin/io/flamenet/e2e/PQVectorTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/PQVectorTest.kt index e15f565..5770d1f 100644 --- a/engine/src/test/kotlin/io/flamenet/e2e/PQVectorTest.kt +++ b/engine/src/test/kotlin/io/flamenet/e2e/PQVectorTest.kt @@ -9,74 +9,57 @@ import kotlin.test.assertNotEquals /** * The hybrid handshake against the published known-answer vector. - * - * This is the only test here worth much. Every other assertion about - * ML-KEM in this module compares Bouncy Castle to itself: seeds round-trip, - * ciphertexts open, sizes are right, all true of an implementation that is - * internally consistent and agrees with nobody. The vector was generated by the - * normative JavaScript engine and is replayed by the Swift one, so matching it - * is the claim that actually matters: a phone can start a post-quantum session - * with an iPhone, and neither has to take our word for it. - * - * The specific thing at risk is the seed. FIPS 203 keygen takes `d ‖ z`, - * and this engine stores only those 64 bytes and re-derives everything from - * them, so if BC ordered or expanded them differently from CryptoKit, every - * cross-platform session would fail at the first message with nothing pointing - * at the cause. The vector settles it byte for byte. */ class PQVectorTest { - private val vector: JSONObject by lazy { - // Resolved rather than hardcoded, for the same reason PublishedVectorsTest - // does it: this module lives both as a subtree of the app repo and as a - // repo of its own beside the engine. - // - // Fails rather than skips when nothing is found. A conformance test - // that quietly stops conforming is worth less than no test at all, - // the file listing still says the handshake is checked. - val candidates = listOfNotNull( - System.getenv("FLAMENET_PQ_VECTORS"), - "../../engine/test/vectors/pqx3dh-v2.json", - "../../../flamenet-e2e/test/vectors/pqx3dh-v2.json", - "../../flamenet-e2e/test/vectors/pqx3dh-v2.json", - ) - val found = candidates.map(::File).firstOrNull { it.isFile } - ?: error("no pqx3dh-v2.json found; looked in $candidates. " + - "Set FLAMENET_PQ_VECTORS to point at it.") + // Resolved rather than hardcoded, for the same reason + // PublishedVectorsTest does it: this module lives both as a subtree of + // the app repo and as a repo of its own beside the engine. + val candidates = + listOfNotNull( + System.getenv("FLAMENET_PQ_VECTORS"), + "../../engine/test/vectors/pqx3dh-v2.json", + "../../../flamenet-e2e/test/vectors/pqx3dh-v2.json", + "../../flamenet-e2e/test/vectors/pqx3dh-v2.json", + ) + val found = + candidates.map(::File).firstOrNull { it.isFile } + ?: error( + "no pqx3dh-v2.json found; looked in $candidates. " + + "Set FLAMENET_PQ_VECTORS to point at it.", + ) JSONObject(found.readText()) } private fun hex(bytes: ByteArray) = bytes.joinToString("") { "%02x".format(it) } /** - * The cross-implementation check. The vector publishes a seed and - * the public key the normative engine derived from it; deriving a different - * one here is the failure that would make every Android↔iPhone post-quantum - * session fail on the first message, and nowhere near this code. + * The cross-implementation check. */ @Test fun `a published seed derives the published public key`() { val archive = vector.getJSONObject("bobArchive") for (which in listOf("pqSpk", "pqOtp")) { - val entry = if (which == "pqSpk") archive.getJSONObject("pqSpk") - else archive.getJSONObject("pqOtp").getJSONObject("1") + val entry = + if (which == "pqSpk") { + archive.getJSONObject("pqSpk") + } else { + archive.getJSONObject("pqOtp").getJSONObject("1") + } val seed = Bytes.unb64(entry.getString("seed")) val published = Bytes.unb64(entry.getString("pub")) assertEquals(PQKEM.SEED_LEN, seed.size, "$which seed is d‖z") - assertContentEquals(published, PQKEM.fromSeed(seed).publicKey, - "$which: this engine derives a different key from the same seed") + assertContentEquals( + published, + PQKEM.fromSeed(seed).publicKey, + "$which: this engine derives a different key from the same seed", + ) } } /** * The whole responder side, end to end, against the published answer. - * - * Everything is exercised at once and that is the point: the four - * agreements, their order, the KEM secret going last, the v2 info string, - * and the associated data. A vector that only checked the KEM would pass - * while the concatenation order was wrong, and the two engines would still - * never talk. */ @Test fun `the responder derives the published session key and associated data`() { @@ -89,24 +72,29 @@ class PQVectorTest { val ciphertext = Bytes.unb64(env.getJSONObject("pq").getString("ct")) val pqSeed = Bytes.unb64(archive.getJSONObject("pqOtp").getJSONObject("1").getString("seed")) - assertEquals("opk", env.getJSONObject("pq").getString("kind"), - "this vector uses the one-time PQ prekey") - - val sk = X3DH.respond( - identityPrivateDH = priv(archive.getJSONObject("ikDH")), - signedPreKeyPrivate = priv(archive.getJSONObject("spk")), - oneTimePrivate = priv(archive.getJSONObject("otp").getJSONObject("1")), - initiatorIdentityDH = Bytes.unb64(x3dh.getString("ik_dh")), - initiatorEphemeral = Bytes.unb64(x3dh.getString("ek")), - pq = pqSeed to ciphertext, + assertEquals( + "opk", + env.getJSONObject("pq").getString("kind"), + "this vector uses the one-time PQ prekey", ) + + val sk = + X3DH.respond( + identityPrivateDH = priv(archive.getJSONObject("ikDH")), + signedPreKeyPrivate = priv(archive.getJSONObject("spk")), + oneTimePrivate = priv(archive.getJSONObject("otp").getJSONObject("1")), + initiatorIdentityDH = Bytes.unb64(x3dh.getString("ik_dh")), + initiatorEphemeral = Bytes.unb64(x3dh.getString("ek")), + pq = pqSeed to ciphertext, + ) assertEquals(expect.getString("sk"), hex(sk)) - val ad = X3DH.associatedData( - first = Bytes.unb64(x3dh.getString("ik_sig")), - second = Bytes.unb64(archive.getJSONObject("ikSig").getString("pub")), - pqCiphertext = ciphertext, - ) + val ad = + X3DH.associatedData( + first = Bytes.unb64(x3dh.getString("ik_sig")), + second = Bytes.unb64(archive.getJSONObject("ikSig").getString("pub")), + pqCiphertext = ciphertext, + ) assertEquals(expect.getString("ad"), hex(ad)) } @@ -114,8 +102,7 @@ class PQVectorTest { * A downgrade must not produce a key the other side accepts. v1's * `DH1‖DH2‖DH3‖DH4` and v2's `DH1‖DH2‖DH3‖SS` are both 128 bytes, so * without the info string changing with the version they are - * indistinguishable KDF inputs (§12.5). Asserted against the published - * answer rather than against another run of our own code. + * indistinguishable KDF inputs (§12.5). */ @Test fun `running the same handshake without the KEM derives a different key`() { @@ -124,20 +111,20 @@ class PQVectorTest { fun priv(path: JSONObject) = X25519.privateKeyFromPkcs8(Bytes.unb64(path.getString("priv"))) - val classical = X3DH.respond( - identityPrivateDH = priv(archive.getJSONObject("ikDH")), - signedPreKeyPrivate = priv(archive.getJSONObject("spk")), - oneTimePrivate = priv(archive.getJSONObject("otp").getJSONObject("1")), - initiatorIdentityDH = Bytes.unb64(x3dh.getString("ik_dh")), - initiatorEphemeral = Bytes.unb64(x3dh.getString("ek")), - ) + val classical = + X3DH.respond( + identityPrivateDH = priv(archive.getJSONObject("ikDH")), + signedPreKeyPrivate = priv(archive.getJSONObject("spk")), + oneTimePrivate = priv(archive.getJSONObject("otp").getJSONObject("1")), + initiatorIdentityDH = Bytes.unb64(x3dh.getString("ik_dh")), + initiatorEphemeral = Bytes.unb64(x3dh.getString("ek")), + ) assertNotEquals(vector.getJSONObject("expect").getString("sk"), hex(classical)) } /** * The associated data binds the ciphertext, so a relay that swapped one - * cannot leave the session looking untouched. ML-KEM's implicit rejection - * means decapsulation itself will not complain. + * cannot leave the session looking untouched. */ @Test fun `changing one byte of the ciphertext changes the associated data`() { diff --git a/engine/src/test/kotlin/io/flamenet/e2e/PerMessageTimerTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/PerMessageTimerTest.kt index 3fea15a..9945d09 100644 --- a/engine/src/test/kotlin/io/flamenet/e2e/PerMessageTimerTest.kt +++ b/engine/src/test/kotlin/io/flamenet/e2e/PerMessageTimerTest.kt @@ -1,5 +1,6 @@ package io.flamenet.e2e +import org.json.JSONObject import javax.crypto.KeyGenerator import javax.crypto.SecretKey import kotlin.test.Test @@ -7,32 +8,34 @@ import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue -import org.json.JSONObject /** * A timer on one message rather than the whole conversation (§6.17). - * - * The rule that keeps this from making the product worse is that the - * shorter timer always wins. A per-message timer may only take life away, - * never add it. Without that, a sender could defeat the recipient's - * conversation timer by putting a long one on every message, turning a setting - * the recipient chose, on their own device, into something the other party gets - * to overrule. */ class PerMessageTimerTest { - private class MapStorage : Vault.Storage { val records = LinkedHashMap() + override fun read(name: String) = records[name] - override fun write(name: String, bytes: ByteArray) { records[name] = bytes } - override fun delete(name: String) { records.remove(name) } + + override fun write( + name: String, + bytes: ByteArray, + ) { + records[name] = bytes + } + + override fun delete(name: String) { + records.remove(name) + } + override fun names() = records.keys.toList() } - private fun key(): SecretKey = - KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() + private fun key(): SecretKey = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() - // ---------------------------------------------------------------- whose timer wins + // ---------------------------------------------------------------- whose + // timer wins. @Test fun `a message timer applies when the conversation has none`() { @@ -45,13 +48,15 @@ class PerMessageTimerTest { } /** - * The one that matters. A message asking to live a week, in a - * conversation set to an hour, lives an hour. + * The one that matters. */ @Test fun `a longer message timer cannot extend a message's life`() { - assertEquals(3600, Vault.effectiveLifetime(message = 604_800, conversation = 3600), - "a sender overruled the recipient's own conversation timer") + assertEquals( + 3600, + Vault.effectiveLifetime(message = 604_800, conversation = 3600), + "a sender overruled the recipient's own conversation timer", + ) } @Test @@ -65,8 +70,7 @@ class PerMessageTimerTest { } /** - * Zero means "no timer", not "expire immediately". Reading it the other - * way would delete a message the instant it arrived. + * Zero means "no timer", not "expire immediately". */ @Test fun `a negative timer is ignored rather than expiring immediately`() { @@ -74,19 +78,27 @@ class PerMessageTimerTest { assertEquals(3600, Vault.effectiveLifetime(message = -1, conversation = 3600)) } - // ---------------------------------------------------------------- pruning + // ---------------------------------------------------------------- pruning. - private fun store(vault: Vault, threadId: String, at: Long, secs: Int, text: String) { - vault.append(threadId, JSONObject() - .put("text", text).put("mine", false).put("at", at) - .also { if (secs > 0) it.put("secs", secs) }) + private fun store( + vault: Vault, + threadId: String, + at: Long, + secs: Int, + text: String, + ) { + vault.append( + threadId, + JSONObject() + .put("text", text) + .put("mine", false) + .put("at", at) + .also { if (secs > 0) it.put("secs", secs) }, + ) } /** - * The bug the old code had. Pruning returned early when the - * CONVERSATION had no timer, so a timed message in an untimed thread was - * kept for ever, the feature would have looked like it worked on the - * sending phone and done nothing on the receiving one. + * The bug the old code had. */ @Test fun `a timed message expires even when the conversation has no timer`() { @@ -125,31 +137,36 @@ class PerMessageTimerTest { vault.saveDisappearSeconds(thread, 60) store(vault, thread, at = now - 120_000, secs = 604_800, text = "asked to live a week") - assertEquals(0, vault.pruneExpired(thread, now).length(), - "a sender overruled the recipient's conversation timer") + assertEquals( + 0, + vault.pruneExpired(thread, now).length(), + "a sender overruled the recipient's conversation timer", + ) } /** - * A message with no timestamp predates the field. Kept rather than - * destroyed: an unknown age is not evidence of an old one, and deleting - * somebody's history on a guess is unrecoverable. + * A message with no timestamp predates the field. */ @Test fun `a message with no timestamp is kept`() { val vault = Vault(MapStorage(), key()) val thread = Vault.threadForUser(7) - vault.append(thread, JSONObject().put("text", "ancient").put("mine", false) - .put("secs", 60)) + vault.append( + thread, + JSONObject() + .put("text", "ancient") + .put("mine", false) + .put("secs", 60), + ) assertEquals(1, vault.pruneExpired(thread, 1_000_000_000L).length()) } - // ---------------------------------------------------------------- the wire + // ---------------------------------------------------------------- the + // wire. /** - * A duration, never a deadline. An absolute expiry would be the - * sender's clock: they could backdate a message so it vanished on arrival, - * or forward-date one so it never expired. + * A duration, never a deadline. */ @Test fun `the envelope carries a duration not a deadline`() { @@ -160,9 +177,9 @@ class PerMessageTimerTest { } /** - * `Content.text` returns raw bytes when it has nothing to carry, so a - * timer that was not counted in that decision would be dropped and the - * message would live for ever. + * `Content.text` returns raw bytes when it has nothing to carry, so a timer + * that was not counted in that decision would be dropped and the message + * would live for ever. */ @Test fun `a timed message always gets an envelope`() { @@ -176,20 +193,23 @@ class PerMessageTimerTest { } /** - * A conversation-timer announcement carries `secs` too and means - * something entirely different. Reading both off one field is why this - * checks the kind. + * A conversation-timer announcement carries `secs` too and means something + * entirely different. */ @Test fun `a conversation timer announcement is not a timed message`() { val announcement = Content.parse(Content.disappearing(3600)) - assertNull(announcement.lifetimeSeconds, - "a conversation timer was read as one message's own") + assertNull( + announcement.lifetimeSeconds, + "a conversation timer was read as one message's own", + ) assertEquals(3600, Content.disappearSeconds(announcement)) val timed = Content.parse(Content.text("meet at 6", lifetimeSeconds = 60)) - assertNull(Content.disappearSeconds(timed), - "a timed message was read as setting the whole conversation") + assertNull( + Content.disappearSeconds(timed), + "a timed message was read as setting the whole conversation", + ) } @Test diff --git a/engine/src/test/kotlin/io/flamenet/e2e/PinTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/PinTest.kt index df7edd1..f88790f 100644 --- a/engine/src/test/kotlin/io/flamenet/e2e/PinTest.kt +++ b/engine/src/test/kotlin/io/flamenet/e2e/PinTest.kt @@ -8,17 +8,8 @@ import kotlin.test.assertTrue /** * Pinning a message in a conversation (§6.19). - * - * The codec half. The Android app does not pin yet, but it must already - * understand an envelope the iPhone app sends, and in particular must not draw - * one as a message, which is what an unrecognised pin becomes. - * - * A pin is an id and never a copy of the message. A pin carrying the text - * would keep a message readable after its own timer had removed it, so pinning - * would quietly defeat disappearing messages. */ class PinTest { - @Test fun `a pin names the message it pins`() { val content = Content.parse(Content.pin("abc")) @@ -33,9 +24,7 @@ class PinTest { } /** - * The bug this prevents. An unrecognised pin falls through `parse` to - * the attachment path and comes back renderable, an empty bubble in - * somebody's conversation every time the other side pins something. + * The bug this prevents. */ @Test fun `a pin is not a message`() { @@ -46,14 +35,15 @@ class PinTest { } /** - * Not swallowed as generic control either. A build that consumed it - * silently would drop every pin while looking like it worked. + * Not swallowed as generic control either. */ @Test fun `a pin is a known kind, not unknown control`() { assertTrue(Content.Kind.PIN in Content.Kind.KNOWN) - assertTrue(Content.Kind.PIN !in Content.Kind.CONTROL, - "a pin swallowed as control would drop silently") + assertTrue( + Content.Kind.PIN !in Content.Kind.CONTROL, + "a pin swallowed as control would drop silently", + ) } /** The envelope carries no copy of the message, only its id. */ diff --git a/engine/src/test/kotlin/io/flamenet/e2e/PublishedVectorsTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/PublishedVectorsTest.kt index 5a8baf9..2536fd7 100644 --- a/engine/src/test/kotlin/io/flamenet/e2e/PublishedVectorsTest.kt +++ b/engine/src/test/kotlin/io/flamenet/e2e/PublishedVectorsTest.kt @@ -12,33 +12,19 @@ import kotlin.test.assertTrue /** * The Kotlin engine against the published vectors. - * - * These are the same bytes the JavaScript and Swift engines are held to, read - * from the one file rather than copied here, a vector transcribed into the - * test that checks it is a vector that agrees with itself. - * - * This is the point of starting Android with the engine. A third - * implementation is only worth anything if it can be checked against something - * other than a reading of our own code. */ class PublishedVectorsTest { - private val vectors: JSONObject by lazy { - // Where the vectors are depends on which checkout this is: android/ as a - // subtree of the app repo, or the extracted Android repo with - // flamenet-e2e cloned beside it. Resolved rather than assumed, because a - // hardcoded depth is how the harness stops testing anything. - // - // This FAILS rather than skips when it finds nothing, deliberately: the - // whole reason a third engine is worth writing is that it is checked - // against the published vectors, and one that quietly stops checking is - // worth less than no third engine at all. - val candidates = listOfNotNull( - System.getenv("FLAMENET_VECTORS"), - "../../engine/test/vectors/protocol-v1.json", // app repo subtree - "../../../flamenet-e2e/test/vectors/protocol-v1.json", // beside the engine repo - "../../flamenet-e2e/test/vectors/protocol-v1.json", - ).map { File(it) } + // Where the vectors are depends on which checkout this is: android/ as + // a subtree of the app repo, or the extracted Android repo with + // flamenet-e2e cloned beside it. + val candidates = + listOfNotNull( + System.getenv("FLAMENET_VECTORS"), + "../../engine/test/vectors/protocol-v1.json", // app repo subtree + "../../../flamenet-e2e/test/vectors/protocol-v1.json", // beside the engine repo + "../../flamenet-e2e/test/vectors/protocol-v1.json", + ).map { File(it) } val file = candidates.firstOrNull { it.exists() } assertTrue( file != null, @@ -87,7 +73,10 @@ class PublishedVectorsTest { @Test fun `account identity encodes exactly as published`() { val ai = vectors.getJSONObject("accountIdentity") - val key = java.util.Base64.getDecoder().decode(ai.getString("accountKeyB64")) + val key = + java.util.Base64 + .getDecoder() + .decode(ai.getString("accountKeyB64")) assertEquals(ai.getString("encoded"), FNID.encode(key)) assertEquals(ai.getString("address"), FNID.address(key)) @@ -112,13 +101,14 @@ class PublishedVectorsTest { val leaf = vectors.getJSONObject("keyLog").getJSONObject("canonicalLeaf") val input = leaf.getJSONObject("input") - val encoded = KeyLog.canonicalLeaf( - kind = input.getString("kind"), - subject = input.getString("subject"), - deviceId = input.getString("deviceId"), - ikSigB64 = input.getString("ikSigB64"), - ikDhB64 = input.getString("ikDhB64"), - ) + val encoded = + KeyLog.canonicalLeaf( + kind = input.getString("kind"), + subject = input.getString("subject"), + deviceId = input.getString("deviceId"), + ikSigB64 = input.getString("ikSigB64"), + ikDhB64 = input.getString("ikDhB64"), + ) assertEquals(leaf.getString("encoded"), String(encoded, Charsets.UTF_8)) assertEquals(leaf.getString("leafHash"), Bytes.b64(KeyLog.leafHash(encoded))) } @@ -163,10 +153,7 @@ class PublishedVectorsTest { val proof = inc.getJSONArray("proof").let { a -> (0 until a.length()).map { a.bytesAt(it) } } val root = Bytes.unb64(inc.getString("root")) - // The audit path is bottom-up. Reading it top-down still produces the - // right root for any left-spine index, so this assertion, at a - // right-descending index, with the path reversed, is the one that - // actually catches the mistake. + // The audit path is bottom-up. assertFalse( KeyLog.verifyInclusion(leaf, inc.getInt("index"), inc.getInt("size"), proof.reversed(), root), "a reversed audit path verified, so the path order is not being honoured", @@ -203,7 +190,7 @@ class PublishedVectorsTest { assertEquals(8, KeyLog.split(9)) } - // -- §19 consistency ----------------------------------------------------- + // -- §19 consistency -----------------------------------------------------. @Test fun `a consistency proof verifies between every pair of prefix sizes`() { @@ -211,16 +198,15 @@ class PublishedVectorsTest { val leaves = keyLog.getJSONArray("leafHashes") val all = (0 until leaves.length()).map { leaves.bytesAt(it) } - // Every pair, not a sample. The traversal has a special case when the - // old size is a power of two, the old root leads the proof implicitly - // rather than being transmitted, so an implementation that gets that - // wrong still passes at 3 -> 5 and fails at 2 -> 5. + // Every pair, not a sample. for (m in 1..all.size) { for (n in m..all.size) { val proof = consistencyProof(all, m, n) assertTrue( KeyLog.verifyConsistency( - from = m, to = n, proof = proof, + from = m, + to = n, + proof = proof, oldRoot = rootOf(all.subList(0, m)), newRoot = rootOf(all.subList(0, n)), ), @@ -236,7 +222,9 @@ class PublishedVectorsTest { val all = (0 until leaves.length()).map { leaves.bytesAt(it) } assertFalse( KeyLog.verifyConsistency( - from = 4, to = 2, proof = consistencyProof(all, 2, 4), + from = 4, + to = 2, + proof = consistencyProof(all, 2, 4), oldRoot = rootOf(all.subList(0, 4)), newRoot = rootOf(all.subList(0, 2)), ), @@ -246,11 +234,6 @@ class PublishedVectorsTest { /** * The attack this whole mechanism exists to stop. - * - * A relay that rewrites history can still issue perfectly valid INCLUSION - * proofs against its new tree, they are honest proofs about a dishonest - * log. What it cannot do is reconcile the new tree with one a client - * already pinned. If this test ever passes, the log is decorative. */ @Test fun `a rewritten log cannot be reconciled with a pinned root`() { @@ -263,10 +246,11 @@ class PublishedVectorsTest { assertFalse( KeyLog.verifyConsistency( - from = 2, to = honest.size, + from = 2, + to = honest.size, proof = consistencyProof(rewritten, 2, rewritten.size), - oldRoot = rootOf(honest.subList(0, 2)), // what we pinned - newRoot = rootOf(rewritten), // what it serves now + oldRoot = rootOf(honest.subList(0, 2)), // what we pinned + newRoot = rootOf(rewritten), // what it serves now ), "a substituted leaf must break consistency against the pinned root", ) @@ -280,7 +264,9 @@ class PublishedVectorsTest { proof[0] = KeyLog.leafHash("not the sibling".toByteArray()) assertFalse( KeyLog.verifyConsistency( - from = 2, to = 5, proof = proof, + from = 2, + to = 5, + proof = proof, oldRoot = rootOf(all.subList(0, 2)), newRoot = rootOf(all), ), @@ -314,11 +300,6 @@ class PublishedVectorsTest { /** * The reference tree, built here rather than in the engine. - * - * [KeyLog] verifies and cannot construct, on purpose. The test needs a - * builder to check the published roots, so it carries its own, which also - * means the roots are checked against an independent construction rather - * than against the same code that produced them. */ private fun rootOf(leaves: List): ByteArray { require(leaves.isNotEmpty()) @@ -329,19 +310,21 @@ class PublishedVectorsTest { /** * The RFC 6962 consistency proof between two prefix sizes. - * - * Built here for the same reason as [rootOf], and trustworthy for the same - * reason: it is made of [KeyLog.split] and [KeyLog.nodeHash], and the tree - * it describes is the one whose roots already matched the published - * vectors at every size. A builder that disagreed with the spec would have - * failed that test first. */ - private fun consistencyProof(leaves: List, m: Int, n: Int): List { + private fun consistencyProof( + leaves: List, + m: Int, + n: Int, + ): List { require(m in 1..n && n <= leaves.size) return subProof(leaves.subList(0, n), m, true) } - private fun subProof(leaves: List, m: Int, isOldRoot: Boolean): List { + private fun subProof( + leaves: List, + m: Int, + isOldRoot: Boolean, + ): List { if (m == leaves.size) return if (isOldRoot) emptyList() else listOf(rootOf(leaves)) val k = KeyLog.split(leaves.size) return if (m <= k) { diff --git a/engine/src/test/kotlin/io/flamenet/e2e/QrCodeRenderForDecodingTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/QrCodeRenderForDecodingTest.kt index 68872e0..914d36d 100644 --- a/engine/src/test/kotlin/io/flamenet/e2e/QrCodeRenderForDecodingTest.kt +++ b/engine/src/test/kotlin/io/flamenet/e2e/QrCodeRenderForDecodingTest.kt @@ -7,18 +7,8 @@ import kotlin.test.Test /** * Renders QR codes to PNG so an INDEPENDENT decoder can read them back. - * - * The only honest way to test an encoder we wrote ourselves. A wrong QR - * still looks like a QR; asserting the matrix against values this same code - * produced proves nothing except that it is deterministic. So this writes real - * images and `Scripts/verify-qr.swift` decodes them with CoreImage's detector, - * which shares no line of code with `QrCode`. - * - * Not an assertion by itself, it is the fixture half. It passes as long as - * the encoder does not throw; the verdict comes from the decoder. */ class QrCodeRenderForDecodingTest { - @Test fun `render the corpus`() { val out = File(System.getProperty("fnmsg.qr.out") ?: "build/qr-corpus") @@ -30,7 +20,11 @@ class QrCodeRenderForDecodingTest { println("wrote ${CORPUS.size} QR images to ${out.absolutePath}") } - private fun render(text: String, scale: Int, quiet: Int): BufferedImage { + private fun render( + text: String, + scale: Int, + quiet: Int, + ): BufferedImage { val m = QrCode.encode(text) val side = (m.size + quiet * 2) * scale val image = BufferedImage(side, side, BufferedImage.TYPE_INT_RGB) @@ -38,8 +32,10 @@ class QrCodeRenderForDecodingTest { g.color = java.awt.Color.WHITE g.fillRect(0, 0, side, side) g.color = java.awt.Color.BLACK - for (r in 0 until m.size) for (c in 0 until m.size) { - if (m[r, c]) g.fillRect((c + quiet) * scale, (r + quiet) * scale, scale, scale) + for (r in 0 until m.size) { + for (c in 0 until m.size) { + if (m[r, c]) g.fillRect((c + quiet) * scale, (r + quiet) * scale, scale, scale) + } } g.dispose() return image @@ -51,25 +47,23 @@ class QrCodeRenderForDecodingTest { * the block layout, the alignment patterns and the version-information * block all change with version and each is its own chance to be wrong. */ - val CORPUS: List = listOf( - "flamenet:fnid:kx7q2m", // v1 - "flamenet:fnid:" + "a".repeat(52) + "@relay1.flamenet.io", // a real address - "flamenet:fnid:" + "b".repeat(52) + "@" + "long".repeat(8) + ".example.com", - // Exactly full, version by version: data codewords minus the - // mode nibble and character count. The boundary is where an - // off-by-one in the terminator or the padding shows up, and it is - // the one place a wrong encoder still produces a scannable-looking - // square. - "x".repeat(14), // v1 - "x".repeat(26), // v2 - "x".repeat(42), // v3 - "x".repeat(62), // v4 - "x".repeat(84), // v5 - "x".repeat(106), // v6 - "x".repeat(122), // v7, first version with a version-information block - "x".repeat(152), // v8, two block groups of different sizes - "x".repeat(180), // v9 - "x".repeat(213), // v10, the largest this encoder makes - ) + val CORPUS: List = + listOf( + "flamenet:fnid:kx7q2m", // v1 + "flamenet:fnid:" + "a".repeat(52) + "@relay1.flamenet.io", // a real address + "flamenet:fnid:" + "b".repeat(52) + "@" + "long".repeat(8) + ".example.com", + // Exactly full, version by version: data codewords minus the mode + // nibble and character count. + "x".repeat(14), // v1 + "x".repeat(26), // v2 + "x".repeat(42), // v3 + "x".repeat(62), // v4 + "x".repeat(84), // v5 + "x".repeat(106), // v6 + "x".repeat(122), // v7, first version with a version-information block + "x".repeat(152), // v8, two block groups of different sizes + "x".repeat(180), // v9 + "x".repeat(213), // v10, the largest this encoder makes + ) } } diff --git a/engine/src/test/kotlin/io/flamenet/e2e/SafetyNumberPQTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/SafetyNumberPQTest.kt index 9939c2a..b98e3b9 100644 --- a/engine/src/test/kotlin/io/flamenet/e2e/SafetyNumberPQTest.kt +++ b/engine/src/test/kotlin/io/flamenet/e2e/SafetyNumberPQTest.kt @@ -6,8 +6,7 @@ import kotlin.test.assertNotEquals /** * Audit H3: a substituted post-quantum identity must change the digits two - * people read aloud. Cross-platform: these values are the same ones the iOS - * SafetyNumberPQTests use, so a drift between the two engines fails here. + * people read aloud. */ class SafetyNumberPQTest { private val aliceIK = ByteArray(32) { 0x11 } @@ -18,36 +17,43 @@ class SafetyNumberPQTest { @Test fun `both sides see the same number`() { assertEquals( SafetyNumber.compute(aliceIK, alicePQ, bobIK, bobPQ), - SafetyNumber.compute(bobIK, bobPQ, aliceIK, alicePQ)) + SafetyNumber.compute(bobIK, bobPQ, aliceIK, alicePQ), + ) } @Test fun `swapping the PQ identity changes the number`() { assertNotEquals( SafetyNumber.compute(aliceIK, alicePQ, bobIK, bobPQ), - SafetyNumber.compute(aliceIK, alicePQ, bobIK, ByteArray(32) { 0xEE.toByte() })) + SafetyNumber.compute(aliceIK, alicePQ, bobIK, ByteArray(32) { 0xEE.toByte() }), + ) } @Test fun `a classical pair keeps the classical number`() { - assertEquals(SafetyNumber.compute(aliceIK, bobIK), - SafetyNumber.compute(aliceIK, null, bobIK, bobPQ)) - assertEquals(SafetyNumber.compute(aliceIK, bobIK), - SafetyNumber.compute(aliceIK, alicePQ, bobIK, null)) + assertEquals( + SafetyNumber.compute(aliceIK, bobIK), + SafetyNumber.compute(aliceIK, null, bobIK, bobPQ), + ) + assertEquals( + SafetyNumber.compute(aliceIK, bobIK), + SafetyNumber.compute(aliceIK, alicePQ, bobIK, null), + ) } /** * The exact digits, from an INDEPENDENT implementation of the rule, the - * same constant the iOS suite pins. This is what makes both platforms - * agree with the spec rather than merely with each other; a silent drift - * would show two people different numbers and read as an attack. + * same constant the iOS suite pins. */ @Test fun `the number matches the independently computed value`() { assertEquals( "526042701003577849302816285461391061377406450730104222477184", - SafetyNumber.compute(aliceIK, alicePQ, bobIK, bobPQ)) + SafetyNumber.compute(aliceIK, alicePQ, bobIK, bobPQ), + ) } @Test fun `the PQ number differs from the classical one`() { - assertNotEquals(SafetyNumber.compute(aliceIK, bobIK), - SafetyNumber.compute(aliceIK, alicePQ, bobIK, bobPQ)) + assertNotEquals( + SafetyNumber.compute(aliceIK, bobIK), + SafetyNumber.compute(aliceIK, alicePQ, bobIK, bobPQ), + ) } } diff --git a/engine/src/test/kotlin/io/flamenet/e2e/ScreenshotNoticeTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/ScreenshotNoticeTest.kt index ce258fa..516aaa4 100644 --- a/engine/src/test/kotlin/io/flamenet/e2e/ScreenshotNoticeTest.kt +++ b/engine/src/test/kotlin/io/flamenet/e2e/ScreenshotNoticeTest.kt @@ -8,16 +8,10 @@ import kotlin.test.assertNull import kotlin.test.assertTrue /** - * Telling somebody their disappearing message was screenshotted (§6.27, ios#36). - * - * A courtesy from the taker's own client, and nothing else. A modified - * build, a second phone photographing the screen, or the platform's own - * recorder all produce nothing. A notice means one was probably taken; no - * notice means NOTHING, and most of these tests are about not letting the - * interface say otherwise. + * Telling somebody their disappearing message was screenshotted (§6.27, + * ios#36). */ class ScreenshotNoticeTest { - @Test fun `the notice carries when it happened`() { val content = Content.parse(Content.screenshot(1_788_000_000)) @@ -50,7 +44,8 @@ class ScreenshotNoticeTest { assertNull(Content.screenshotAt(Content.parse(Content.text("hi", messageId = "m")))) } - // -- When one is sent ----------------------------------------------------- + // -- When one is sent + // -----------------------------------------------------. /** Only where a timer is set. */ @Test @@ -80,11 +75,11 @@ class ScreenshotNoticeTest { assertFalse(Content.shouldAnnounceScreenshot(0, 1_000, 999_000)) } - // -- What may be claimed -------------------------------------------------- + // -- What may be claimed + // --------------------------------------------------. /** - * The most important test here. Presenting the absence of notices as - * a fact would be inventing evidence out of silence. + * The most important test here. */ @Test fun `the disclaimer refuses to turn silence into evidence`() { @@ -95,9 +90,7 @@ class ScreenshotNoticeTest { } /** - * Android's half of the asymmetry has to be stated, not hidden. Claiming - * a symmetry with iOS that does not exist is worse than the gap, because - * somebody would rely on it. + * Android's half of the asymmetry has to be stated, not hidden. */ @Test fun `the disclaimer states the android limitation`() { diff --git a/engine/src/test/kotlin/io/flamenet/e2e/SealedRosterTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/SealedRosterTest.kt index 2a0b5e5..2e0d59a 100644 --- a/engine/src/test/kotlin/io/flamenet/e2e/SealedRosterTest.kt +++ b/engine/src/test/kotlin/io/flamenet/e2e/SealedRosterTest.kt @@ -8,34 +8,42 @@ import kotlin.test.assertTrue /** * §8.9b, the member list as something the relay cannot read. - * - * The relay used to hold `gid -> user_id` rows: a readable social graph in a - * table. This covers the client half, what goes into the opaque blob and how - * it comes back, and the properties that make it worth doing. - * - * None of this hides membership from a running relay, which still sees who - * asks about what. It hides it from a stolen database. */ class SealedRosterTest { - private class MapStorage : Vault.Storage { private val records = mutableMapOf() + override fun read(name: String) = records[name] - override fun write(name: String, bytes: ByteArray) { records[name] = bytes } - override fun delete(name: String) { records.remove(name) } + + override fun write( + name: String, + bytes: ByteArray, + ) { + records[name] = bytes + } + + override fun delete(name: String) { + records.remove(name) + } + override fun names() = records.keys.toList() } - private fun vault(): Vault = Vault( - MapStorage(), - javax.crypto.KeyGenerator.getInstance("AES").apply { init(256) }.generateKey(), - ).also { it.saveIdentity(DeviceIdentity.create()) } + private fun vault(): Vault = + Vault( + MapStorage(), + javax.crypto.KeyGenerator + .getInstance("AES") + .apply { init(256) } + .generateKey(), + ).also { it.saveIdentity(DeviceIdentity.create()) } - private fun contents() = SealedRoster.Contents( - members = listOf(41L, 42L, 43L), - tags = mapOf("41" to SealedRoster.newTag(), "42" to SealedRoster.newTag()), - name = "Book club", - ) + private fun contents() = + SealedRoster.Contents( + members = listOf(41L, 42L, 43L), + tags = mapOf("41" to SealedRoster.newTag(), "42" to SealedRoster.newTag()), + name = "Book club", + ) @Test fun `a roster survives the round trip`() { @@ -49,35 +57,30 @@ class SealedRosterTest { /** * The property the whole change rests on: the stored bytes say nothing. - * A dump that yields the member ids has bought nobody anything. - * - * The values are long on purpose, and the search covers the decoded - * bytes. This sealed ids `41`/`42` and looked for `"41"`/`"42"` in the - * BASE64, two characters out of a 64-symbol alphabet, so each had about a - * 1-in-4096 chance at any position and roughly a 14% chance across a blob - * this size. It failed on exactly that, having passed for days: a coin - * flip wearing the clothes of an assertion. The iOS twin had the same bug - * and was fixed the same way; this one was missed because only one engine - * was looked at. - * - * Nine-digit ids and a real-sounding name make a chance match impossible - * rather than unlikely, and the byte-level search is the one that actually - * states the property, base64 could in principle hide a plaintext run the - * raw bytes reveal. */ @Test fun `the sealed blob reveals nothing`() { - val secret = SealedRoster.Contents( - members = listOf(918273645L, 564738291L), - tags = mapOf("918273645" to SealedRoster.newTag(), - "564738291" to SealedRoster.newTag()), - name = "Chemotherapy support", - ) + val secret = + SealedRoster.Contents( + members = listOf(918273645L, 564738291L), + tags = + mapOf( + "918273645" to SealedRoster.newTag(), + "564738291" to SealedRoster.newTag(), + ), + name = "Chemotherapy support", + ) val blob = SealedRoster.seal(secret, SealedRoster.newGroupKey()) val raw = Bytes.unb64(blob) - for (needle in listOf("918273645", "564738291", "Chemotherapy support", - "members", "tags", "name")) { + for (needle in listOf( + "918273645", + "564738291", + "Chemotherapy support", + "members", + "tags", + "name", + )) { assertFalse(blob.contains(needle), "$needle is readable in the sealed roster") assertFalse( String(raw, Charsets.ISO_8859_1) @@ -88,9 +91,7 @@ class SealedRosterTest { } /** - * A blob that will not open is an error, never an empty group. Treating - * it as empty would silently drop every member from a conversation that - * still exists. + * A blob that will not open is an error, never an empty group. */ @Test fun `the wrong key throws rather than returning nothing`() { @@ -101,8 +102,7 @@ class SealedRosterTest { } /** - * The relay stores these and could change them. Adding a member to - * somebody's roster is exactly the attack this refuses. + * The relay stores these and could change them. */ @Test fun `a tampered blob is refused`() { @@ -113,9 +113,7 @@ class SealedRosterTest { } /** - * Tags are random, not derived from the account. Anything derived would - * be a pseudonym the relay could link across groups, the same graph - * wearing a hash. + * Tags are random, not derived from the account. */ @Test fun `tags are unlinkable`() { @@ -125,22 +123,20 @@ class SealedRosterTest { } /** - * The bytes must match the Swift engine and the relay exactly. A - * roster signed here is verified there; one character of difference and - * every signature this client makes is refused, with nothing in the error - * pointing at the format. + * The bytes must match the Swift engine and the relay exactly. */ @Test fun `the signed bytes match the other implementations`() { assertEquals( "fngroup-v1|abc|7|Zm9v", - String(SealedRoster.canonical("abc", 7, "Zm9v"))) + String(SealedRoster.canonical("abc", 7, "Zm9v")), + ) } /** * The encoding must not depend on map iteration order, or two devices - * sealing the same roster produce different bytes, and a signature over - * one does not verify the other. + * sealing the same roster produce different bytes, and a signature over one + * does not verify the other. */ @Test fun `the encoding is stable regardless of tag insertion order`() { @@ -150,22 +146,23 @@ class SealedRosterTest { // Sealing uses a random nonce, so compare what comes back out instead. assertEquals( SealedRoster.open(SealedRoster.seal(a, key), key).tags, - SealedRoster.open(SealedRoster.seal(b, key), key).tags) + SealedRoster.open(SealedRoster.seal(b, key), key).tags, + ) } /** - * A backup that loses the tags loses the groups for good. The relay - * cannot say what you are a member of, so the tag is the only way to fetch - * a group and the key the only way to read it. Restoring the messages - * without these gives a device that can read old conversations and can - * never rejoin the groups they came from. + * A backup that loses the tags loses the groups for good. */ @Test fun `an exported backup carries sealed group secrets`() { val old = vault() - val secrets = Vault.GroupSecrets( - tag = SealedRoster.newTag(), key = SealedRoster.newGroupKey(), - ownerPrivate = null, version = 3) + val secrets = + Vault.GroupSecrets( + tag = SealedRoster.newTag(), + key = SealedRoster.newGroupKey(), + ownerPrivate = null, + version = 3, + ) old.saveGroupSecrets("g1", secrets) val payload = old.exportPayload() @@ -180,13 +177,15 @@ class SealedRosterTest { @Test fun `a restored backup does not overwrite secrets we already hold`() { val old = vault() - old.saveGroupSecrets("g1", Vault.GroupSecrets( - tag = "old", key = SealedRoster.newGroupKey(), version = 1)) + old.saveGroupSecrets( + "g1", + Vault.GroupSecrets(tag = "old", key = SealedRoster.newGroupKey(), version = 1), + ) val payload = old.exportPayload() val current = vault() - val live = Vault.GroupSecrets( - tag = "live", key = SealedRoster.newGroupKey(), version = 9) + val live = + Vault.GroupSecrets(tag = "live", key = SealedRoster.newGroupKey(), version = 9) current.saveGroupSecrets("g1", live) current.mergeImportedPayload(payload, 0.0) diff --git a/engine/src/test/kotlin/io/flamenet/e2e/SealedSenderTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/SealedSenderTest.kt index 9662950..36dad9c 100644 --- a/engine/src/test/kotlin/io/flamenet/e2e/SealedSenderTest.kt +++ b/engine/src/test/kotlin/io/flamenet/e2e/SealedSenderTest.kt @@ -9,21 +9,27 @@ import kotlin.test.assertTrue /** * Sealed sender (§14), and mostly the things it must refuse. - * - * A seal that opens for the right person is easy. The properties worth testing - * are that it does NOT open for anyone else, and that the identity it carries - * is treated as a claim. */ class SealedSenderTest { - - private fun pair(): Pair = - DeviceIdentity.create() to DeviceIdentity.create() + private fun pair(): Pair = DeviceIdentity.create() to DeviceIdentity.create() /** A real envelope, so the test is not sealing a string. */ - private fun envelope(from: DeviceIdentity, to: DeviceIdentity, text: String): Envelope { + private fun envelope( + from: DeviceIdentity, + to: DeviceIdentity, + text: String, + ): Envelope { val oneTime = to.generateOneTimePreKeys(1).first() - val wire = to.registrationWire() - .put("opk", org.json.JSONObject().put("id", oneTime.id).put("pub", Bytes.b64(oneTime.publicKey))) + val wire = + to + .registrationWire() + .put( + "opk", + org.json + .JSONObject() + .put("id", oneTime.id) + .put("pub", Bytes.b64(oneTime.publicKey)), + ) return E2ESession.initiate(from, PeerBundle.fromWire(wire)).encrypt(text) } @@ -42,17 +48,13 @@ class SealedSenderTest { /** * The AAD binds the destination device. - * - * Without this a relay could copy one person's sealed blob into somebody - * else's inbox and it would decrypt there, turning a privacy feature into - * a delivery bug that leaks messages to the wrong account. */ @Test fun `a seal does not open on a device it was not addressed to`() { val (alice, bob) = pair() val eve = DeviceIdentity.create() - val blob = SealedSender.seal( - envelope(alice, bob, "hello"), 7, alice.deviceId, bob.deviceId, bob.identityDHPublic) + val blob = + SealedSender.seal(envelope(alice, bob, "hello"), 7, alice.deviceId, bob.deviceId, bob.identityDHPublic) // Even holding a valid private key, and even being handed the blob. assertFailsWith { @@ -62,16 +64,12 @@ class SealedSenderTest { /** * Bob's own key, but the AAD says another device id. - * - * Separated from the test above so the two failure reasons cannot be - * confused: this one proves the device id is in the AAD, not merely that a - * different DH key fails. */ @Test fun `the destination device id is part of what is authenticated`() { val (alice, bob) = pair() - val blob = SealedSender.seal( - envelope(alice, bob, "hello"), 7, alice.deviceId, bob.deviceId, bob.identityDHPublic) + val blob = + SealedSender.seal(envelope(alice, bob, "hello"), 7, alice.deviceId, bob.deviceId, bob.identityDHPublic) assertFailsWith { SealedSender.open(blob, bob.identityDH.private, "some-other-device-id") @@ -81,8 +79,8 @@ class SealedSenderTest { @Test fun `a tampered ciphertext is refused`() { val (alice, bob) = pair() - val blob = SealedSender.seal( - envelope(alice, bob, "hello"), 7, alice.deviceId, bob.deviceId, bob.identityDHPublic) + val blob = + SealedSender.seal(envelope(alice, bob, "hello"), 7, alice.deviceId, bob.deviceId, bob.identityDHPublic) val broken = blob.copy(ciphertext = blob.ciphertext.copyOf().also { it[0] = (it[0] + 1).toByte() }) assertFailsWith { @@ -92,26 +90,21 @@ class SealedSenderTest { /** * Anyone can forge the claimed sender, and this records that on purpose. - * - * The recipient's IK_dh is published to the world, so a seal claiming to be - * from anybody is trivial to make. This test exists so that if someone ever - * "fixes" the naming to look authoritative, the reason it is a claim is - * still written down and still executing. */ @Test fun `anyone holding the recipient's public key can forge the claimed sender`() { val (alice, bob) = pair() val eve = DeviceIdentity.create() - // Eve seals her own envelope while claiming to be Alice. Nothing stops - // her, the seal is confidentiality, not authentication. - val forged = SealedSender.seal( - envelope(eve, bob, "not really from alice"), - fromUser = 7, - fromDevice = alice.deviceId, - toDevice = bob.deviceId, - recipientIKdh = bob.identityDHPublic, - ) + // Eve seals her own envelope while claiming to be Alice. + val forged = + SealedSender.seal( + envelope(eve, bob, "not really from alice"), + fromUser = 7, + fromDevice = alice.deviceId, + toDevice = bob.deviceId, + recipientIKdh = bob.identityDHPublic, + ) val opened = SealedSender.open(forged, bob.identityDH.private, bob.deviceId) // The seal opens and the lie is right there in the claim. @@ -143,10 +136,10 @@ class SealedSenderTest { @Test fun `every seal uses a fresh ephemeral`() { val (alice, bob) = pair() - val one = SealedSender.seal( - envelope(alice, bob, "a"), 7, alice.deviceId, bob.deviceId, bob.identityDHPublic) - val two = SealedSender.seal( - envelope(alice, bob, "b"), 7, alice.deviceId, bob.deviceId, bob.identityDHPublic) + val one = + SealedSender.seal(envelope(alice, bob, "a"), 7, alice.deviceId, bob.deviceId, bob.identityDHPublic) + val two = + SealedSender.seal(envelope(alice, bob, "b"), 7, alice.deviceId, bob.deviceId, bob.identityDHPublic) // The nonce is derived from the shared secret, so a repeated ephemeral // is a repeated (key, nonce) pair, which under GCM leaks the diff --git a/engine/src/test/kotlin/io/flamenet/e2e/SealingRecordTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/SealingRecordTest.kt index b511a2d..6e9090b 100644 --- a/engine/src/test/kotlin/io/flamenet/e2e/SealingRecordTest.kt +++ b/engine/src/test/kotlin/io/flamenet/e2e/SealingRecordTest.kt @@ -8,7 +8,6 @@ import kotlin.test.assertTrue /** Counting the sealed-to-unsealed fallback (§14.5, ios#16). */ class SealingRecordTest { - @Test fun `a sealed send is counted`() { val r = SealingRecord().recording(wasSealed = true, at = 5) @@ -26,9 +25,10 @@ class SealingRecordTest { /** One good send afterwards does not unhappen the last bad one. */ @Test fun `a sealed send does not clear the last unsealed stamp`() { - val r = SealingRecord() - .recording(wasSealed = false, at = 100) - .recording(wasSealed = true, at = 200) + val r = + SealingRecord() + .recording(wasSealed = false, at = 100) + .recording(wasSealed = true, at = 200) assertEquals(100, r.lastUnsealedAt) } @@ -48,8 +48,7 @@ class SealingRecordTest { } /** - * Must not say the message was readable. An unsealed send is still - * end-to-end encrypted; the relay learns the sender, not the content. + * Must not say the message was readable. */ @Test fun `the summary distinguishes sender from content`() { diff --git a/engine/src/test/kotlin/io/flamenet/e2e/SecureBytesTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/SecureBytesTest.kt index f82089b..f541f1d 100644 --- a/engine/src/test/kotlin/io/flamenet/e2e/SecureBytesTest.kt +++ b/engine/src/test/kotlin/io/flamenet/e2e/SecureBytesTest.kt @@ -9,33 +9,15 @@ import kotlin.test.assertTrue /** * Zeroing key material once it has been used (ios#39, Android half). - * - * The claim being tested is narrow, and the tests are shaped to keep it - * narrow. What this buys is that a derived key is not left sitting in the - * heap after the one moment it was needed. It is not "the key is gone", a - * compacting collector can have moved the array, Android has compressed swap, - * and there is no `memset_s` on the JVM. `SecureBytes` says all of that in - * prose; these assert only what is actually true. - * - * The most valuable test here is that the derived keys did not change. - * Wiping an intermediate one line too early derives every account secret from - * thirty-two zero bytes, which produces a perfectly consistent, perfectly - * wrong identity, silently, on every device. That is a far worse failure than - * the leak this feature exists to narrow, so it is checked first. */ class SecureBytesTest { - private val passphrase = "a-long-enough-passphrase" private val salt = ByteArray(16) { it.toByte() } - // ---- The failure that would matter most ----------------------------- + // ---- The failure that would matter most -----------------------------. /** * The pinned vectors already cover `authSecret` and `accountKeySeed`. - * This covers the shape wiping could break without moving a vector: that - * the two secrets from one stretch still match the two derived separately. - * If the wipe landed between the expansions, the second would be derived - * from zeroes and this diverges. */ @Test fun `wiping the intermediate did not change what is derived`() { @@ -45,9 +27,8 @@ class SecureBytesTest { } /** - * And neither is zeroes, which is what "derived from a wiped buffer" - * would look like. A test that only compared the two paths would pass if - * BOTH were wrong in the same way. + * And neither is zeroes, which is what "derived from a wiped buffer" would + * look like. */ @Test fun `no derived secret is a run of zeroes`() { @@ -80,7 +61,7 @@ class SecureBytesTest { assertFalse(identity.contentEquals(account)) } - // ---- The wiping itself ---------------------------------------------- + // ---- The wiping itself ----------------------------------------------. @Test fun `wipe zeroes the array it is given`() { @@ -104,9 +85,7 @@ class SecureBytesTest { } /** - * The case the `finally` exists for. A derivation that failed - * half-way is exactly where the material gets forgotten, so the wipe has to - * survive a throw, not sit at the end of a happy path. + * The case the `finally` exists for. */ @Test fun `withSecret wipes when the body throws`() { @@ -118,10 +97,7 @@ class SecureBytesTest { } /** - * The honest limit, asserted rather than left to the prose. A copy - * taken before the wipe is untouched by it. This is not a bug to fix, a - * `ByteArray` cannot reach copies of itself, and stating it as a passing - * test stops anybody reading `wipe` as stronger than it is. + * The honest limit, asserted rather than left to the prose. */ @Test fun `a copy taken beforehand survives the wipe`() { @@ -129,8 +105,10 @@ class SecureBytesTest { val copy = SecureBytes.copyOf(secret) SecureBytes.wipe(secret) assertTrue(secret.all { it == 0.toByte() }) - assertTrue(copy.all { it == 0x41.toByte() }, - "the copy was wiped too, which would mean these share a buffer") + assertTrue( + copy.all { it == 0x41.toByte() }, + "the copy was wiped too, which would mean these share a buffer", + ) } @Test diff --git a/engine/src/test/kotlin/io/flamenet/e2e/StoryAndMentionInteropTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/StoryAndMentionInteropTest.kt index c449d37..aa4b35e 100644 --- a/engine/src/test/kotlin/io/flamenet/e2e/StoryAndMentionInteropTest.kt +++ b/engine/src/test/kotlin/io/flamenet/e2e/StoryAndMentionInteropTest.kt @@ -9,15 +9,8 @@ import kotlin.test.assertTrue /** * Stories and mentions, against envelopes the iOS build produces. - * - * Every string below is the shape iOS assembles, not this engine's own - * output. Two implementations that each only test against themselves agree - * right up to the day somebody sends a real message, and the symptom is a - * mention that never rings, or a story that arrives as "[unsupported - * attachment]", with nothing in either log to say why. */ class StoryAndMentionInteropTest { - // MARK: text stories private val iosTextStory = @@ -36,10 +29,6 @@ class StoryAndMentionInteropTest { fun `this engine assembles a text story the same way`() { val wire = Content.textStory("hello", 1800000000.0, "m-1") // Asserted on the WIRE STRING, not on a JSONObject built from it. - // Round-tripping through JSONObject reorders the keys and puts `fnc` - // somewhere other than first, which is exactly the hazard that makes - // both engines hand-assemble these strings. An earlier version of this - // test did that and failed, correctly. assertTrue(wire.startsWith("{\"fnc\":1"), wire) assertTrue(wire.contains("\"kind\":\"story\""), wire) assertTrue(wire.contains("\"caption\":\"hello\""), wire) @@ -48,10 +37,7 @@ class StoryAndMentionInteropTest { /** * Kotlin prints a Double in scientific notation, so this engine writes - * `"exp":1.8E9` where iOS writes `1800000000.0`. Both are valid JSON and - * both sides read either, but a story's expiry decides whether it shows - * at all, so the shape is pinned here rather than assumed. (This is not - * new: picture stories have carried `exp` this way all along.) + * `"exp":1.8E9` where iOS writes `1800000000.0`. */ @Test fun `an expiry written in scientific notation reads back`() { @@ -63,11 +49,18 @@ class StoryAndMentionInteropTest { /** A picture story must be completely unaffected. */ @Test fun `a picture story still parses as an attachment`() { - val ref = Attachment.Ref( - kind = Attachment.STORY, id = "a".repeat(64), - key = ByteArray(32) { 0x11 }, digest = ByteArray(32) { 0x22 }, - bytes = 10, mime = "image/jpeg", width = 4, height = 3, expires = 1800000000.0, - ) + val ref = + Attachment.Ref( + kind = Attachment.STORY, + id = "a".repeat(64), + key = ByteArray(32) { 0x11 }, + digest = ByteArray(32) { 0x22 }, + bytes = 10, + mime = "image/jpeg", + width = 4, + height = 3, + expires = 1800000000.0, + ) val content = Content.parse(Content.attachment(ref, "look")) assertNotNull(content.attachment) assertEquals(null, content.textStoryExpires) @@ -110,9 +103,7 @@ class StoryAndMentionInteropTest { } /** - * The separation that matters. Both are `kind: "react"`; without the - * flag a story reaction folds onto a message with that id, and if one - * ever matched, onto entirely the wrong thing. + * The separation that matters. */ @Test fun `an ordinary message reaction is not read as a story one`() { @@ -144,8 +135,12 @@ class StoryAndMentionInteropTest { @Test fun `an ordinary reply is not a story reply`() { - val json = Content.text("sure", messageId = "m-1", - about = Content.Reference("other", 2, "earlier")) + val json = + Content.text( + "sure", + messageId = "m-1", + about = Content.Reference("other", 2, "earlier"), + ) assertFalse(Content.parse(json).repliesToStory) } diff --git a/engine/src/test/kotlin/io/flamenet/e2e/StoryArchiveTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/StoryArchiveTest.kt index 901699c..d2709d8 100644 --- a/engine/src/test/kotlin/io/flamenet/e2e/StoryArchiveTest.kt +++ b/engine/src/test/kotlin/io/flamenet/e2e/StoryArchiveTest.kt @@ -6,28 +6,39 @@ import kotlin.test.assertTrue /** * A private archive of your own stories (ios#54). - * - * This keeps something the interface said would disappear. So it is - * opt-in, and it only ever holds stories this account posted, an archive of - * other people's expiring stories is the exact opposite of what they were - * promised, and "it is private to you" does not repair that. */ class StoryArchiveTest { - - private fun story(id: String, mine: Boolean, at: Long) = Vault.Story( - id = id, authorId = if (mine) 1L else 7L, mine = mine, - ref = Attachment.Ref( - kind = Attachment.STORY, id = "b$id", key = ByteArray(32), - digest = ByteArray(32), bytes = 1, mime = "image/jpeg", - width = 1, height = 1, duration = 0.0, expires = 1.0, - ), - caption = "c$id", postedAt = at, + private fun story( + id: String, + mine: Boolean, + at: Long, + ) = Vault.Story( + id = id, + authorId = if (mine) 1L else 7L, + mine = mine, + ref = + Attachment.Ref( + kind = Attachment.STORY, + id = "b$id", + key = ByteArray(32), + digest = ByteArray(32), + bytes = 1, + mime = "image/jpeg", + width = 1, + height = 1, + duration = 0.0, + expires = 1.0, + ), + caption = "c$id", + postedAt = at, ) @Test fun `your own expired story is kept`() { - assertEquals(listOf("1"), - Vault.archive(emptyList(), listOf(story("1", true, 100))).map { it.id }) + assertEquals( + listOf("1"), + Vault.archive(emptyList(), listOf(story("1", true, 100))).map { it.id }, + ) } /** The rule the whole feature turns on. */ @@ -38,8 +49,11 @@ class StoryArchiveTest { @Test fun `a mixed batch keeps only your own`() { - val result = Vault.archive(emptyList(), listOf( - story("1", true, 100), story("2", false, 200), story("3", true, 300))) + val result = + Vault.archive( + emptyList(), + listOf(story("1", true, 100), story("2", false, 200), story("3", true, 300)), + ) assertEquals(setOf("1", "3"), result.map { it.id }.toSet()) } @@ -54,21 +68,38 @@ class StoryArchiveTest { /** Newest first, so the screen needs no sort. */ @Test fun `the archive is newest first`() { - val result = Vault.archive(emptyList(), listOf( - story("1", true, 100), story("3", true, 300), story("2", true, 200))) + val result = + Vault.archive( + emptyList(), + listOf(story("1", true, 100), story("3", true, 300), story("2", true, 200)), + ) assertEquals(listOf("3", "2", "1"), result.map { it.id }) } private class MapStorage : Vault.Storage { val records = LinkedHashMap() + override fun read(name: String) = records[name] - override fun write(name: String, bytes: ByteArray) { records[name] = bytes } - override fun delete(name: String) { records.remove(name) } + + override fun write( + name: String, + bytes: ByteArray, + ) { + records[name] = bytes + } + + override fun delete(name: String) { + records.remove(name) + } + override fun names() = records.keys.toList() } - private fun key() = javax.crypto.KeyGenerator.getInstance("AES") - .apply { init(256) }.generateKey() + private fun key() = + javax.crypto.KeyGenerator + .getInstance("AES") + .apply { init(256) } + .generateKey() /** Kept in the vault, like everything else about somebody's stories. */ @Test @@ -81,8 +112,8 @@ class StoryArchiveTest { /** * The archive must NOT be swept by the expiry rule the live set uses, - * everything in it is expired by definition, so reusing `saveStories` - * would empty it on the next write. + * everything in it is expired by definition, so reusing `saveStories` would + * empty it on the next write. */ @Test fun `an expired story survives in the archive`() { diff --git a/engine/src/test/kotlin/io/flamenet/e2e/StorySaveTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/StorySaveTest.kt index 1deb736..266857e 100644 --- a/engine/src/test/kotlin/io/flamenet/e2e/StorySaveTest.kt +++ b/engine/src/test/kotlin/io/flamenet/e2e/StorySaveTest.kt @@ -6,14 +6,8 @@ import kotlin.test.assertTrue /** * Saving your own story before it expires (ios#34). - * - * Only ever your own. Saving somebody else's keeps a copy of something - * they were told would disappear, which is the opposite of what the feature - * promises them. The rule lives in the engine, not in the button, because a - * guard in a composable is one refactor away from being lost. */ class StorySaveTest { - @Test fun `your own story with a picture can be saved`() { assertTrue(Vault.isSaveable(mine = true, hasBlob = true)) @@ -26,9 +20,7 @@ class StorySaveTest { } /** - * A text story (§6.21) has no blob and nothing to write out. False - * rather than rendering the words into an image: a story is what the - * author posted, and a picture of it is a different object. + * A text story (§6.21) has no blob and nothing to write out. */ @Test fun `a text story of your own has nothing to save`() { diff --git a/engine/src/test/kotlin/io/flamenet/e2e/StorySetTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/StorySetTest.kt index b75a37d..57f313c 100644 --- a/engine/src/test/kotlin/io/flamenet/e2e/StorySetTest.kt +++ b/engine/src/test/kotlin/io/flamenet/e2e/StorySetTest.kt @@ -9,21 +9,40 @@ import kotlin.test.assertTrue /** Multi-item stories (spec §6.23, ios#53). */ class StorySetTest { - - private fun ref(id: Int, expires: Double = 100.0) = Attachment.Ref( - kind = Attachment.STORY, id = "b$id", key = ByteArray(32), - digest = ByteArray(32), bytes = 1, mime = "image/jpeg", - width = 1, height = 1, duration = 0.0, expires = expires, + private fun ref( + id: Int, + expires: Double = 100.0, + ) = Attachment.Ref( + kind = Attachment.STORY, + id = "b$id", + key = ByteArray(32), + digest = ByteArray(32), + bytes = 1, + mime = "image/jpeg", + width = 1, + height = 1, + duration = 0.0, + expires = expires, ) - private fun item(id: Int, set: String?, i: Int = 0, n: Int = 1, expires: Double = 100.0) = - Vault.Story( - id = "s$id", authorId = 7, mine = false, ref = ref(id, expires), - caption = "", postedAt = id.toLong(), - set = set?.let { Content.StorySet(it, i, n) }, - ) + private fun item( + id: Int, + set: String?, + i: Int = 0, + n: Int = 1, + expires: Double = 100.0, + ) = Vault.Story( + id = "s$id", + authorId = 7, + mine = false, + ref = ref(id, expires), + caption = "", + postedAt = id.toLong(), + set = set?.let { Content.StorySet(it, i, n) }, + ) - // -- The wire ------------------------------------------------------------- + // -- The wire + // -------------------------------------------------------------. @Test fun `an item carries its set position and size`() { @@ -35,9 +54,7 @@ class StorySetTest { } /** - * One envelope per item. An array of attachments would fail the - * single-`att` guard on any build predating §6.23 and render the whole set - * as a message in the conversation. + * One envelope per item. */ @Test fun `an item is an ordinary story envelope`() { @@ -52,12 +69,15 @@ class StorySetTest { assertNull(Content.parse(Content.attachment(ref(1))).storySet) } - // -- Grouping ------------------------------------------------------------- + // -- Grouping + // -------------------------------------------------------------. @Test fun `items of one set are grouped in position order`() { - val groups = Vault.groupedStories(listOf( - item(3, "a", i = 2, n = 3), item(1, "a", i = 0, n = 3), item(2, "a", i = 1, n = 3))) + val groups = + Vault.groupedStories( + listOf(item(3, "a", i = 2, n = 3), item(1, "a", i = 0, n = 3), item(2, "a", i = 1, n = 3)), + ) assertEquals(1, groups.size) assertEquals(listOf("s1", "s2", "s3"), groups[0].map { it.id }) } @@ -65,8 +85,10 @@ class StorySetTest { /** `n` says three; two came. Render what arrived. */ @Test fun `a partly arrived set is still rendered`() { - val groups = Vault.groupedStories(listOf( - item(1, "a", i = 0, n = 3), item(3, "a", i = 2, n = 3))) + val groups = + Vault.groupedStories( + listOf(item(1, "a", i = 0, n = 3), item(3, "a", i = 2, n = 3)), + ) assertEquals(1, groups.size) assertEquals(2, groups[0].size) } @@ -74,51 +96,86 @@ class StorySetTest { /** Two singles must not collide just because neither has a set id. */ @Test fun `two sets and a single stay apart`() { - val groups = Vault.groupedStories(listOf( - item(1, "a", i = 0, n = 2), item(2, "b"), item(3, null), item(4, "a", i = 1, n = 2))) + val groups = + Vault.groupedStories( + listOf(item(1, "a", i = 0, n = 2), item(2, "b"), item(3, null), item(4, "a", i = 1, n = 2)), + ) assertEquals(3, groups.size) assertEquals(listOf("s1", "s4"), groups[0].map { it.id }) } - // -- Expiry --------------------------------------------------------------- + // -- Expiry + // ---------------------------------------------------------------. /** The earliest wins, so a sender cannot outlive part of its own set. */ @Test fun `a set expires at its earliest stamp`() { - assertEquals(100.0, Vault.setExpiry(listOf( - item(1, "a", expires = 300.0), - item(2, "a", expires = 100.0), - item(3, "a", expires = 200.0)))) + assertEquals( + 100.0, + Vault.setExpiry( + listOf( + item(1, "a", expires = 300.0), + item(2, "a", expires = 100.0), + item(3, "a", expires = 200.0), + ), + ), + ) } /** A zero means "never" and must not be read as "expires at the epoch". */ @Test fun `a zero stamp does not expire the whole set`() { - assertEquals(500.0, Vault.setExpiry(listOf( - item(1, "a", expires = 0.0), item(2, "a", expires = 500.0)))) + assertEquals( + 500.0, + Vault.setExpiry( + listOf(item(1, "a", expires = 0.0), item(2, "a", expires = 500.0)), + ), + ) } @Test fun `a set of never-expiring items never expires`() { - assertEquals(0.0, Vault.setExpiry(listOf( - item(1, "a", expires = 0.0), item(2, "a", expires = 0.0)))) + assertEquals( + 0.0, + Vault.setExpiry( + listOf(item(1, "a", expires = 0.0), item(2, "a", expires = 0.0)), + ), + ) } - // -- Storage -------------------------------------------------------------- + // -- Storage + // --------------------------------------------------------------. private class MapStorage : Vault.Storage { val records = LinkedHashMap() + override fun read(name: String) = records[name] - override fun write(name: String, bytes: ByteArray) { records[name] = bytes } - override fun delete(name: String) { records.remove(name) } + + override fun write( + name: String, + bytes: ByteArray, + ) { + records[name] = bytes + } + + override fun delete(name: String) { + records.remove(name) + } + override fun names() = records.keys.toList() } /** The set survives a reload, or a relaunch scatters every sequence. */ @Test fun `the set survives the vault round trip`() { - val vault = Vault(MapStorage(), - javax.crypto.KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()) + val vault = + Vault( + MapStorage(), + javax.crypto.KeyGenerator + .getInstance("AES") + .apply { init(256) } + .generateKey(), + ) val now = System.currentTimeMillis() / 1000.0 vault.saveStories(listOf(item(1, "a", i = 1, n = 4, expires = now + 600)), now) val back = vault.loadStories().single() diff --git a/engine/src/test/kotlin/io/flamenet/e2e/StreamDisclosureTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/StreamDisclosureTest.kt index 872b74f..68eaac8 100644 --- a/engine/src/test/kotlin/io/flamenet/e2e/StreamDisclosureTest.kt +++ b/engine/src/test/kotlin/io/flamenet/e2e/StreamDisclosureTest.kt @@ -8,17 +8,8 @@ import kotlin.test.assertTrue /** * Embedded live streams in stories (spec §6.13). - * - * The one media path where the viewer must contact a third party. Every - * other one is a sealed blob; a live stream is continuous and hosted elsewhere, - * so watching it means this device connects to that host, which learns the - * viewer's address and that they watched. §6.12's answer, have the sender - * fetch it once, does not transfer, because a stream cannot be fetched once. - * - * Everything here exists to make that a decision rather than a surprise. */ class StreamDisclosureTest { - @Test fun `the disclosure names the host`() { assertTrue("live.example.com" in StreamDisclosure.text("live.example.com")) @@ -57,17 +48,26 @@ class StreamDisclosureTest { assertFalse(StreamDisclosure.acceptable("")) } - // -- The wire ------------------------------------------------------------ + // -- The wire ------------------------------------------------------------. - private fun ref() = Attachment.Ref( - kind = Attachment.STORY, id = "abc", key = ByteArray(32) { 1 }, - digest = ByteArray(32) { 2 }, bytes = 10, mime = "image/jpeg", - width = 1, height = 1, duration = 0.0, expires = 0.0) + private fun ref() = + Attachment.Ref( + kind = Attachment.STORY, + id = "abc", + key = ByteArray(32) { 1 }, + digest = ByteArray(32) { 2 }, + bytes = 10, + mime = "image/jpeg", + width = 1, + height = 1, + duration = 0.0, + expires = 0.0, + ) @Test fun `a stream url survives the wire`() { - val wire = Content.attachment( - ref(), caption = "live now", streamUrl = "https://live.example.com/s.m3u8") + val wire = + Content.attachment(ref(), caption = "live now", streamUrl = "https://live.example.com/s.m3u8") val parsed = Content.parse(wire) assertEquals("https://live.example.com/s.m3u8", parsed.streamUrl) assertEquals("live now", parsed.text) @@ -82,16 +82,20 @@ class StreamDisclosureTest { } /** - * The host comes from the URL, never the caption. A story captioned - * "youtube.com" pointing elsewhere is the phishing shape §6.12 guards - * against. + * The host comes from the URL, never the caption. */ @Test fun `the host comes from the url not the caption`() { - val story = Vault.Story( - id = "s1", authorId = 2L, mine = false, ref = ref(), - caption = "totally youtube.com", postedAt = 0L, - streamUrl = "https://evil.example/s.m3u8") + val story = + Vault.Story( + id = "s1", + authorId = 2L, + mine = false, + ref = ref(), + caption = "totally youtube.com", + postedAt = 0L, + streamUrl = "https://evil.example/s.m3u8", + ) assertEquals("evil.example", story.streamHost) } diff --git a/engine/src/test/kotlin/io/flamenet/e2e/VaultPersistenceTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/VaultPersistenceTest.kt index f726fb0..9b15346 100644 --- a/engine/src/test/kotlin/io/flamenet/e2e/VaultPersistenceTest.kt +++ b/engine/src/test/kotlin/io/flamenet/e2e/VaultPersistenceTest.kt @@ -13,61 +13,68 @@ import kotlin.test.assertTrue /** * Round trips for everything the vault remembers. - * - * This class of bug has already shipped here once. `requireKeyLog` was - * computed, held in memory, and never written down, so a security control the - * UI offered silently reverted on every relaunch. `Trust.changed` carries the - * same warning in its own doc comment: it was returned by `record()` and - * persisted by nothing, so a contact who reinstalled became permanently - * unmessageable while the header insisted nothing had changed. - * - * Neither was a hard bug to write. Both were invisible because a save and a - * load that disagree still compile, still run, and only differ after a restart - * which no test performed. Every test below writes with one `Vault` instance - * and reads with a second one built over the same bytes, because that is the - * only arrangement in which the two halves have to agree. */ class VaultPersistenceTest { - private class MapStorage : Vault.Storage { val records = LinkedHashMap() + override fun read(name: String) = records[name] - override fun write(name: String, bytes: ByteArray) { records[name] = bytes } - override fun delete(name: String) { records.remove(name) } + + override fun write( + name: String, + bytes: ByteArray, + ) { + records[name] = bytes + } + + override fun delete(name: String) { + records.remove(name) + } + override fun names() = records.keys.toList() } - private fun key(): SecretKey = - KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() + private fun key(): SecretKey = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() /** Write with one instance, read with another, a restart, in effect. */ - private fun roundTrip(write: (Vault) -> Unit, read: (Vault) -> T): T { - val storage = MapStorage(); val secret = key() + private fun roundTrip( + write: (Vault) -> Unit, + read: (Vault) -> T, + ): T { + val storage = MapStorage() + val secret = key() write(Vault(storage, secret)) return read(Vault(storage, secret)) } - // -- Credential ---------------------------------------------------------- + // -- Credential ----------------------------------------------------------. @Test fun `a credential survives a restart`() { - val original = Vault.Credential( - relayBaseUrl = "https://relay.example", username = "ada", - appPassword = "hunter2", userId = 42, fnid = "ada@example") + val original = + Vault.Credential( + relayBaseUrl = "https://relay.example", + username = "ada", + appPassword = "hunter2", + userId = 42, + fnid = "ada@example", + ) val restored = roundTrip({ it.saveCredential(original) }, { it.loadCredential() }) assertEquals(original, restored) } /** - * A credential written before addresses existed is still a valid - * credential and must not fail to load. + * A credential written before addresses existed is still a valid credential + * and must not fail to load. */ @Test fun `a credential with no address round-trips as null rather than empty`() { - val restored = roundTrip({ - it.saveCredential(Vault.Credential( - "https://relay.example", "ada", "pw", 42, fnid = null)) - }, { it.loadCredential() }) + val restored = + roundTrip({ + it.saveCredential( + Vault.Credential("https://relay.example", "ada", "pw", 42, fnid = null), + ) + }, { it.loadCredential() }) assertNull(restored!!.fnid) } @@ -76,28 +83,30 @@ class VaultPersistenceTest { assertNull(Vault(MapStorage(), key()).loadCredential()) } - // -- Trust --------------------------------------------------------------- + // -- Trust ---------------------------------------------------------------. @Test fun `a trust record survives a restart`() { - val original = Vault.Trust( - identities = listOf("ik-one", "ik-two"), verified = true, - fnid = "bob@example", changed = false) + val original = + Vault.Trust( + identities = listOf("ik-one", "ik-two"), + verified = true, + fnid = "bob@example", + changed = false, + ) val restored = roundTrip({ it.saveTrust(7, original) }, { it.loadTrust(7) }) assertEquals(original, restored) } /** - * The flag whose absence made reinstalled contacts unmessageable. If - * this does not persist, `state()` can never answer CHANGED, so the - * "Accept new keys" button never renders and the only route back is - * clearing app data. + * The flag whose absence made reinstalled contacts unmessageable. */ @Test fun `a detected key change is still there after a restart`() { - val restored = roundTrip({ - it.saveTrust(7, Vault.Trust(listOf("ik-old"), verified = false, changed = true)) - }, { it.loadTrust(7) }) + val restored = + roundTrip({ + it.saveTrust(7, Vault.Trust(listOf("ik-old"), verified = false, changed = true)) + }, { it.loadTrust(7) }) assertTrue(restored!!.changed) // and the pinned identities are unchanged: the flag records that // something else was OFFERED, not that we believe it. @@ -106,15 +115,17 @@ class VaultPersistenceTest { @Test fun `trust with no address round-trips as null`() { - val restored = roundTrip({ - it.saveTrust(7, Vault.Trust(listOf("ik"), verified = false, fnid = null)) - }, { it.loadTrust(7) }) + val restored = + roundTrip({ + it.saveTrust(7, Vault.Trust(listOf("ik"), verified = false, fnid = null)) + }, { it.loadTrust(7) }) assertNull(restored!!.fnid) } @Test fun `trust is per contact`() { - val storage = MapStorage(); val secret = key() + val storage = MapStorage() + val secret = key() val v = Vault(storage, secret) v.saveTrust(1, Vault.Trust(listOf("ik-1"), verified = true)) v.saveTrust(2, Vault.Trust(listOf("ik-2"), verified = false)) @@ -127,7 +138,8 @@ class VaultPersistenceTest { @Test fun `forgetting a contact's trust removes it and leaves others alone`() { - val storage = MapStorage(); val secret = key() + val storage = MapStorage() + val secret = key() val v = Vault(storage, secret) v.saveTrust(1, Vault.Trust(listOf("ik-1"), verified = true)) v.saveTrust(2, Vault.Trust(listOf("ik-2"), verified = true)) @@ -138,15 +150,13 @@ class VaultPersistenceTest { } /** - * Every record's name is bound into the AAD, so a blob is not - * portable between slots. Without that, someone with file access could - * paste one contact's pinned keys into another contact's slot and the app - * would accept them, attributing a stranger's identity to someone you - * trust. + * Every record's name is bound into the AAD, so a blob is not portable + * between slots. */ @Test fun `a trust record moved into another contact's slot does not open`() { - val storage = MapStorage(); val secret = key() + val storage = MapStorage() + val secret = key() val v = Vault(storage, secret) v.saveTrust(1, Vault.Trust(listOf("ik-victim"), verified = true)) v.saveTrust(2, Vault.Trust(listOf("ik-other"), verified = false)) @@ -158,17 +168,22 @@ class VaultPersistenceTest { assertFailsWith { Vault(storage, secret).loadTrust(2) } } - // -- Sealed sender delivery key ----------------------------------------- + // -- Sealed sender delivery key -----------------------------------------. @Test fun `a delivery key survives a restart with its peers`() { val mine = ByteArray(32) { 9 } - val restored = roundTrip({ - it.saveDelivery(Vault.Delivery( - key = mine, published = true, - peers = mapOf(5L to ByteArray(32) { 1 }, 6L to ByteArray(32) { 2 }), - toldPeers = setOf(5L))) - }, { it.loadDelivery() })!! + val restored = + roundTrip({ + it.saveDelivery( + Vault.Delivery( + key = mine, + published = true, + peers = mapOf(5L to ByteArray(32) { 1 }, 6L to ByteArray(32) { 2 }), + toldPeers = setOf(5L), + ), + ) + }, { it.loadDelivery() })!! assertContentEquals(mine, restored.key) assertTrue(restored.published) @@ -178,31 +193,29 @@ class VaultPersistenceTest { } /** - * `published` is the relay's knowledge, not ours. Losing it across a - * restart means either re-publishing forever or believing a key is - * reachable when the relay has never seen its digest. + * `published` is the relay's knowledge, not ours. */ @Test fun `an unpublished delivery key does not come back as published`() { - val restored = roundTrip({ - it.saveDelivery(Vault.Delivery(ByteArray(32) { 3 }, published = false)) - }, { it.loadDelivery() })!! + val restored = + roundTrip({ + it.saveDelivery(Vault.Delivery(ByteArray(32) { 3 }, published = false)) + }, { it.loadDelivery() })!! assertTrue(!restored.published) } - // -- Key transparency pins ---------------------------------------------- + // -- Key transparency pins ----------------------------------------------. /** * The pin is what makes the log append-only from this device's point of - * view: a head smaller than the one we already saw is a rewind. A pin that - * does not survive a restart makes every launch trust whatever it is - * handed first. + * view: a head smaller than the one we already saw is a rewind. */ @Test fun `a key log pin survives a restart`() { - val restored = roundTrip({ - it.saveKeyLogPin(Vault.KeyLogPin(size = 128, root = "cm9vdA==")) - }, { it.loadKeyLogPin() })!! + val restored = + roundTrip({ + it.saveKeyLogPin(Vault.KeyLogPin(size = 128, root = "cm9vdA==")) + }, { it.loadKeyLogPin() })!! assertEquals(128, restored.size) assertEquals("cm9vdA==", restored.root) } @@ -210,8 +223,11 @@ class VaultPersistenceTest { @Test fun `the pinned issuer key survives a restart`() { val issuer = ByteArray(32) { it.toByte() } - val restored = roundTrip({ it.saveKeyLogIssuerPin(issuer) }, - { it.keyLogIssuerPin() }) + val restored = + roundTrip( + { it.saveKeyLogIssuerPin(issuer) }, + { it.keyLogIssuerPin() }, + ) assertContentEquals(issuer, restored) } @@ -220,13 +236,16 @@ class VaultPersistenceTest { assertNull(Vault(MapStorage(), key()).keyLogIssuerPin()) } - // -- Roster -------------------------------------------------------------- + // -- Roster --------------------------------------------------------------. @Test fun `a roster survives a restart with its off-server flag`() { val items = JSONArray().put(JSONObject().put("id", 1).put("name", "ada")) - val (readItems, offServer) = roundTrip({ it.saveRoster(items, offServer = true) }, - { it.loadRoster() }) + val (readItems, offServer) = + roundTrip( + { it.saveRoster(items, offServer = true) }, + { it.loadRoster() }, + ) assertEquals(1, readItems.length()) assertEquals("ada", readItems.getJSONObject(0).getString("name")) assertTrue(offServer) @@ -239,12 +258,19 @@ class VaultPersistenceTest { assertTrue(!offServer) } - // -- Avatars ------------------------------------------------------------- + // -- Avatars -------------------------------------------------------------. - private fun ref(id: String = "blob-1") = Attachment.Ref( - kind = Attachment.IMAGE, id = id, key = ByteArray(32) { 4 }, - digest = ByteArray(32) { 5 }, bytes = 100, mime = "image/jpeg", - width = 64, height = 64) + private fun ref(id: String = "blob-1") = + Attachment.Ref( + kind = Attachment.IMAGE, + id = id, + key = ByteArray(32) { 4 }, + digest = ByteArray(32) { 5 }, + bytes = 100, + mime = "image/jpeg", + width = 64, + height = 64, + ) @Test fun `a contact's avatar survives a restart`() { @@ -255,7 +281,8 @@ class VaultPersistenceTest { @Test fun `clearing a contact's avatar is remembered`() { - val storage = MapStorage(); val secret = key() + val storage = MapStorage() + val secret = key() Vault(storage, secret).saveAvatar(11, ref()) Vault(storage, secret).saveAvatar(11, null) assertNull(Vault(storage, secret).avatarFor(11)) @@ -269,29 +296,38 @@ class VaultPersistenceTest { /** * Whether a peer already has our avatar is what stops us re-sending it on - * every message. Forgetting it across a restart turns a one-time upload - * into a per-launch one. + * every message. */ @Test fun `who we have already shared an avatar with is remembered`() { - val restored = roundTrip({ it.markAvatarShared(11, "blob-1") }, - { it.avatarSharedWith(11) }) + val restored = + roundTrip( + { it.markAvatarShared(11, "blob-1") }, + { it.avatarSharedWith(11) }, + ) assertEquals("blob-1", restored) } - // -- History deletion ---------------------------------------------------- + // -- History deletion ----------------------------------------------------. - private fun line(at: Long, mine: Boolean, text: String) = - JSONObject().put("at", at).put("mine", mine).put("text", text) + private fun line( + at: Long, + mine: Boolean, + text: String, + ) = JSONObject().put("at", at).put("mine", mine).put("text", text) @Test fun `deleting one line leaves the rest of the thread intact`() { - val storage = MapStorage(); val secret = key() + val storage = MapStorage() + val secret = key() val v = Vault(storage, secret) - v.replaceHistory("t", JSONArray() - .put(line(1, true, "first")) - .put(line(2, false, "second")) - .put(line(3, true, "third"))) + v.replaceHistory( + "t", + JSONArray() + .put(line(1, true, "first")) + .put(line(2, false, "second")) + .put(line(3, true, "third")), + ) Vault(storage, secret).deleteLine("t", at = 2, mine = false, text = "second") @@ -307,11 +343,15 @@ class VaultPersistenceTest { */ @Test fun `deleting is precise about which line it removes`() { - val storage = MapStorage(); val secret = key() - Vault(storage, secret).replaceHistory("t", JSONArray() - .put(line(5, true, "same ms mine")) - .put(line(5, false, "same ms theirs")) - .put(line(5, true, "same ms mine too"))) + val storage = MapStorage() + val secret = key() + Vault(storage, secret).replaceHistory( + "t", + JSONArray() + .put(line(5, true, "same ms mine")) + .put(line(5, false, "same ms theirs")) + .put(line(5, true, "same ms mine too")), + ) Vault(storage, secret).deleteLine("t", at = 5, mine = true, text = "same ms mine") @@ -323,7 +363,8 @@ class VaultPersistenceTest { @Test fun `deleting a line that is not there changes nothing`() { - val storage = MapStorage(); val secret = key() + val storage = MapStorage() + val secret = key() Vault(storage, secret).replaceHistory("t", JSONArray().put(line(1, true, "only"))) Vault(storage, secret).deleteLine("t", at = 99, mine = true, text = "absent") assertEquals(1, Vault(storage, secret).history("t").length()) @@ -332,7 +373,8 @@ class VaultPersistenceTest { /** "Deleted means deleted", the record goes, not a tombstone. */ @Test fun `forgetting a thread removes its record entirely`() { - val storage = MapStorage(); val secret = key() + val storage = MapStorage() + val secret = key() Vault(storage, secret).replaceHistory("t", JSONArray().put(line(1, true, "hi"))) val before = storage.names().size Vault(storage, secret).forgetHistory("t") diff --git a/engine/src/test/kotlin/io/flamenet/e2e/VaultTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/VaultTest.kt index 8241ecd..2cd5366 100644 --- a/engine/src/test/kotlin/io/flamenet/e2e/VaultTest.kt +++ b/engine/src/test/kotlin/io/flamenet/e2e/VaultTest.kt @@ -12,23 +12,36 @@ import kotlin.test.assertNull import kotlin.test.assertTrue class VaultTest { - /** Stands in for a directory on a phone, and lets a test see the raw bytes. */ private class MapStorage : Vault.Storage { val records = LinkedHashMap() + override fun read(name: String) = records[name] - override fun write(name: String, bytes: ByteArray) { records[name] = bytes } - override fun delete(name: String) { records.remove(name) } + + override fun write( + name: String, + bytes: ByteArray, + ) { + records[name] = bytes + } + + override fun delete(name: String) { + records.remove(name) + } + override fun names() = records.keys.toList() } - private fun key(): SecretKey = - KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() + private fun key(): SecretKey = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() private fun bundleFor(identity: DeviceIdentity): PeerBundle { val oneTime = identity.generateOneTimePreKeys(1).first() - return PeerBundle.fromWire(identity.registrationWire().put("opk", - JSONObject().put("id", oneTime.id).put("pub", Bytes.b64(oneTime.publicKey)))) + return PeerBundle.fromWire( + identity.registrationWire().put( + "opk", + JSONObject().put("id", oneTime.id).put("pub", Bytes.b64(oneTime.publicKey)), + ), + ) } @Test @@ -71,16 +84,6 @@ class VaultTest { /** * The chain a re-establishment replaces must survive it. - * - * Both sides open the chat at once, so each runs X3DH before seeing the - * other's prekey and each adopts the other's chain to read what arrived. - * From then on the peer sends on the chain being replaced. A client that - * stored only the newest session could not open any of it, silently, - * because an envelope that will not decrypt is skipped, not reported. - * - * This is the storage half of the fix. `E2ESessionTest` covers the engine - * primitives, and passes with or without it, because a test holding both - * sessions in local variables never exercises what a client keeps. */ @Test fun `a retired session is kept and still opens its chain`() { @@ -111,27 +114,20 @@ class VaultTest { val reply = onTheRetiredChain.encrypt("bob, this is the old chain") val fresh = Vault(storage, secret) - assertNull(runCatching { fresh.loadSession(bob.deviceId)!!.decrypt(reply) }.getOrNull(), + assertNull( + runCatching { fresh.loadSession(bob.deviceId)!!.decrypt(reply) }.getOrNull(), "the adopted session must NOT open the retired chain, otherwise this " + - "test would pass without the retired copy existing at all") - assertEquals("bob, this is the old chain", - fresh.loadRetiredSession(bob.deviceId)!!.decrypt(reply)) + "test would pass without the retired copy existing at all", + ) + assertEquals( + "bob, this is the old chain", + fresh.loadRetiredSession(bob.deviceId)!!.decrypt(reply), + ) } /** - * A failed decrypt damages the session object it was handed, so the - * thing that gets retired must be re-read from the vault. - * - * This is the bug the first version of the retirement fix shipped with, and - * the reason `a retired session is kept and still opens its chain` did not - * catch it: that test retires a PRISTINE session, which is not what the - * receive path has in its hand. The real path has just proved the peer is on - * another chain, by handing this session an envelope it could not open, - * and `E2ESession` is a class, so that attempt consumed skipped keys and - * stepped the chain. Retiring THAT instance stores something that can no - * longer open anything, which looks exactly like not retiring at all. - * - * Asserts both halves: the mutated instance fails, the re-read one works. + * A failed decrypt damages the session object it was handed, so the thing + * that gets retired must be re-read from the vault. */ @Test fun `the session retired after a failed decrypt must be re-read, not reused`() { @@ -140,40 +136,49 @@ class VaultTest { val alice = DeviceIdentity.create() val bob = DeviceIdentity.create() - // Bob's own chain, stored, with a message Alice will later answer on it. + // Bob's own chain, stored, with a message Alice will later answer on + // it. val bobsOwn = E2ESession.initiate(bob, bundleFor(alice)) val bobsFirst = bobsOwn.encrypt("from bob") Vault(storage, secret).saveSession(bob.deviceId, bobsOwn) - val alicesReplyOnBobsChain = E2ESession.respond(alice, bobsFirst).let { - it.decrypt(bobsFirst) - it.encrypt("answering on the chain bob started") - } + val alicesReplyOnBobsChain = + E2ESession.respond(alice, bobsFirst).let { + it.decrypt(bobsFirst) + it.encrypt("answering on the chain bob started") + } // The receive path: Bob's stored session is handed a foreign envelope - // and fails. THIS is the instance the buggy version retired. + // and fails. val foreign = E2ESession.initiate(alice, bundleFor(bob)).encrypt("different chain") val damaged = Vault(storage, secret).loadSession(bob.deviceId)!! - assertNull(runCatching { damaged.decrypt(foreign) }.getOrNull(), - "the foreign envelope must NOT open, or this test proves nothing") + assertNull( + runCatching { damaged.decrypt(foreign) }.getOrNull(), + "the foreign envelope must NOT open, or this test proves nothing", + ) // Retiring the damaged instance loses the chain. val wrong = MapStorage().also { storage.records.forEach(it.records::put) } Vault(wrong, secret).saveRetiredSession(bob.deviceId, damaged) assertNull( runCatching { - Vault(wrong, secret).loadRetiredSession(bob.deviceId)!! + Vault(wrong, secret) + .loadRetiredSession(bob.deviceId)!! .decrypt(alicesReplyOnBobsChain) }.getOrNull(), "a session damaged by a failed decrypt should NOT still open its chain, " + - "if this ever starts passing, the hazard is gone and this test can go") + "if this ever starts passing, the hazard is gone and this test can go", + ) - // Re-reading the vault copy keeps it. This is the fix. + // Re-reading the vault copy keeps it. val pristine = Vault(storage, secret).loadSession(bob.deviceId)!! Vault(storage, secret).saveRetiredSession(bob.deviceId, pristine) - assertEquals("answering on the chain bob started", - Vault(storage, secret).loadRetiredSession(bob.deviceId)!! - .decrypt(alicesReplyOnBobsChain)) + assertEquals( + "answering on the chain bob started", + Vault(storage, secret) + .loadRetiredSession(bob.deviceId)!! + .decrypt(alicesReplyOnBobsChain), + ) } /** Signing out must take the retired chains too, they hold chain keys. */ @@ -192,8 +197,10 @@ class VaultTest { assertNull(Vault(storage, secret).loadSession(bob.deviceId)) assertNull(Vault(storage, secret).loadRetiredSession(bob.deviceId)) - assertTrue(storage.records.keys.none { it.contains("session") }, - "a record still names a session: ${storage.records.keys}") + assertTrue( + storage.records.keys.none { it.contains("session") }, + "a record still names a session: ${storage.records.keys}", + ) } /** What is on disk must not be readable, or none of this was worth doing. */ @@ -210,10 +217,7 @@ class VaultTest { } /** - * The failure this catches: a stored blob moved from one slot to - * another. Without the name in the AAD, a peer's session pasted into a - * different peer's slot decrypts perfectly and the app attributes their - * messages to the wrong person. + * The failure this catches: a stored blob moved from one slot to another. */ @Test fun `a record moved to another slot does not open`() { @@ -251,10 +255,7 @@ class VaultTest { } /** - * A corrupt identity must NOT read as "no identity". The caller treats - * null as a new install and mints a fresh device, which every contact sees - * as "security keys changed", the warning that is supposed to mean an - * attack. Absent and unreadable are different answers. + * A corrupt identity must NOT read as "no identity". */ @Test fun `an unreadable identity is not mistaken for a missing one`() { @@ -305,8 +306,7 @@ class VaultTest { @Test fun `two writes of the same value do not produce the same bytes`() { - // A fresh nonce per write. Repeating one under the same key is the - // catastrophic AES-GCM failure, not a degraded one. + // A fresh nonce per write. val storage = MapStorage() val secret = key() val identity = DeviceIdentity.create() @@ -316,23 +316,10 @@ class VaultTest { assertNotEquals(Bytes.b64(first), Bytes.b64(storage.records.getValue("identity"))) } - // ---- Trust records and the platform's JSON --------------------------- + // ---- Trust records and the platform's JSON ---------------------------. /** * A contact with no known address must come back with NO address. - * - * `saveTrust` used to write `"fnid": null` explicitly, and Android's - * bundled `org.json` reads a JSON null back as the four-character string - * `"null"`, a non-null value that is not an address. `Trust.pin` compares a - * newly offered fnid against the pinned one and treats a difference as a - * different person, so the first time a real address arrived the contact was - * marked CHANGED: the "security keys changed" warning, which is meant to - * mean an attack, fired on an ordinary first sighting. - * - * The JVM's `org.json` returns `""` there, so this passed on a laptop and - * failed on a phone. The two tests below cover both halves, what is - * written now, and what has to be tolerated because it is already on - * devices in the field. */ @Test fun `a trust record with no address writes no address at all`() { @@ -340,39 +327,35 @@ class VaultTest { val secret = key() Vault(storage, secret).saveTrust(7, Vault.Trust(listOf("aaa"), verified = false, fnid = null)) - assertNull(Vault(storage, secret).loadTrust(7)?.fnid, - "an absent address came back as a value") + assertNull( + Vault(storage, secret).loadTrust(7)?.fnid, + "an absent address came back as a value", + ) } /** - * This is the test that reproduces the device, and the one above is - * not. On the JVM a real JSON null reads back as `""` whether the writer - * omits the key or not, so the round trip above passes with the bug intact. - * Handing the reader the literal string is what Android actually does, so - * writing it here is what makes a laptop run tell the truth. + * This is the test that reproduces the device, and the one above is not. */ @Test fun `a record already holding the literal string null reads as no address`() { - // Records written the old way exist on phones already. The reader has - // to repair them, or those contacts stay permanently mis-addressed, - // `rememberIdentity` returns early once `fnid != null`, so nothing would - // ever ask again. + // Records written the old way exist on phones already. val storage = MapStorage() val secret = key() val vault = Vault(storage, secret) vault.saveTrust(7, Vault.Trust(listOf("aaa"), verified = false, fnid = "null")) - assertNull(Vault(storage, secret).loadTrust(7)?.fnid, - "the literal string \"null\" was accepted as somebody's global address") + assertNull( + Vault(storage, secret).loadTrust(7)?.fnid, + "the literal string \"null\" was accepted as somebody's global address", + ) } @Test fun `a real address still round-trips`() { val storage = MapStorage() val secret = key() - Vault(storage, secret).saveTrust( - 7, Vault.Trust(listOf("aaa"), verified = true, fnid = "fnid:abc123")) + Vault(storage, secret).saveTrust(7, Vault.Trust(listOf("aaa"), verified = true, fnid = "fnid:abc123")) val back = Vault(storage, secret).loadTrust(7) assertEquals("fnid:abc123", back?.fnid) assertTrue(back!!.verified) diff --git a/engine/src/test/kotlin/io/flamenet/e2e/WalletPrimitivesTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/WalletPrimitivesTest.kt index edd5ac6..7e4717b 100644 --- a/engine/src/test/kotlin/io/flamenet/e2e/WalletPrimitivesTest.kt +++ b/engine/src/test/kotlin/io/flamenet/e2e/WalletPrimitivesTest.kt @@ -10,35 +10,21 @@ import kotlin.test.assertTrue /** * BIP-39, Base58 and SLIP-0010 against the published vectors. - * - * These are pinned to the STANDARDS, not to the Swift engine. Testing - * one implementation against the other proves only that they agree, and two - * implementations agreeing on the wrong answer is exactly how a wallet ends up - * empty: a phrase written down on an iPhone has to restore on an Android - * handset, and on somebody else's software years from now. So the vectors here - * are BIP-39's own, SLIP-0010's own, and the published word-list digest, which - * the Swift engine is also held to. - * - * Every failure in this file is silent in production. A wrong checksum - * bit, a lost leading zero, a missed NFKD: none of them error. They derive a - * different, perfectly valid, empty wallet, and the way somebody finds out is - * restoring their phrase and seeing nothing there. */ class WalletPrimitivesTest { - private fun hex(bytes: ByteArray) = bytes.joinToString("") { "%02x".format(it) } - private fun unhex(text: String) = ByteArray(text.length / 2) { - text.substring(it * 2, it * 2 + 2).toInt(16).toByte() - } + + private fun unhex(text: String) = + ByteArray(text.length / 2) { + text.substring(it * 2, it * 2 + 2).toInt(16).toByte() + } private val words by lazy { Bip39.englishWordlist() } - // ---- The word list --------------------------------------------------- + // ---- The word list ---------------------------------------------------. /** - * The whole mitigation for vendoring 2,048 strings. A silently corrupted - * list does not error, it produces a DIFFERENT wallet from the same - * phrase. + * The whole mitigation for vendoring 2,048 strings. */ @Test fun `the vendored word list matches its published fingerprint`() { @@ -55,7 +41,7 @@ class WalletPrimitivesTest { assertFailsWith { Bip39.verify(words.drop(1)) } } - // ---- BIP-39 vectors -------------------------------------------------- + // ---- BIP-39 vectors --------------------------------------------------. /** * The canonical first vector from BIP-39's own test file: all-zero entropy. @@ -83,16 +69,15 @@ class WalletPrimitivesTest { val entropy = ByteArray(32) { 0xff.toByte() } val phrase = Bip39.phrase(entropy, words) assertEquals(24, phrase.split(" ").size) - assertEquals("zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo " + - "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo vote", phrase) + assertEquals( + "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo " + + "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo vote", + phrase, + ) assertContentEquals(entropy, Bip39.entropy(phrase, words)) // This expectation was WRONG in the first version of this file, it // carried the seed from a different BIP-39 vector, and the Kotlin - // answer below is the correct one. It was resolved by computing the - // value independently (PBKDF2-HMAC-SHA512, NFKD, 2048 rounds) rather - // than by adopting whatever the implementation produced, which is the - // only way a vector test is worth anything: a pinned value taken from - // the code under test pins the bug. + // answer below is the correct one. assertEquals( "dd48c104698c30cfe2b6142103248622fb7bb0ff692eebb00089b32d22484e16" + "13912f0a5b694407be899ffd31ed3992c456cdf60f5d4564b8ba3f05a69890ad", @@ -114,9 +99,7 @@ class WalletPrimitivesTest { } /** - * The passphrase silently names a different wallet. That is BIP-39's - * design, and it is the sharpest edge in the whole standard, somebody who - * mistypes it does not get an error, they get an empty account. + * The passphrase silently names a different wallet. */ @Test fun `a different BIP-39 passphrase derives a different seed`() { @@ -125,7 +108,7 @@ class WalletPrimitivesTest { assertNotEquals(hex(Bip39.seed(phrase, "a")), hex(Bip39.seed(phrase, "A"))) } - // ---- BIP-39 refusals ------------------------------------------------- + // ---- BIP-39 refusals -------------------------------------------------. @Test fun `a mistyped word is refused rather than resolved to a neighbour`() { @@ -145,7 +128,12 @@ class WalletPrimitivesTest { @Test fun `a phrase of the wrong length is refused`() { - val short = Bip39.phrase(ByteArray(16), words).split(" ").drop(1).joinToString(" ") + val short = + Bip39 + .phrase(ByteArray(16), words) + .split(" ") + .drop(1) + .joinToString(" ") assertFailsWith { Bip39.entropy(short, words) } } @@ -165,14 +153,10 @@ class WalletPrimitivesTest { ) } - // ---- Base58 ---------------------------------------------------------- + // ---- Base58 ----------------------------------------------------------. /** - * The leading-zero rule, which is where money goes missing. Base58 is - * base conversion of one integer, and an integer cannot hold a leading zero - * byte, the standard prepends one `1` per zero byte instead. An - * implementation using `BigInteger` loses them and encodes two different - * keys to one address. + * The leading-zero rule, which is where money goes missing. */ @Test fun `leading zero bytes each become a one`() { @@ -198,8 +182,11 @@ class WalletPrimitivesTest { ByteArray(32) { 0xff.toByte() }, ByteArray(32), )) { - assertContentEquals(case, Base58.decode(Base58.encode(case)), - "round trip lost ${hex(case)}") + assertContentEquals( + case, + Base58.decode(Base58.encode(case)), + "round trip lost ${hex(case)}", + ) } } @@ -213,8 +200,7 @@ class WalletPrimitivesTest { } /** - * A mistyped address must FAIL, not resolve to somebody else's. `0`, - * `O`, `I` and `l` are absent from the alphabet for exactly this reason. + * A mistyped address must FAIL, not resolve to somebody else's. */ @Test fun `a character outside the alphabet decodes to nothing`() { @@ -223,12 +209,10 @@ class WalletPrimitivesTest { assertNull(Base58.decode("abc+def")) } - // ---- SLIP-0010 vectors ----------------------------------------------- + // ---- SLIP-0010 vectors -----------------------------------------------. /** - * SLIP-0010's own ed25519 test vector one. The chain code is checked as - * well as the key: a derivation that gets the key right and the chain code - * wrong produces correct CHILDREN of nothing, and diverges one level down. + * SLIP-0010's own ed25519 test vector one. */ @Test fun `slip10 ed25519 vector one`() { @@ -263,10 +247,11 @@ class WalletPrimitivesTest { /** SLIP-0010's ed25519 vector two, with a longer seed. */ @Test fun `slip10 ed25519 vector two`() { - val seed = unhex( - "fffcf9f6f3f0edeae7e4e1dedbd8d5d2cfccc9c6c3c0bdbab7b4b1aeaba8a5a2" + - "9f9c999693908d8a8784817e7b7875726f6c696663605d5a5754514e4b484542" - ) + val seed = + unhex( + "fffcf9f6f3f0edeae7e4e1dedbd8d5d2cfccc9c6c3c0bdbab7b4b1aeaba8a5a2" + + "9f9c999693908d8a8784817e7b7875726f6c696663605d5a5754514e4b484542", + ) val master = HdKey.master(seed) assertEquals( "171cb88b1b3c1db25add599712e36245d75bc65a1a5c9e18d76f9f2b1eab4012", @@ -281,10 +266,6 @@ class WalletPrimitivesTest { /** * A path without hardening is REFUSED, not silently hardened. - * SLIP-0010 defines no non-hardened derivation for ed25519. Somebody - * writing `m/44` means non-hardened; quietly handing them a different key - * than every other wallet would give them an address nobody else can - * reproduce from the same phrase. */ @Test fun `an unhardened path component is refused`() { @@ -315,8 +296,7 @@ class WalletPrimitivesTest { } /** - * Derivation is a pure function of seed and path. Sounds obvious; it is - * the property a restored wallet depends on entirely. + * Derivation is a pure function of seed and path. */ @Test fun `the same phrase and path always give the same key`() { @@ -333,9 +313,6 @@ class WalletPrimitivesTest { /** * The BIP-44 shape the wallet actually uses, end to end from a phrase. - * Not a published vector, the point is that the whole chain runs and that - * successive accounts differ, which is what "a fresh address per receive" - * rests on. */ @Test fun `a phrase walks the wallet path and gives distinct keys per account`() { diff --git a/engine/src/test/kotlin/io/flamenet/e2e/WalletTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/WalletTest.kt index ea96d50..7bec34c 100644 --- a/engine/src/test/kotlin/io/flamenet/e2e/WalletTest.kt +++ b/engine/src/test/kotlin/io/flamenet/e2e/WalletTest.kt @@ -10,28 +10,22 @@ import kotlin.test.assertTrue /** * Creating, importing and reading back a wallet. - * - * The property worth guarding hardest is a negative one: the wallet must - * NOT be derivable from the account. Deriving it from the passphrase would be - * convenient, would need no backup at all, and would tie every payment somebody - * ever received to their handle on a public ledger for ever. There is no way to - * undo that later, the chain does not forget, so it is asserted rather than - * left to good intentions. */ class WalletTest { - private val words by lazy { Bip39.englishWordlist() } /** Deterministic randomness, so a create() can be asserted against. */ - private fun fixedRandom(fill: Byte) = object : SecureRandom() { - override fun nextBytes(bytes: ByteArray) { bytes.fill(fill) } - } + private fun fixedRandom(fill: Byte) = + object : SecureRandom() { + override fun nextBytes(bytes: ByteArray) { + bytes.fill(fill) + } + } - // ---- What a wallet is not -------------------------------------------- + // ---- What a wallet is not --------------------------------------------. /** - * Two wallets made in a row share nothing. If `create` ever started - * deriving from something about the account, this is what would catch it. + * Two wallets made in a row share nothing. */ @Test fun `every wallet created is a different one`() { @@ -41,8 +35,22 @@ class WalletTest { @Test fun `a created wallet is 24 words by default and can be 12`() { - assertEquals(24, Wallet.create(words).phrase.split(" ").size) - assertEquals(12, Wallet.create(words, entropyBytes = 16).phrase.split(" ").size) + assertEquals( + 24, + Wallet + .create(words) + .phrase + .split(" ") + .size, + ) + assertEquals( + 12, + Wallet + .create(words, entropyBytes = 16) + .phrase + .split(" ") + .size, + ) } /** The entropy really is what the phrase encodes, not a parallel value. */ @@ -53,23 +61,31 @@ class WalletTest { assertEquals(made.phrase, Wallet.phrase(made.entropy, words)) } - // ---- Importing ------------------------------------------------------- + // ---- Importing -------------------------------------------------------. /** - * Validated before it is stored. A phrase that fails its checksum is - * refused here rather than saved and discovered later, when the wallet it - * names turns out to be empty and nobody can say why. + * Validated before it is stored. */ @Test fun `a phrase that does not check out is refused at import`() { val real = Wallet.create(words).phrase - val broken = real.split(" ").toMutableList().also { it[0] = it[1] }.joinToString(" ") + val broken = + real + .split(" ") + .toMutableList() + .also { it[0] = it[1] } + .joinToString(" ") assertFailsWith { Wallet.import(broken, words) } } @Test fun `a phrase containing a word that is not on the list is refused`() { - val real = Wallet.create(words).phrase.split(" ").toMutableList() + val real = + Wallet + .create(words) + .phrase + .split(" ") + .toMutableList() real[3] = "flamenet" assertFailsWith { Wallet.import(real.joinToString(" "), words) @@ -88,12 +104,10 @@ class WalletTest { ) } - // ---- The counter ----------------------------------------------------- + // ---- The counter -----------------------------------------------------. /** - * A restored wallet resumes at the index it was left at, when one is - * known. Resuming at zero would hand out addresses somebody has already - * been paid at, undoing the one linkability the wallet can prevent. + * A restored wallet resumes at the index it was left at, when one is known. */ @Test fun `an account can resume from a stored index`() { @@ -107,12 +121,10 @@ class WalletTest { ) } - // ---- The BIP-39 passphrase ------------------------------------------- + // ---- The BIP-39 passphrase -------------------------------------------. /** - * A BIP-39 passphrase silently names a DIFFERENT wallet. That is the - * standard's design and the sharpest edge in it: somebody who mistypes one - * does not get an error, they get an empty account. + * A BIP-39 passphrase silently names a DIFFERENT wallet. */ @Test fun `a BIP-39 passphrase produces a different wallet from the same phrase`() { @@ -122,7 +134,7 @@ class WalletTest { assertNotEquals(plain, guarded) } - // ---- End to end ------------------------------------------------------ + // ---- End to end ------------------------------------------------------. /** * The whole promise, in one test: write these words down, and any @@ -131,15 +143,17 @@ class WalletTest { @Test fun `a phrase written down restores the same wallet`() { val made = Wallet.create(words, random = fixedRandom(0x11)) - val addresses = (0L..3L).map { - Wallet.account(made.entropy, words).receive(it).address - } + val addresses = + (0L..3L).map { + Wallet.account(made.entropy, words).receive(it).address + } // Somebody types the words into a fresh install. val restored = Wallet.import(made.phrase, words) - val restoredAddresses = (0L..3L).map { - Wallet.account(restored, words).receive(it).address - } + val restoredAddresses = + (0L..3L).map { + Wallet.account(restored, words).receive(it).address + } assertEquals(addresses, restoredAddresses) assertTrue(addresses.all { HdAccount.looksLikeAddress(it) }) diff --git a/engine/src/test/kotlin/io/flamenet/e2e/X25519Test.kt b/engine/src/test/kotlin/io/flamenet/e2e/X25519Test.kt index 344c7f5..8e1760e 100644 --- a/engine/src/test/kotlin/io/flamenet/e2e/X25519Test.kt +++ b/engine/src/test/kotlin/io/flamenet/e2e/X25519Test.kt @@ -11,20 +11,12 @@ import kotlin.test.assertTrue * wire depends on. */ class X25519Test { - - private fun hex(s: String): ByteArray = - s.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + private fun hex(s: String): ByteArray = s.chunked(2).map { it.toInt(16).toByte() }.toByteArray() private fun ByteArray.hex(): String = joinToString("") { "%02x".format(it) } /** * RFC 7748 §6.1, the worked Diffie-Hellman example. - * - * Only the public halves are checked here: the JCA will not construct an - * X25519 private key from raw scalar bytes without a PKCS#8 wrapper, and - * building one by hand to check a number the RFC already publishes would be - * testing the wrapper rather than the agreement. The agreement itself is - * checked by the symmetry test below, which is the property that matters. */ @Test fun `rfc 7748 public keys survive the raw to DER round trip`() { @@ -32,8 +24,11 @@ class X25519Test { val bobPub = hex("de9edb7d7b7dc1b4d35b61c2ece435373f8343c85b78674dadfc7e146f882b4f") for (raw in listOf(alicePub, bobPub)) { val key = X25519.publicKeyFromRaw(raw) - assertContentEquals(raw, X25519.rawFromPublicKey(key), - "raw -> DER -> raw must be the identity") + assertContentEquals( + raw, + X25519.rawFromPublicKey(key), + "raw -> DER -> raw must be the identity", + ) } } @@ -71,22 +66,18 @@ class X25519Test { } /** - * RFC 7748 §6.1 lists the small-order points. A peer sending one forces the - * shared secret to all zeros, which both sides would then happily derive - * keys from, and the sender knows it in advance. - * - * The RFC says implementations MAY check. When the peer's key arrives over - * a relay nobody trusts, that is not optional. + * RFC 7748 §6.1 lists the small-order points. */ @Test fun `small-order peer keys are refused, not silently accepted`() { val alice = X25519.generate() - val smallOrder = listOf( - "0000000000000000000000000000000000000000000000000000000000000000", - "0100000000000000000000000000000000000000000000000000000000000000", - "e0eb7a7c3b41b8ae1656e3faf19fc46ada098deb9c32b1fd866205165f49b800", - "5f9c95bca3508c24b1d0b1559c83ef5b04445cc4581c8e86d8224eddd09f1157", - ) + val smallOrder = + listOf( + "0000000000000000000000000000000000000000000000000000000000000000", + "0100000000000000000000000000000000000000000000000000000000000000", + "e0eb7a7c3b41b8ae1656e3faf19fc46ada098deb9c32b1fd866205165f49b800", + "5f9c95bca3508c24b1d0b1559c83ef5b04445cc4581c8e86d8224eddd09f1157", + ) for (point in smallOrder) { val thrown = runCatching { X25519.agree(alice.private, hex(point)) }.exceptionOrNull() assertTrue( diff --git a/engine/src/test/kotlin/io/flamenet/e2e/X3DHTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/X3DHTest.kt index adcf3c3..8a23676 100644 --- a/engine/src/test/kotlin/io/flamenet/e2e/X3DHTest.kt +++ b/engine/src/test/kotlin/io/flamenet/e2e/X3DHTest.kt @@ -7,16 +7,8 @@ import kotlin.test.assertTrue /** * X3DH, both directions. - * - * The property that matters is agreement: Alice computes a secret from her - * private keys and Bob's public bundle, Bob computes one from his private keys - * and what Alice sent, and the two must be identical. A wrong DH order, a - * swapped argument, or a zero-filled DH4 all still produce 32 plausible bytes, - * they just produce *different* 32 bytes on each side, which is why this checks - * equality rather than shape. */ class X3DHTest { - private fun raw(kp: java.security.KeyPair) = X25519.rawFromPublicKey(kp.public) @Test @@ -26,21 +18,24 @@ class X3DHTest { val bobSigned = X25519.generate() val bobOneTime = X25519.generate() - val alice = X3DH.initiate( - identityPrivateDH = aliceIdentity.private, - bundle = X3DH.Bundle( - identityKeyDH = raw(bobIdentity), - signedPreKey = raw(bobSigned), - oneTimePreKey = raw(bobOneTime), - ), - ) - val bob = X3DH.respond( - identityPrivateDH = bobIdentity.private, - signedPreKeyPrivate = bobSigned.private, - oneTimePrivate = bobOneTime.private, - initiatorIdentityDH = raw(aliceIdentity), - initiatorEphemeral = alice.ephemeralPublic, - ) + val alice = + X3DH.initiate( + identityPrivateDH = aliceIdentity.private, + bundle = + X3DH.Bundle( + identityKeyDH = raw(bobIdentity), + signedPreKey = raw(bobSigned), + oneTimePreKey = raw(bobOneTime), + ), + ) + val bob = + X3DH.respond( + identityPrivateDH = bobIdentity.private, + signedPreKeyPrivate = bobSigned.private, + oneTimePrivate = bobOneTime.private, + initiatorIdentityDH = raw(aliceIdentity), + initiatorEphemeral = alice.ephemeralPublic, + ) assertContentEquals(alice.sharedSecret, bob, "the two sides disagree") assertTrue(alice.sharedSecret.size == 32) } @@ -48,23 +43,24 @@ class X3DHTest { @Test fun `both sides agree when the one-time prekey pool is drained`() { // A degraded but valid state: initial forward secrecy is lost, and the - // session must still work. Omitting DH4 on one side while the other - // zero-fills it is the classic way to break exactly this case. + // session must still work. val aliceIdentity = X25519.generate() val bobIdentity = X25519.generate() val bobSigned = X25519.generate() - val alice = X3DH.initiate( - identityPrivateDH = aliceIdentity.private, - bundle = X3DH.Bundle(raw(bobIdentity), raw(bobSigned), null), - ) - val bob = X3DH.respond( - identityPrivateDH = bobIdentity.private, - signedPreKeyPrivate = bobSigned.private, - oneTimePrivate = null, - initiatorIdentityDH = raw(aliceIdentity), - initiatorEphemeral = alice.ephemeralPublic, - ) + val alice = + X3DH.initiate( + identityPrivateDH = aliceIdentity.private, + bundle = X3DH.Bundle(raw(bobIdentity), raw(bobSigned), null), + ) + val bob = + X3DH.respond( + identityPrivateDH = bobIdentity.private, + signedPreKeyPrivate = bobSigned.private, + oneTimePrivate = null, + initiatorIdentityDH = raw(aliceIdentity), + initiatorEphemeral = alice.ephemeralPublic, + ) assertContentEquals(alice.sharedSecret, bob) } @@ -79,16 +75,18 @@ class X3DHTest { val bobOneTime = X25519.generate() val ephemeral = X25519.generate() - val withOpk = X3DH.initiate( - aliceIdentity.private, - X3DH.Bundle(raw(bobIdentity), raw(bobSigned), raw(bobOneTime)), - ephemeral, - ) - val withoutOpk = X3DH.initiate( - aliceIdentity.private, - X3DH.Bundle(raw(bobIdentity), raw(bobSigned), null), - ephemeral, - ) + val withOpk = + X3DH.initiate( + aliceIdentity.private, + X3DH.Bundle(raw(bobIdentity), raw(bobSigned), raw(bobOneTime)), + ephemeral, + ) + val withoutOpk = + X3DH.initiate( + aliceIdentity.private, + X3DH.Bundle(raw(bobIdentity), raw(bobSigned), null), + ephemeral, + ) assertFalse(withOpk.sharedSecret.contentEquals(withoutOpk.sharedSecret)) } @@ -100,15 +98,16 @@ class X3DHTest { val malloryIdentity = X25519.generate() val mallorySigned = X25519.generate() - val toBob = X3DH.initiate( - aliceIdentity.private, X3DH.Bundle(raw(bobIdentity), raw(bobSigned), null)) - val malloryTries = X3DH.respond( - identityPrivateDH = malloryIdentity.private, - signedPreKeyPrivate = mallorySigned.private, - oneTimePrivate = null, - initiatorIdentityDH = raw(aliceIdentity), - initiatorEphemeral = toBob.ephemeralPublic, - ) + val toBob = + X3DH.initiate(aliceIdentity.private, X3DH.Bundle(raw(bobIdentity), raw(bobSigned), null)) + val malloryTries = + X3DH.respond( + identityPrivateDH = malloryIdentity.private, + signedPreKeyPrivate = mallorySigned.private, + oneTimePrivate = null, + initiatorIdentityDH = raw(aliceIdentity), + initiatorEphemeral = toBob.ephemeralPublic, + ) assertFalse(toBob.sharedSecret.contentEquals(malloryTries)) } } diff --git a/interop/src/main/kotlin/io/flamenet/interop/Main.kt b/interop/src/main/kotlin/io/flamenet/interop/Main.kt index e094f21..b612217 100644 --- a/interop/src/main/kotlin/io/flamenet/interop/Main.kt +++ b/interop/src/main/kotlin/io/flamenet/interop/Main.kt @@ -1,18 +1,18 @@ package io.flamenet.interop import io.flamenet.e2e.AccountKdf -import io.flamenet.e2e.Content import io.flamenet.e2e.Aead +import io.flamenet.e2e.Content import io.flamenet.e2e.DeviceIdentity import io.flamenet.e2e.DoubleRatchet import io.flamenet.e2e.E2ESession -import io.flamenet.e2e.Envelope -import io.flamenet.e2e.PQKEM -import io.flamenet.e2e.PeerBundle import io.flamenet.e2e.Ed25519 +import io.flamenet.e2e.Envelope import io.flamenet.e2e.Hkdf import io.flamenet.e2e.KeyLog import io.flamenet.e2e.Label +import io.flamenet.e2e.PQKEM +import io.flamenet.e2e.PeerBundle import io.flamenet.e2e.SafetyNumber import io.flamenet.e2e.SealedSender import io.flamenet.e2e.X25519 @@ -22,24 +22,6 @@ import java.security.KeyPair /** * The Kotlin side of the cross-engine interop harness (spec §12.10.3). - * - * Reads one JSON request per line on stdin and writes one JSON response per - * line on stdout. `engine/test/interop-kotlin.mjs` drives it against the web - * engine, which is normative. - * - * This must call the engine, never reimplement it. The moment this file - * contains its own HKDF or its own header encoding, the harness starts proving - * that this file agrees with the web engine, which is worthless, because this - * file is not what runs on a phone. - * - * Long-lived rather than one process per request: the alternative is - * serialising ratchet state across the boundary, and a state serialiser written - * for a test is a second implementation of the ratchet's invariants. Sessions - * therefore live here, behind an id. - * - * Errors go back as `{"error": "..."}` on stdout, not as a crash: a driver that - * dies takes every later request with it, and the harness then reports one - * failure where there were twenty. */ private val devices = HashMap() @@ -55,20 +37,20 @@ private class Device( val oneTimePreKeyId: Int, val oneTimePreKey: KeyPair, /** - * The post-quantum signed prekey (§12.5). Published in the bundle, and the - * key an initiator encapsulates to, which is also where the v3 ratchet - * starts, because `PQr` must be exactly the key X3DH targeted (§13.2). + * The post-quantum signed prekey (§12.5). */ val pqSignedPreKeyId: Int = 1, val pqSignedPreKey: PQKEM.KeyPair = PQKEM.generate(), ) -private class Session(val ratchet: DoubleRatchet, val ad: ByteArray) +private class Session( + val ratchet: DoubleRatchet, + val ad: ByteArray, +) -// Spelled out here rather than imported: `DeviceIdentity.PQDomain` is -// private, and a driver that reached into the engine's internals to build a -// bundle would be checking the engine against itself. These are the strings the -// SPEC names, so a divergence between engine and spec shows up as a failure. +// Spelled out here rather than imported: `DeviceIdentity.PQDomain` is private, +// and a driver that reached into the engine's internals to build a bundle would +// be checking the engine against itself. private val PQ_SPK_DOMAIN = "FlamenetE2E_PQSPK_v2".toByteArray(Charsets.UTF_8) private val CAPS_DOMAIN = "FlamenetE2E_Caps_v2".toByteArray(Charsets.UTF_8) @@ -76,134 +58,217 @@ fun main() { val out = System.out.bufferedWriter() generateSequence(::readLine).forEach { line -> if (line.isBlank()) return@forEach - val reply = try { - handle(JSONObject(line)) - } catch (e: Throwable) { - JSONObject().put("error", "${e::class.simpleName}: ${e.message}") - } + val reply = + try { + handle(JSONObject(line)) + } catch (e: Throwable) { + JSONObject().put("error", "${e::class.simpleName}: ${e.message}") + } out.write(reply.toString()) out.newLine() out.flush() } } -private fun next(prefix: String): String { counter += 1; return "$prefix$counter" } - -/** - * Base64 here rather than through the engine's own `Bytes`, which is - * deliberately `internal`, the engine's public surface is the protocol, not - * its helpers, and widening it so a test tool can reach in would be the tail - * wagging the dog. Base64 is a transport encoding with one right answer, so a - * second call site cannot drift; the protocol-bearing encodings (the ratchet - * header, the canonical log leaf) still come from the engine. - */ -private fun b64(data: ByteArray): String = java.util.Base64.getEncoder().encodeToString(data) -private fun unb64(text: String): ByteArray = java.util.Base64.getDecoder().decode(text) - -private fun JSONObject.bytes(key: String): ByteArray = unb64(getString(key)) - -private fun handle(req: JSONObject): JSONObject = when (req.getString("cmd")) { - "device" -> newDevice(req) - "initiate" -> initiate(req) - "respond" -> respond(req) - "encrypt" -> encrypt(req) - "decrypt" -> decrypt(req) - // --- the session layer, which is what an app actually uses ------------ - "session_device" -> sessionDevice(req) - "session_initiate" -> sessionInitiate(req) - "session_respond" -> sessionRespond(req) - "session_encrypt" -> sessionEncrypt(req) - "session_decrypt" -> sessionDecrypt(req) - "session_reload" -> sessionReload(req) - "safety" -> JSONObject().put( - "number", SafetyNumber.compute(req.bytes("local"), req.bytes("remote"))) - "account_kdf" -> accountKdf(req) - "log_leaf" -> logLeaf(req) - "hkdf" -> JSONObject().put("okm", b64(Hkdf.derive( - req.bytes("ikm"), req.bytes("salt"), req.bytes("info"), req.getInt("length")))) - "aead_open" -> JSONObject().put("plaintext", String(Aead.open( - req.bytes("key"), req.bytes("box"), req.bytes("aad")), Charsets.UTF_8)) - "seal" -> sealSender(req) - "open_sealed" -> openSealed(req) - "label_encode" -> labelEncode(req) - "label_dominates" -> labelDominates(req) - "content_encode" -> contentEncode(req) - "content_parse" -> contentParse(req) - "verify" -> JSONObject().put("ok", Ed25519.verify( - req.bytes("public_key"), req.bytes("signature"), req.bytes("message"))) - else -> JSONObject().put("error", "unknown cmd ${req.getString("cmd")}") +private fun next(prefix: String): String { + counter += 1 + return "$prefix$counter" } +/** + * Base64 here rather than through the engine's own `Bytes`, which is + * deliberately `internal`, the engine's public surface is the protocol, not its + * helpers, and widening it so a test tool can reach in would be the tail + * wagging the dog. + */ +private fun b64(data: ByteArray): String = + java.util.Base64 + .getEncoder() + .encodeToString(data) + +private fun unb64(text: String): ByteArray = + java.util.Base64 + .getDecoder() + .decode(text) + +private fun JSONObject.bytes(key: String): ByteArray = unb64(getString(key)) + +private fun handle(req: JSONObject): JSONObject = + when (req.getString("cmd")) { + "device" -> { + newDevice(req) + } + + "initiate" -> { + initiate(req) + } + + "respond" -> { + respond(req) + } + + "encrypt" -> { + encrypt(req) + } + + "decrypt" -> { + decrypt(req) + } + + // --- the session layer, which is what an app actually uses ------------. + "session_device" -> { + sessionDevice(req) + } + + "session_initiate" -> { + sessionInitiate(req) + } + + "session_respond" -> { + sessionRespond(req) + } + + "session_encrypt" -> { + sessionEncrypt(req) + } + + "session_decrypt" -> { + sessionDecrypt(req) + } + + "session_reload" -> { + sessionReload(req) + } + + "safety" -> { + JSONObject().put("number", SafetyNumber.compute(req.bytes("local"), req.bytes("remote"))) + } + + "account_kdf" -> { + accountKdf(req) + } + + "log_leaf" -> { + logLeaf(req) + } + + "hkdf" -> { + JSONObject().put( + "okm", + b64( + Hkdf.derive(req.bytes("ikm"), req.bytes("salt"), req.bytes("info"), req.getInt("length")), + ), + ) + } + + "aead_open" -> { + JSONObject().put( + "plaintext", + String( + Aead.open(req.bytes("key"), req.bytes("box"), req.bytes("aad")), + Charsets.UTF_8, + ), + ) + } + + "seal" -> { + sealSender(req) + } + + "open_sealed" -> { + openSealed(req) + } + + "label_encode" -> { + labelEncode(req) + } + + "label_dominates" -> { + labelDominates(req) + } + + "content_encode" -> { + contentEncode(req) + } + + "content_parse" -> { + contentParse(req) + } + + "verify" -> { + JSONObject().put( + "ok", + Ed25519.verify(req.bytes("public_key"), req.bytes("signature"), req.bytes("message")), + ) + } + + else -> { + JSONObject().put("error", "unknown cmd ${req.getString("cmd")}") + } + } + /** * A device and the bundle a relay would serve for it. - * - * `pq` chooses what this bundle advertises: `proto` 3 with a signed ML-KEM - * prekey, or `proto` 1 with no PQ block at all. Both are shapes a real relay - * serves, the fleet still holds released builds that speak v1, and a driver - * that could only produce the newest one would stop covering the older paths on - * the day they matter most. - * - * This is the LOW-level path, wiring X3DH and the ratchet together by hand. - * The `session_*` commands go through `E2ESession` instead, which is what the - * app calls, and which owns the negotiation decision. Version numbers reported - * here are X3DH's; the app's envelope version comes from the session layer. */ private fun newDevice(req: JSONObject): JSONObject { // A v1 bundle is still a thing this engine must be able to publish, and - // still a thing the harness must be able to test against. The fleet holds - // released builds that advertise 1 and 2; if this driver could only speak - // v3, the legacy paths would stop being covered on the day v3 landed, - // which is the day they matter most. + // still a thing the harness must be able to test against. val postQuantum = req.optBoolean("pq", true) // A peer can be asked to advertise an OLDER version, and it has to be - // possible to ask. The standing rule is that the current version and the - // one before it interoperate, so when v4 lands, v3 and v4 must talk, and - // the only way to keep that honest is a harness that can stand up a peer - // on the previous version and hold a real conversation with it. Without - // this the suite can only ever test the newest code against itself, which - // is the shape of every compatibility bug that has ever shipped. + // possible to ask. val proto = req.optInt("proto", if (postQuantum) 3 else 1) val id = next("kotlin-device-") - val device = Device( - deviceId = id, - ikDH = X25519.generate(), - ikSig = Ed25519.generate(), - signedPreKeyId = 1, - signedPreKey = X25519.generate(), - oneTimePreKeyId = 7, - oneTimePreKey = X25519.generate(), - ) + val device = + Device( + deviceId = id, + ikDH = X25519.generate(), + ikSig = Ed25519.generate(), + signedPreKeyId = 1, + signedPreKey = X25519.generate(), + oneTimePreKeyId = 7, + oneTimePreKey = X25519.generate(), + ) devices[id] = device val spkRaw = X25519.rawFromPublicKey(device.signedPreKey.public) - val bundle = JSONObject() - .put("device_id", id) - .put("ik_dh", b64(X25519.rawFromPublicKey(device.ikDH.public))) - .put("ik_sig", b64(device.ikSig.publicKey)) - .put("spk_id", device.signedPreKeyId) - .put("spk", b64(spkRaw)) - .put("spk_sig", b64(Ed25519.sign(device.ikSig, spkRaw))) - .put("opk", JSONObject() - .put("id", device.oneTimePreKeyId) - .put("pub", b64(X25519.rawFromPublicKey(device.oneTimePreKey.public)))) - // §12.3/§12.5, the PQ signed prekey, under its OWN domain string. - // Signing the bare key would verify against nothing the peer computes: - // the web engine checks `Ed25519(ik_sig, "FlamenetE2E_PQSPK_v2" ‖ pub)`. - .putOpt("pqspk_id", if (postQuantum) device.pqSignedPreKeyId else null) - .putOpt("pqspk", if (postQuantum) b64(device.pqSignedPreKey.publicKey) else null) - .putOpt("pqspk_sig", if (postQuantum) b64(Ed25519.sign( - device.ikSig, PQ_SPK_DOMAIN + device.pqSignedPreKey.publicKey)) else null) - // §13.4, advertising a version is a SIGNED claim, not a number. - // Without `caps_sig` the web engine's `bundleFromWire` discards the - // whole v2 block and `negotiate` returns v1, which is exactly what this - // harness was quietly doing before. - // Signed over the version actually claimed. A signature over a - // different number is exactly what `provenVersion` exists to reject, - // and a harness that got this wrong would "prove" compatibility by - // silently falling back to v1 on every run. - .putOpt("caps_sig", if (postQuantum) b64(Ed25519.sign( - device.ikSig, CAPS_DOMAIN + byteArrayOf(proto.toByte()))) else null) - .put("proto", proto) + val bundle = + JSONObject() + .put("device_id", id) + .put("ik_dh", b64(X25519.rawFromPublicKey(device.ikDH.public))) + .put("ik_sig", b64(device.ikSig.publicKey)) + .put("spk_id", device.signedPreKeyId) + .put("spk", b64(spkRaw)) + .put("spk_sig", b64(Ed25519.sign(device.ikSig, spkRaw))) + .put( + "opk", + JSONObject() + .put("id", device.oneTimePreKeyId) + .put("pub", b64(X25519.rawFromPublicKey(device.oneTimePreKey.public))), + ) + // §12.3/§12.5, the PQ signed prekey, under its OWN domain string. + .putOpt("pqspk_id", if (postQuantum) device.pqSignedPreKeyId else null) + .putOpt("pqspk", if (postQuantum) b64(device.pqSignedPreKey.publicKey) else null) + .putOpt( + "pqspk_sig", + if (postQuantum) { + b64( + Ed25519.sign(device.ikSig, PQ_SPK_DOMAIN + device.pqSignedPreKey.publicKey), + ) + } else { + null + }, + ) + // §13.4, advertising a version is a SIGNED claim, not a number. + .putOpt( + "caps_sig", + if (postQuantum) { + b64( + Ed25519.sign(device.ikSig, CAPS_DOMAIN + byteArrayOf(proto.toByte())), + ) + } else { + null + }, + ).put("proto", proto) return JSONObject().put("device", id).put("bundle", bundle) } @@ -213,9 +278,7 @@ private fun initiate(req: JSONObject): JSONObject { val me = devices.getValue(req.getString("device")) val bundle = req.getJSONObject("bundle") - // Verify the peer's signed prekey before using it. A relay that can - // substitute an unsigned prekey can read everything, and the point of - // the bundle carrying a signature is that the client actually checks it. + // Verify the peer's signed prekey before using it. val spk = unb64(bundle.getString("spk")) val theirIkSig = unb64(bundle.getString("ik_sig")) if (!Ed25519.verify(theirIkSig, unb64(bundle.getString("spk_sig")), spk)) { @@ -225,8 +288,7 @@ private fun initiate(req: JSONObject): JSONObject { val opk = bundle.optJSONObject("opk") // §12.5, the peer's PQ prekey, verified against the same identity key that - // signed the classical one. An unverified PQ prekey is worth nothing: a - // relay that can substitute it chooses the secret. + // signed the classical one. val pqspk = bundle.optString("pqspk").ifEmpty { null }?.let(::unb64) if (pqspk != null) { val sig = bundle.optString("pqspk_sig").ifEmpty { null }?.let(::unb64) @@ -235,22 +297,22 @@ private fun initiate(req: JSONObject): JSONObject { } } - val initiated = X3DH.initiate( - identityPrivateDH = me.ikDH.private, - bundle = X3DH.Bundle( - identityKeyDH = unb64(bundle.getString("ik_dh")), - signedPreKey = spk, - oneTimePreKey = opk?.let { unb64(it.getString("pub")) }, - ), - bobPQ = pqspk, - ) + val initiated = + X3DH.initiate( + identityPrivateDH = me.ikDH.private, + bundle = + X3DH.Bundle( + identityKeyDH = unb64(bundle.getString("ik_dh")), + signedPreKey = spk, + oneTimePreKey = opk?.let { unb64(it.getString("pub")) }, + ), + bobPQ = pqspk, + ) // The AD binds the PQ ciphertext on v2+, so a substituted one fails the // AEAD open rather than silently deriving a different key. val ad = X3DH.associatedData(me.ikSig.publicKey, theirIkSig, initiated.pqCiphertext) - // `pqspk`, the SAME key X3DH just encapsulated to. The responder holds - // its seed, so this is the only value the first ratchet step can be - // decapsulated against (§13.2). + // `pqspk`, the SAME key X3DH just encapsulated to. val ratchet = DoubleRatchet.initiator(initiated.sharedSecret, spk, pqspk) val id = next("kotlin-session-") sessions[id] = Session(ratchet, ad) @@ -263,12 +325,15 @@ private fun initiate(req: JSONObject): JSONObject { .put("opk_id", opk?.getInt("id") ?: -1) .put("spk_id", bundle.optInt("spk_id", 0)) .put("version", initiated.version) - .put("pq", initiated.pqCiphertext?.let { - JSONObject().put("kind", "spk") - .put("id", bundle.optInt("pqspk_id", 0)) - .put("ct", b64(it)) - }) - .put("header", headerJson(message.header)) + .put( + "pq", + initiated.pqCiphertext?.let { + JSONObject() + .put("kind", "spk") + .put("id", bundle.optInt("pqspk_id", 0)) + .put("ct", b64(it)) + }, + ).put("header", headerJson(message.header)) .put("ct", b64(message.ciphertext)) } @@ -279,24 +344,22 @@ private fun respond(req: JSONObject): JSONObject { val theirIkDH = req.bytes("ik_dh") val theirIkSig = req.bytes("ik_sig") - // The PQ half, when the initiator sent one. `pq.ct` was encapsulated to our - // published PQ prekey, so the seed is what opens it. + // The PQ half, when the initiator sent one. val pqBlock = req.optJSONObject("pq") val pq = pqBlock?.let { me.pqSignedPreKey.seed to unb64(it.getString("ct")) } - val sharedSecret = X3DH.respond( - identityPrivateDH = me.ikDH.private, - signedPreKeyPrivate = me.signedPreKey.private, - oneTimePrivate = if (usedOpk) me.oneTimePreKey.private else null, - initiatorIdentityDH = theirIkDH, - initiatorEphemeral = req.bytes("ek"), - pq = pq, - ) + val sharedSecret = + X3DH.respond( + identityPrivateDH = me.ikDH.private, + signedPreKeyPrivate = me.signedPreKey.private, + oneTimePrivate = if (usedOpk) me.oneTimePreKey.private else null, + initiatorIdentityDH = theirIkDH, + initiatorEphemeral = req.bytes("ek"), + pq = pq, + ) val ad = X3DH.associatedData(theirIkSig, me.ikSig.publicKey, pq?.second) - // The seed, not the public key. The initiator encapsulated to our PQ - // prekey, so decapsulating the first ratchet step needs the private half, - // and `PQr` is adopted from that first header, not set here (§13.2). + // The seed, not the public key. val ratchet = DoubleRatchet.responder(sharedSecret, me.signedPreKey, pq?.first) val id = next("kotlin-session-") sessions[id] = Session(ratchet, ad) @@ -311,8 +374,8 @@ private fun respond(req: JSONObject): JSONObject { private fun encrypt(req: JSONObject): JSONObject { val session = sessions.getValue(req.getString("session")) - val message = session.ratchet.encrypt( - req.getString("text").toByteArray(Charsets.UTF_8), session.ad) + val message = + session.ratchet.encrypt(req.getString("text").toByteArray(Charsets.UTF_8), session.ad) return JSONObject() .put("header", headerJson(message.header)) .put("ct", b64(message.ciphertext)) @@ -325,32 +388,32 @@ private fun decrypt(req: JSONObject): JSONObject { } // §13.3, `pqdh`/`pqct` present only on a PQ step, omitted entirely otherwise. -// `JSONObject.put(String, null)` drops the key, which is the shape the canonical -// header needs. -private fun headerJson(header: DoubleRatchet.Header): JSONObject = JSONObject() - .put("dh", b64(header.dh)) - .put("pn", header.pn) - .put("n", header.n) - .put("pqdh", header.pqdh?.let(::b64)) - .put("pqct", header.pqct?.let(::b64)) +private fun headerJson(header: DoubleRatchet.Header): JSONObject = + JSONObject() + .put("dh", b64(header.dh)) + .put("pn", header.pn) + .put("n", header.n) + .put("pqdh", header.pqdh?.let(::b64)) + .put("pqct", header.pqct?.let(::b64)) private fun messageFrom(req: JSONObject): DoubleRatchet.Message { val header = req.getJSONObject("header") return DoubleRatchet.Message( - header = DoubleRatchet.Header( - dh = unb64(header.getString("dh")), - pn = header.getInt("pn"), - n = header.getInt("n"), - pqdh = header.optString("pqdh").ifEmpty { null }?.let(::unb64), - pqct = header.optString("pqct").ifEmpty { null }?.let(::unb64), - ), + header = + DoubleRatchet.Header( + dh = unb64(header.getString("dh")), + pn = header.getInt("pn"), + n = header.getInt("n"), + pqdh = header.optString("pqdh").ifEmpty { null }?.let(::unb64), + pqct = header.optString("pqct").ifEmpty { null }?.let(::unb64), + ), ciphertext = req.bytes("ct"), ) } // --------------------------------------------------------------------------- // Session layer -// --------------------------------------------------------------------------- +// ---------------------------------------------------------------------------. private val identities = HashMap() private val liveSessions = HashMap() @@ -360,24 +423,20 @@ private fun sessionDevice(req: JSONObject): JSONObject { val identity = DeviceIdentity.create() identities[identity.deviceId] = identity val oneTime = identity.generateOneTimePreKeys(1).first() - val wire = identity.registrationWire() - .put("opk", JSONObject().put("id", oneTime.id).put("pub", b64(oneTime.publicKey))) + val wire = + identity + .registrationWire() + .put("opk", JSONObject().put("id", oneTime.id).put("pub", b64(oneTime.publicKey))) // Stand up a peer on an OLDER version, for the compatibility rule. - // The engine publishes whatever it currently implements, which is right for - // a device and useless for testing the release before it, and the standing - // rule is that the current version and the one before it interoperate. - // - // The capability signature is REMADE over the number actually claimed. - // Leaving the original in place would advertise one version and sign - // another, which `provenVersion` rejects by falling back to v1, so the - // test would quietly pass while exercising a pairing nobody ships. - // Overriding lives here rather than in `registrationWire` on purpose: the - // engine should publish what it implements, not what a test wants. if (req.has("proto")) { val proto = req.getInt("proto") wire.put("proto", proto) - wire.put("caps_sig", b64(Ed25519.sign( - identity.identitySigning, CAPS_DOMAIN + byteArrayOf(proto.toByte())))) + wire.put( + "caps_sig", + b64( + Ed25519.sign(identity.identitySigning, CAPS_DOMAIN + byteArrayOf(proto.toByte())), + ), + ) } return JSONObject().put("device", identity.deviceId).put("bundle", wire) } @@ -390,10 +449,7 @@ private fun sessionInitiate(req: JSONObject): JSONObject { return JSONObject() .put("session", id) .put("ad", b64(session.associatedData)) - // The full envelope, exactly as it would go on the wire. This is the - // layer where the one bug this protocol has actually shipped lives: a - // client and a relay disagreeing about a field name, caught by nothing - // until a live round trip. + // The full envelope, exactly as it would go on the wire. .put("version", session.version) .put("envelope", session.encrypt(req.getString("text")).toJson()) } @@ -408,29 +464,22 @@ private fun sessionRespond(req: JSONObject): JSONObject { .put("session", id) .put("ad", b64(session.associatedData)) // What the session ANSWERED with, not what the harness assumes. - // Without this the interop test could not see the version a responder - // settled on, and a responder that capped itself below the initiator - // shipped: iOS ran the session as v3, this side as v2, and every - // message sent into it was refused by the peer as a downgrade. .put("version", session.version) .put("text", session.decrypt(envelope)) } /** * Seal an envelope for a device the web engine holds. - * - * The envelope is taken as-is from the request rather than produced here, so - * the two engines are compared on the SEAL and nothing else, a mismatch in the - * ratchet would otherwise show up as a sealed-sender failure. */ private fun sealSender(req: JSONObject): JSONObject { - val blob = SealedSender.seal( - envelope = Envelope.fromJson(req.getJSONObject("envelope")), - fromUser = req.getLong("from_user"), - fromDevice = req.getString("from_device"), - toDevice = req.getString("to_device"), - recipientIKdh = req.bytes("recipient_ik_dh"), - ) + val blob = + SealedSender.seal( + envelope = Envelope.fromJson(req.getJSONObject("envelope")), + fromUser = req.getLong("from_user"), + fromDevice = req.getString("from_device"), + toDevice = req.getString("to_device"), + recipientIKdh = req.bytes("recipient_ik_dh"), + ) return JSONObject().put("blob", blob.toJson()) } @@ -438,11 +487,12 @@ private fun sealSender(req: JSONObject): JSONObject { private fun openSealed(req: JSONObject): JSONObject { val identity = identities.getValue(req.getString("device")) val json = req.getJSONObject("blob") - val opened = SealedSender.open( - SealedSender.Blob(unb64(json.getString("eph")), unb64(json.getString("ct"))), - identity.identityDH.private, - identity.deviceId, - ) + val opened = + SealedSender.open( + SealedSender.Blob(unb64(json.getString("eph")), unb64(json.getString("ct"))), + identity.identityDH.private, + identity.deviceId, + ) return JSONObject() .put("from_user", opened.claimedFromUser) .put("from_device", opened.claimedFromDevice) @@ -456,27 +506,23 @@ private fun sessionEncrypt(req: JSONObject): JSONObject = .put("envelope", session.encrypt(req.getString("text")).toJson()) } -private fun sessionDecrypt(req: JSONObject): JSONObject = JSONObject() - .put("text", liveSessions.getValue(req.getString("session")) - .decrypt(Envelope.fromJson(req.getJSONObject("envelope")))) +private fun sessionDecrypt(req: JSONObject): JSONObject = + JSONObject() + .put( + "text", + liveSessions + .getValue(req.getString("session")) + .decrypt(Envelope.fromJson(req.getJSONObject("envelope"))), + ) /** * Archive a session and restore it, in place. - * - * Exposed to the harness so the persistence path is exercised by a real - * cross-engine conversation rather than only by a Kotlin test talking to - * itself. A session that survives a restart against our own bytes but not - * against the peer's is a distinction the user experiences. */ private fun sessionReload(req: JSONObject): JSONObject { val id = req.getString("session") val archived = liveSessions.getValue(id).archive() - // `as_version` exists to reproduce a phone that stored a session - // BEFORE the responder fix, and it is the only way to test the upgrade. - // The version is persisted, so a session written by the old build restores - // as v2 no matter how new the code reading it is, updating the app does - // NOT repair a conversation already on disk. That population is every - // existing user, and nothing else in this harness can produce them. + // `as_version` exists to reproduce a phone that stored a session BEFORE the + // responder fix, and it is the only way to test the upgrade. val forced = JSONObject(archived.toString()) if (req.has("as_version")) forced.put("version", req.getInt("as_version")) liveSessions[id] = E2ESession.fromArchive(forced) @@ -495,42 +541,27 @@ private fun accountKdf(req: JSONObject): JSONObject { .put("identity", b64(AccountKdf.identityKey(passphrase, salt))) .put("account_seed", b64(seed)) // The address a user would actually see, so a divergence anywhere in - // the chain. PBKDF2, HKDF, the context string, Ed25519 keygen, - // surfaces as the wrong address rather than as the wrong bytes. + // the chain. .put("account_public", b64(Ed25519.fromSeed(seed).publicKey)) } private fun logLeaf(req: JSONObject): JSONObject { - val leaf = KeyLog.canonicalLeaf( - kind = req.getString("kind"), - subject = req.getString("subject"), - deviceId = req.getString("device_id"), - ikSigB64 = req.getString("ik_sig"), - ikDhB64 = req.getString("ik_dh"), - ) + val leaf = + KeyLog.canonicalLeaf( + kind = req.getString("kind"), + subject = req.getString("subject"), + deviceId = req.getString("device_id"), + ikSigB64 = req.getString("ik_sig"), + ikDhB64 = req.getString("ik_dh"), + ) return JSONObject() .put("leaf", String(leaf, Charsets.UTF_8)) .put("hash", b64(KeyLog.leafHash(leaf))) } // --------------------------------------------------------------------------- -// §6.6–§6.27, the content envelope -// -// This layer had no cross-language check at all until now, and it is -// where every silent divergence has actually happened. The ratchet is -// covered from three directions; the plaintext layer that rides inside it was -// covered by each engine parsing what it had written itself. SPEC.md §6.10 -// says so in as many words, after the `ctl:true` bug, and calls the interop -// harness "the standing argument". The Swift driver grew `content_encode` / -// `content_parse` for that reason. This is the other half. -// -// Android is the half that most needed it. This engine reads field by -// field through `org.json`, whose bundled Android build coerces a JSON null -// to the *string* `"null"` where Maven's returns `""`, while iOS decodes -// all-or-nothing through `Codable`, so the same additive change degrades -// differently on each platform. That asymmetry is invisible to any test that -// only round-trips one codec. -// --------------------------------------------------------------------------- +// §6.6–§6.27, the content envelope This layer had no cross-language check at +// all until now, and it is where every silent divergence has actually happened. private fun reference(req: JSONObject): Content.Reference? { val re = req.optJSONObject("re") ?: return null @@ -548,34 +579,33 @@ private fun contentEncode(req: JSONObject): JSONObject { val mid = req.optString("mid", "").ifEmpty { null } val reaction = req.optString("reaction", "").ifEmpty { null } - // A reaction is checked before text, and needs a target. The Swift - // driver orders it the same way; a divergence here would show up as one - // engine emitting a text envelope where the other emitted a react, which - // reads downstream as a duplicate bubble rather than as an error. + // A reaction is checked before text, and needs a target. if (reaction != null && about != null) { return JSONObject().put("wire", Content.reaction(reaction, about, gid)) } - val mentions = req.optJSONArray("mentions")?.let { array -> - (0 until array.length()).map { array.getInt(it) } - } ?: emptyList() + val mentions = + req.optJSONArray("mentions")?.let { array -> + (0 until array.length()).map { array.getInt(it) } + } ?: emptyList() - val wire = if (gid != null) { - Content.groupText(req.optString("text", ""), gid, messageId = mid, - about = about, mentions = mentions) - } else { - Content.text(req.optString("text", ""), messageId = mid, about = about) - } + val wire = + if (gid != null) { + Content.groupText( + req.optString("text", ""), + gid, + messageId = mid, + about = about, + mentions = mentions, + ) + } else { + Content.text(req.optString("text", ""), messageId = mid, about = about) + } return JSONObject().put("wire", wire) } /** - * Parse one, and report what the app would render. Mirrors `cmdContentParse`. - * - * Reports only what the Swift side also reports, plus mentions. A field - * present here and absent there cannot be compared, and a comparison that - * silently skips a field is the shape of guard this whole exercise exists to - * replace. + * Parse one, and report what the app would render. */ private fun contentParse(req: JSONObject): JSONObject { val parsed = Content.parse(req.getString("wire")) @@ -584,17 +614,19 @@ private fun contentParse(req: JSONObject): JSONObject { parsed.reaction?.let { out.put("reaction", it) } parsed.groupId?.let { out.put("gid", it) } parsed.about?.let { - out.put("re", JSONObject() - .put("mid", it.messageId).put("author", it.author).put("preview", it.preview)) + out.put( + "re", + JSONObject() + .put("mid", it.messageId) + .put("author", it.author) + .put("preview", it.preview), + ) } if (parsed.mentions.isNotEmpty()) { out.put("mentions", org.json.JSONArray(parsed.mentions)) } - // The field the `ctl:true` bug turned on. §6.10 requires a receiver - // to consume ANY envelope carrying `ctl` silently, known kind or not. - // Reported so the harness can check the rule ACROSS engines, this engine - // wrote `"ctl":true` until 2026-08-30 and iOS decoded it as a number, and - // each side testing itself is precisely what missed that. + // The field the `ctl:true` bug turned on. §6.10 requires a receiver to + // consume ANY envelope carrying `ctl` silently, known kind or not. out.put("control", parsed.isControl) return out } @@ -602,23 +634,13 @@ private fun contentParse(req: JSONObject): JSONObject { // --------------------------------------------------------------------------- // The security lattice, §1 of the Files specification, and the core of // relay#49's "labels and security levels are to be kept". -// -// The ten-byte encoding is authenticated data for every construction that -// uses a label. An encoding that differs between platforms does not warn: it -// produces a decryption failure, or, worse, plaintext that quietly belongs to -// the wrong sensitivity. Both engines have implemented this independently since -// the Files apps shipped, and nothing has ever compared them. -// -// Each side's own unit tests pin their vectors, which is necessary and not -// sufficient. Two engines that each pass their own vectors can still disagree, -// and the disagreement here is silent by construction. -// --------------------------------------------------------------------------- private fun labelFrom(json: JSONObject?): Label { val o = json ?: JSONObject() - val bits = o.optJSONArray("compartments")?.let { a -> - (0 until a.length()).map { a.getInt(it) } - } ?: emptyList() + val bits = + o.optJSONArray("compartments")?.let { a -> + (0 until a.length()).map { a.getInt(it) } + } ?: emptyList() return Label(o.optInt("classification", 0).toShort(), bits.toSet()) } @@ -626,13 +648,12 @@ private fun labelEncode(req: JSONObject): JSONObject { val label = labelFrom(req) return JSONObject() .put("hex", label.bytes().joinToString("") { "%02x".format(it) }) - // As a STRING. Bit 63 makes the mask a negative Long, and a JSON - // number would arrive on the other side as a double and lose the low - // bits, turning the one compartment most likely to be mis-shifted - // into the one this check cannot see. + // As a STRING. .put("mask", label.mask.toULong().toString()) } private fun labelDominates(req: JSONObject): JSONObject = - JSONObject().put("dominates", - labelFrom(req.optJSONObject("a")).dominates(labelFrom(req.optJSONObject("b")))) + JSONObject().put( + "dominates", + labelFrom(req.optJSONObject("a")).dominates(labelFrom(req.optJSONObject("b"))), + ) diff --git a/interop/src/main/kotlin/io/flamenet/interop/Peer.kt b/interop/src/main/kotlin/io/flamenet/interop/Peer.kt index 0683873..35e0d39 100644 --- a/interop/src/main/kotlin/io/flamenet/interop/Peer.kt +++ b/interop/src/main/kotlin/io/flamenet/interop/Peer.kt @@ -14,34 +14,30 @@ import javax.crypto.spec.SecretKeySpec /** * A Flamenet account you can drive from a shell. - * - * The other end of a conversation, for testing the app against something that - * is not itself. Two emulators talking proves the app agrees with the app; a - * peer built from `:engine` and `:client` proves rather less than the JS - * interop harness does about the protocol, but it is the only way to watch a - * message arrive in the app's own UI from somewhere else. - * - * State persists to a file so a peer survives between commands, otherwise - * every invocation is a new account and nothing can ever reply. - * - * peer create [handle] - * peer add - * peer send - * peer poll - * peer whoami - * - * Not a secure store. The state file holds the vault key and the account - * credential in the clear, because a test peer that needed a keystore would - * need a phone. Point it at a throwaway relay and nothing else. */ object Peer { + private class FileStorage( + private val root: File, + ) : Vault.Storage { + init { + root.mkdirs() + } - private class FileStorage(private val root: File) : Vault.Storage { - init { root.mkdirs() } private fun file(name: String) = File(root, name.replace('/', '_')) + override fun read(name: String) = file(name).takeIf { it.exists() }?.readBytes() - override fun write(name: String, bytes: ByteArray) { file(name).writeBytes(bytes) } - override fun delete(name: String) { file(name).delete() } + + override fun write( + name: String, + bytes: ByteArray, + ) { + file(name).writeBytes(bytes) + } + + override fun delete(name: String) { + file(name).delete() + } + override fun names() = root.listFiles().orEmpty().map { it.name.replaceFirst('_', '/') } } @@ -60,23 +56,30 @@ object Peer { } val state = JSONObject(stateFile.readText()) - val vault = Vault( - FileStorage(File(stateFile.absolutePath + ".vault")), - key(state.getString("vaultKey")), - ) - val identity = vault.loadIdentity() ?: error("no device identity in ${stateFile}.vault") + val vault = + Vault( + FileStorage(File(stateFile.absolutePath + ".vault")), + key(state.getString("vaultKey")), + ) + val identity = vault.loadIdentity() ?: error("no device identity in $stateFile.vault") val base = state.getString("relay") - val relay = RelayClient(base, state.getString("username"), state.getString("appPassword")) - .also { it.useRelayAuth(identity) } + val relay = + RelayClient(base, state.getString("username"), state.getString("appPassword")) + .also { it.useRelayAuth(identity) } val api = MessengerApi(base, state.getString("username"), state.getString("appPassword")) - val conversations = Conversations(identity, vault, relay) - .also { it.myUserId = state.getLong("userId") } + val conversations = + Conversations(identity, vault, relay) + .also { it.myUserId = state.getLong("userId") } when (command) { - "whoami" -> println(JSONObject() - .put("handle", state.getString("username")) - .put("user_id", state.getLong("userId")) - .put("device", identity.deviceId)) + "whoami" -> { + println( + JSONObject() + .put("handle", state.getString("username")) + .put("user_id", state.getLong("userId")) + .put("device", identity.deviceId), + ) + } "add" -> { api.addBuddy(args[2]) @@ -84,34 +87,34 @@ object Peer { } "send" -> { - val target = api.buddies().firstOrNull { it.displayName == args[2] } - ?: api.buddies().firstOrNull { it.userId.toString() == args[2] } - ?: error("no contact '${args[2]}', add them first, and check the name matches") + val target = + api.buddies().firstOrNull { it.displayName == args[2] } + ?: api.buddies().firstOrNull { it.userId.toString() == args[2] } + ?: error("no contact '${args[2]}', add them first, and check the name matches") val sent = conversations.send(target.userId, args.drop(3).joinToString(" ")) - println(JSONObject() - .put("delivered", sent.delivered.size) - .put("failed", sent.failed.size) - .put("to", target.userId)) + println( + JSONObject() + .put("delivered", sent.delivered.size) + .put("failed", sent.failed.size) + .put("to", target.userId), + ) } "poll" -> { val received = conversations.poll() val out = org.json.JSONArray() received.forEach { - out.put(JSONObject() - .put("from_user", it.fromUser) - .put("text", it.text)) + out.put( + JSONObject() + .put("from_user", it.fromUser) + .put("text", it.text), + ) } println(JSONObject().put("messages", out)) } /** * Poll for a while and echo everything back. - * - * Echoes to the sender, not to a configured contact. A test - * peer that replies to whoever it was told about would pass while - * the from-user field was wrong, which is exactly the field a - * round-trip test exists to check. */ "autoreply" -> { val seconds = args.getOrNull(2)?.toIntOrNull() ?: 60 @@ -128,16 +131,27 @@ object Peer { println(JSONObject().put("echoed", replied)) } - else -> error("unknown command '$command'") + else -> { + error("unknown command '$command'") + } } } - private fun create(stateFile: File, base: String, handle: String?) { + private fun create( + stateFile: File, + base: String, + handle: String?, + ) { val name = handle ?: ("peer" + (System.currentTimeMillis() % 100_000_000)) val password = "a-long-enough-passphrase" val account = RelayAccounts(base).signUp(name, "$name@example.invalid", password) - val raw = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey().encoded + val raw = + KeyGenerator + .getInstance("AES") + .apply { init(256) } + .generateKey() + .encoded val vault = Vault(FileStorage(File(stateFile.absolutePath + ".vault")), key(raw)) val identity = DeviceIdentity.create() vault.saveIdentity(identity) @@ -147,21 +161,35 @@ object Peer { relay.registerDevice(identity, identity.generateOneTimePreKeys(20)) vault.saveIdentity(identity) - stateFile.writeText(JSONObject() - .put("relay", base) - .put("username", account.username) - .put("appPassword", account.appPassword) - .put("userId", account.userId) - .put("vaultKey", java.util.Base64.getEncoder().encodeToString(raw)) - .toString(2)) + stateFile.writeText( + JSONObject() + .put("relay", base) + .put("username", account.username) + .put("appPassword", account.appPassword) + .put("userId", account.userId) + .put( + "vaultKey", + java.util.Base64 + .getEncoder() + .encodeToString(raw), + ).toString(2), + ) - println(JSONObject() - .put("handle", account.username) - .put("user_id", account.userId) - .put("device", identity.deviceId) - .put("password", password)) + println( + JSONObject() + .put("handle", account.username) + .put("user_id", account.userId) + .put("device", identity.deviceId) + .put("password", password), + ) } - private fun key(encoded: String): SecretKey = key(java.util.Base64.getDecoder().decode(encoded)) + private fun key(encoded: String): SecretKey = + key( + java.util.Base64 + .getDecoder() + .decode(encoded), + ) + private fun key(raw: ByteArray): SecretKey = SecretKeySpec(raw, "AES") } diff --git a/obfs4/src/main/kotlin/io/flamenet/obfs4/BridgeLine.kt b/obfs4/src/main/kotlin/io/flamenet/obfs4/BridgeLine.kt index 3eee982..e07da20 100644 --- a/obfs4/src/main/kotlin/io/flamenet/obfs4/BridgeLine.kt +++ b/obfs4/src/main/kotlin/io/flamenet/obfs4/BridgeLine.kt @@ -2,29 +2,10 @@ package io.flamenet.obfs4 /** * Reading a bridge line the way a person actually pastes one. - * - * The reason this exists: a wrong line used to be indistinguishable from - * no line. Nothing carried a bridge at all on Android, every client took the - * default `null`, and the moment one does, the failure mode arrives with it: - * a line that will not parse becomes "no bridge", and "no bridge" is a DIRECT - * connection. That puts the one person who pasted a bridge because direct does - * not work back on the exact path they were trying to leave. - * - * The messages here are the same sentences iOS shows. They are a pair on - * purpose, the two apps are handed the same lines by the same operators, and - * somebody comparing two phones, or one support answer covering both, should - * not meet two different vocabularies for one mistake. `Obfs4Settings.Problem` - * in the iOS repo is the other half. */ sealed interface BridgeLineProblem { - /** * Written for the person who pasted the line, not for us. - * - * Never names obfs4, cert, base64 or a node id. Somebody reaching - * this screen was handed a line by an operator and told to paste it; the - * vocabulary of the protocol is not theirs, and an error written in it - * reads as "you are not qualified to fix this". */ val message: String @@ -38,7 +19,9 @@ sealed interface BridgeLineProblem { "start, like 198.51.100.7:4444." } - data class BadPort(val text: String) : BridgeLineProblem { + data class BadPort( + val text: String, + ) : BridgeLineProblem { override val message = "\"$text\" isn't a port number. It should be digits, after the colon " + "in the address." @@ -58,13 +41,10 @@ sealed interface BridgeLineProblem { /** * The decoded `cert=`, which must be 52 bytes: 20 of node id and 32 of key. - * - * The likeliest real failure by a distance, and the one worth spending - * words on. A bridge line is long, and every chat app and terminal wraps - * it, so a paste that stops at the wrap is the ordinary mistake, not an - * exotic one, and it must not be reported as gibberish. */ - data class CertWrongLength(val got: Int) : BridgeLineProblem { + data class CertWrongLength( + val got: Int, + ) : BridgeLineProblem { override val message = if (got < CERT_BYTES) { "This line looks cut short. They're long enough that most apps wrap them " + @@ -80,37 +60,35 @@ sealed interface BridgeLineProblem { /** What reading a line produced. */ sealed interface BridgeLineResult { /** - * @param caveat something true about a line that WORKS. Kept apart from - * [BridgeLineProblem] deliberately: reporting it as an error would push - * somebody to go and find a different bridge they do not have, to fix - * something that is not broken. + * @param caveat something true about a line that WORKS. */ - data class Ok(val bridge: Obfs4Bridge, val caveat: String? = null) : BridgeLineResult - data class Bad(val problem: BridgeLineProblem) : BridgeLineResult + data class Ok( + val bridge: Obfs4Bridge, + val caveat: String? = null, + ) : BridgeLineResult + + data class Bad( + val problem: BridgeLineProblem, + ) : BridgeLineResult } /** 20 bytes of node id followed by a 32-byte identity key. */ private const val CERT_BYTES = 52 object BridgeLine { - /** * Reads a bridge line, saying what is wrong when it cannot. - * - * Tolerant of the leading `obfs4` and of the fingerprint, both of which - * lyrebird includes and neither of which this client needs, the `cert=` - * carries the node id and key. Unknown `key=value` fields are ignored, since - * real lines carry several. */ fun parse(line: String): BridgeLineResult { val fields = line.trim().split(Regex("\\s+")).filter { it.isNotEmpty() } if (fields.isEmpty()) return BridgeLineResult.Bad(BridgeLineProblem.Empty) - val address = fields.firstOrNull { it.contains(":") && !it.contains("=") } - ?: return BridgeLineResult.Bad(BridgeLineProblem.NoAddress) + val address = + fields.firstOrNull { it.contains(":") && !it.contains("=") } + ?: return BridgeLineResult.Bad(BridgeLineProblem.NoAddress) - // Split from the LAST colon, so a bracketed IPv6 literal keeps its - // own colons and only the port comes off the end. + // Split from the LAST colon, so a bracketed IPv6 literal keeps its own + // colons and only the port comes off the end. val colon = address.lastIndexOf(':') val host = address.substring(0, colon) val portText = address.substring(colon + 1) @@ -120,12 +98,15 @@ object BridgeLine { return BridgeLineResult.Bad(BridgeLineProblem.BadPort(portText)) } - val cert = fields.firstOrNull { it.startsWith("cert=") } - ?.removePrefix("cert=") - ?: return BridgeLineResult.Bad(BridgeLineProblem.NoCert) + val cert = + fields + .firstOrNull { it.startsWith("cert=") } + ?.removePrefix("cert=") + ?: return BridgeLineResult.Bad(BridgeLineProblem.NoCert) - val raw = decodeCert(cert) - ?: return BridgeLineResult.Bad(BridgeLineProblem.CertNotReadable) + val raw = + decodeCert(cert) + ?: return BridgeLineResult.Bad(BridgeLineProblem.CertNotReadable) if (raw.size != CERT_BYTES) { return BridgeLineResult.Bad(BridgeLineProblem.CertWrongLength(raw.size)) } @@ -138,29 +119,22 @@ object BridgeLine { /** * Decodes the bare string, never `cert + "=="`. - * - * Java's decoder is strict about padding where Foundation's is not, and the - * difference lands precisely on the mistake people actually make. Appending - * `==` to a truncated cert throws for most cut positions, 20, 29, 30, 50 - * and 51 bytes among them, so the same short paste that iOS reports as - * "looks cut short" would have come back here as unreadable gibberish. The - * bare form decodes at every length, which lets the length check below give - * the honest answer on both platforms. */ - private fun decodeCert(cert: String): ByteArray? = runCatching { - java.util.Base64.getDecoder().decode(cert.trimEnd('=')) - }.getOrNull() + private fun decodeCert(cert: String): ByteArray? = + runCatching { + java.util.Base64 + .getDecoder() + .decode(cert.trimEnd('=')) + }.getOrNull() /** - * An unsupported `iat-mode` is a caveat, not a rejection. Modes 1 and - * 2 ask the CLIENT to space its writes out; the bridge does not enforce - * them, so such a line connects and works, it simply does not get the - * timing obfuscation its operator intended. Refusing it would take a working - * bridge away from somebody who has no other one. + * An unsupported `iat-mode` is a caveat, not a rejection. */ private fun caveatFor(fields: List): String? { - val mode = fields.firstOrNull { it.startsWith("iat-mode=") } - ?.removePrefix("iat-mode=") ?: return null + val mode = + fields + .firstOrNull { it.startsWith("iat-mode=") } + ?.removePrefix("iat-mode=") ?: return null if (mode == "0") return null return "This bridge asks for extra timing camouflage (iat-mode $mode) that this " + "app doesn't do yet. It will still connect, and the traffic still doesn't " + diff --git a/obfs4/src/main/kotlin/io/flamenet/obfs4/Connection.kt b/obfs4/src/main/kotlin/io/flamenet/obfs4/Connection.kt index 2d5cdc3..133cbf2 100644 --- a/obfs4/src/main/kotlin/io/flamenet/obfs4/Connection.kt +++ b/obfs4/src/main/kotlin/io/flamenet/obfs4/Connection.kt @@ -1,16 +1,9 @@ package io.flamenet.obfs4 /** - * The packet layer, which sits inside frames. - * - * uint8 type 0 = payload, 1 = PRNG seed - * uint16 length payload length, big endian - * uint8[] payload - * uint8[] padding zeros, ignored - * - * Padding lives here, not in the frame. A frame's length is whatever it - * is; the packet declares how much of it is real. That is what lets obfs4 make - * every write an arbitrary size on the wire without the peer having to care. + * The packet layer, which sits inside frames. uint8 type 0 = payload, 1 = PRNG + * seed uint16 length payload length, big endian uint8[] payload uint8[] padding + * zeros, ignored Padding lives here, not in the frame. */ object Obfs4Packet { const val OVERHEAD = 3 @@ -19,17 +12,27 @@ object Obfs4Packet { const val TYPE_PAYLOAD = 0 const val TYPE_PRNG_SEED = 1 - class TooShort(val size: Int) : Exception("packet too short: $size") - /** - * A declared length longer than the packet. Verified in the Swift port: - * deleting this check turns one malformed packet into an out-of-bounds - * crash, a remote denial of service costing an attacker three bytes. - */ - class DeclaredLengthExceedsPacket(val declared: Int, val available: Int) : - Exception("declared $declared but only $available available") - class PayloadTooLong(val size: Int) : Exception("payload too long: $size") + class TooShort( + val size: Int, + ) : Exception("packet too short: $size") - fun encode(type: Int, payload: ByteArray, padding: Int): ByteArray { + /** + * A declared length longer than the packet. + */ + class DeclaredLengthExceedsPacket( + val declared: Int, + val available: Int, + ) : Exception("declared $declared but only $available available") + + class PayloadTooLong( + val size: Int, + ) : Exception("payload too long: $size") + + fun encode( + type: Int, + payload: ByteArray, + padding: Int, + ): ByteArray { if (payload.size + padding > MAX_PAYLOAD_LENGTH) { throw PayloadTooLong(payload.size + padding) } @@ -38,44 +41,38 @@ object Obfs4Packet { } /** - * An unknown `type` is ignored, not rejected, the reference does this - * so the protocol can gain packet types without breaking older peers. A - * parser that errored here would fail against a future bridge. - * - * Returns `null` as the type when it is unknown. + * An unknown `type` is ignored, not rejected, the reference does this so + * the protocol can gain packet types without breaking older peers. */ fun decode(packet: ByteArray): Pair { if (packet.size < OVERHEAD) throw TooShort(packet.size) val declared = ((packet[1].toInt() and 0xff) shl 8) or (packet[2].toInt() and 0xff) val available = packet.size - OVERHEAD if (declared > available) throw DeclaredLengthExceedsPacket(declared, available) - val type = when (packet[0].toInt() and 0xff) { - TYPE_PAYLOAD -> TYPE_PAYLOAD - TYPE_PRNG_SEED -> TYPE_PRNG_SEED - else -> null - } + val type = + when (packet[0].toInt() and 0xff) { + TYPE_PAYLOAD -> TYPE_PAYLOAD + TYPE_PRNG_SEED -> TYPE_PRNG_SEED + else -> null + } return type to packet.copyOfRange(OVERHEAD, OVERHEAD + declared) } } /** * Rejects handshakes that have been seen before. - * - * Anti-replay is probe resistance, not just an integrity check. Without - * it a scanner can record one real client's handshake and replay it to confirm - * a bridge is there, the server would answer, which is the one thing it must - * never do for anyone who cannot construct a fresh valid handshake. - * - * Share ONE of these across all connections. A replay is by definition a - * handshake seen on a *different* connection; a per-connection filter catches - * nothing and leaves the probe it exists to stop wide open. */ -class ReplayFilter(private val lifetimeMillis: Long = 3_600_000) { +class ReplayFilter( + private val lifetimeMillis: Long = 3_600_000, +) { private val seen = HashMap() /** `true` means this MAC has been seen, reject. */ @Synchronized - fun testAndSet(mac: ByteArray, nowMillis: Long = System.currentTimeMillis()): Boolean { + fun testAndSet( + mac: ByteArray, + nowMillis: Long = System.currentTimeMillis(), + ): Boolean { if (seen.size > 1000) seen.entries.removeIf { nowMillis - it.value >= lifetimeMillis } val key = mac.joinToString("") { "%02x".format(it) } val first = seen[key] @@ -89,14 +86,11 @@ class ReplayFilter(private val lifetimeMillis: Long = 3_600_000) { object Obfs4Session { /** * `Kdf(KEY_SEED, 144)`, a framing key for each direction. - * - * The halves swap by role. The client encodes with the first and - * decodes with the second; the server does the reverse. Getting this - * backwards produces two ends that each work perfectly alone and cannot hear - * each other, and only a client-to-server test catches it, never a - * client-only one. */ - fun directionKeys(keySeed: ByteArray, isClient: Boolean): Pair { + fun directionKeys( + keySeed: ByteArray, + isClient: Boolean, + ): Pair { val okm = Ntor.kdf(keySeed, FrameEncoder.KEY_LENGTH * 2) val first = okm.copyOfRange(0, FrameEncoder.KEY_LENGTH) val second = okm.copyOfRange(FrameEncoder.KEY_LENGTH, okm.size) @@ -105,7 +99,10 @@ object Obfs4Session { } /** The data plane: packets in frames, both directions. */ -class Obfs4DataPlane(encodeKey: ByteArray, decodeKey: ByteArray) { +class Obfs4DataPlane( + encodeKey: ByteArray, + decodeKey: ByteArray, +) { private val encoder = FrameEncoder(encodeKey) private val decoder = FrameDecoder(decodeKey) private val inbound = mutableListOf() @@ -115,57 +112,74 @@ class Obfs4DataPlane(encodeKey: ByteArray, decodeKey: ByteArray) { private set /** - * Payloads are split, not truncated. A write larger than one packet - * spans several; a caller that silently dropped the remainder would corrupt - * the stream in a way only the far end notices. + * Payloads are split, not truncated. */ - fun send(payload: ByteArray, padding: Int = 0): ByteArray { + fun send( + payload: ByteArray, + padding: Int = 0, + ): ByteArray { var out = ByteArray(0) var offset = 0 do { val room = Obfs4Packet.MAX_PAYLOAD_LENGTH - padding val n = minOf(room, payload.size - offset) - out += encoder.encode( - Obfs4Packet.encode(Obfs4Packet.TYPE_PAYLOAD, - payload.copyOfRange(offset, offset + n), padding)) + out += + encoder.encode( + Obfs4Packet.encode( + Obfs4Packet.TYPE_PAYLOAD, + payload.copyOfRange(offset, offset + n), + padding, + ), + ) offset += n } while (offset < payload.size) return out } /** Padding-only traffic, for cover when there is nothing to say. */ - fun sendPadding(length: Int): ByteArray = - encoder.encode(Obfs4Packet.encode(Obfs4Packet.TYPE_PAYLOAD, ByteArray(0), length)) + fun sendPadding(length: Int): ByteArray = encoder.encode(Obfs4Packet.encode(Obfs4Packet.TYPE_PAYLOAD, ByteArray(0), length)) /** The server's opening move: hand the client a seed so both pad alike. */ - fun sendPrngSeed(seed: ByteArray, padding: Int = 0): ByteArray { + fun sendPrngSeed( + seed: ByteArray, + padding: Int = 0, + ): ByteArray { require(seed.size == 24) { "a DRBG seed is 24 bytes" } return encoder.encode(Obfs4Packet.encode(Obfs4Packet.TYPE_PRNG_SEED, seed, padding)) } /** * Feed everything the socket gave you; get back the application data in it. - * - * Frames arrive split across reads and several to a read, so the buffer - * is retained between calls and "need more data" is a normal outcome. */ - fun receive(bytes: ByteArray, isClient: Boolean = true): ByteArray { + fun receive( + bytes: ByteArray, + isClient: Boolean = true, + ): ByteArray { inbound.addAll(bytes.toList()) var application = ByteArray(0) while (true) { - val packet = try { decoder.decode(inbound) } - catch (_: FrameDecoder.NeedMoreData) { break } + val packet = + try { + decoder.decode(inbound) + } catch (_: FrameDecoder.NeedMoreData) { + break + } val (type, payload) = Obfs4Packet.decode(packet) when (type) { - Obfs4Packet.TYPE_PAYLOAD -> application += payload - // Only a client adopts a seed. A server that honoured one - // would let any peer dictate its padding, which is a channel a - // censor could use to make traffic recognisable. - Obfs4Packet.TYPE_PRNG_SEED -> + Obfs4Packet.TYPE_PAYLOAD -> { + application += payload + } + + // Only a client adopts a seed. + Obfs4Packet.TYPE_PRNG_SEED -> { if (isClient && payload.size == 24) { lengthDist = WeightedDist(payload, 0, FrameEncoder.MAX_SEGMENT_LENGTH, true) } - else -> Unit // unknown type: ignored by design + } + + else -> { + Unit + } // unknown type: ignored by design } } return application @@ -185,7 +199,9 @@ class Obfs4ClientHandshake( private var buffer = ByteArray(0) class InvalidHandshake : Exception("the bridge's handshake did not verify") + class AuthMismatch : Exception("ntor AUTH mismatch, this is not the bridge we expect") + class NtorFailed : Exception("ntor handshake failure") /** @@ -200,39 +216,37 @@ class Obfs4ClientHandshake( return Obfs4Handshake.clientMessage(sessionRepresentative, padding, macKey, epochHour) } - class Established(val keySeed: ByteArray, val leftover: ByteArray) + class Established( + val keySeed: ByteArray, + val leftover: ByteArray, + ) /** * `null` means keep reading and stay silent. - * - * Returns the leftover BYTES, not an offset. An offset would be an - * index into the buffer accumulated across *all* calls, which a caller - * feeding one socket read at a time does not have, the Swift port crashed - * on exactly that the first time a server handshake spanned two reads, - * which is routine over a real network and almost never happens over - * loopback. - * - * Those bytes are already framed traffic, usually the bridge's PRNG seed - * riding in the same segment. Dropping them desynchronises the decoder. */ fun receive(bytes: ByteArray): Established? { buffer += bytes - val parsed = try { - Obfs4Handshake.parseServerMessage(buffer, macKey, epochHour) - } catch (_: Obfs4Handshake.NeedMoreData) { - return null - } catch (e: Exception) { - throw InvalidHandshake() - } + val parsed = + try { + Obfs4Handshake.parseServerMessage(buffer, macKey, epochHour) + } catch (_: Obfs4Handshake.NeedMoreData) { + return null + } catch (e: Exception) { + throw InvalidHandshake() + } val serverPublic = Elligator2.publicKeyFromRepresentative(parsed.representative) - val result = Ntor.clientHandshake( - sessionPrivate, sessionPublic, serverPublic, serverIdentityPublic, nodeId, - ) ?: throw NtorFailed() + val result = + Ntor.clientHandshake( + sessionPrivate, + sessionPublic, + serverPublic, + serverIdentityPublic, + nodeId, + ) ?: throw NtorFailed() - // Constant-time, and non-negotiable: this is what proves the peer - // holds the bridge's identity key rather than merely having seen a - // handshake. + // Constant-time, and non-negotiable: this is what proves the peer holds + // the bridge's identity key rather than merely having seen a handshake. if (!Ntor.authMatches(result.auth, parsed.auth)) throw AuthMismatch() return Established(result.keySeed, buffer.copyOfRange(parsed.consumed, buffer.size)) } @@ -240,12 +254,6 @@ class Obfs4ClientHandshake( /** * Drives a bridge from "a client connected" to "keys established". - * - * Every failure here must be silent to the peer. obfs4's probe - * resistance is that a bridge which cannot verify a handshake behaves exactly - * like a service that is not there. Errors go to the caller; the caller's only - * correct response is to keep reading nothing and eventually drop the - * connection. */ class Obfs4ServerHandshake( private val nodeId: ByteArray, @@ -260,38 +268,51 @@ class Obfs4ServerHandshake( private var buffer = ByteArray(0) class InvalidHandshake : Exception("the client's handshake did not verify") + class Replayed : Exception("this handshake has been seen before") + class NtorFailed : Exception("ntor handshake failure") - class Accepted(val keySeed: ByteArray, val reply: ByteArray, val consumed: Int) + class Accepted( + val keySeed: ByteArray, + val reply: ByteArray, + val consumed: Int, + ) /** - * The reply must be MAC'd under the hour the CLIENT used, not ours. A - * client whose clock is an hour off is accepted, that is what the ±1 window - * is for, but it will only accept a reply computed under *its* hour. Using - * the server's own works in a test where both share a clock, and fails for - * every real client with a slow one. + * The reply must be MAC'd under the hour the CLIENT used, not ours. */ - fun receive(bytes: ByteArray, padding: ByteArray, replayFilter: ReplayFilter, - nowMillis: Long = System.currentTimeMillis()): Accepted? { + fun receive( + bytes: ByteArray, + padding: ByteArray, + replayFilter: ReplayFilter, + nowMillis: Long = System.currentTimeMillis(), + ): Accepted? { buffer += bytes - val parsed = try { - Obfs4Handshake.parseClientMessage(buffer, macKey, epochHour) - } catch (_: Obfs4Handshake.NeedMoreData) { - return null - } catch (e: Exception) { - throw InvalidHandshake() - } + val parsed = + try { + Obfs4Handshake.parseClientMessage(buffer, macKey, epochHour) + } catch (_: Obfs4Handshake.NeedMoreData) { + return null + } catch (e: Exception) { + throw InvalidHandshake() + } if (replayFilter.testAndSet(parsed.mac, nowMillis)) throw Replayed() val clientPublic = Elligator2.publicKeyFromRepresentative(parsed.representative) - val result = Ntor.serverHandshake( - clientPublic, sessionPrivate, sessionPublic, identityPrivate, identityPublic, nodeId, - ) ?: throw NtorFailed() + val result = + Ntor.serverHandshake( + clientPublic, + sessionPrivate, + sessionPublic, + identityPrivate, + identityPublic, + nodeId, + ) ?: throw NtorFailed() - val reply = Obfs4Handshake.serverMessage( - sessionRepresentative, result.auth, padding, macKey, parsed.epochHour) + val reply = + Obfs4Handshake.serverMessage(sessionRepresentative, result.auth, padding, macKey, parsed.epochHour) return Accepted(result.keySeed, reply, parsed.consumed) } } diff --git a/obfs4/src/main/kotlin/io/flamenet/obfs4/Elligator2.kt b/obfs4/src/main/kotlin/io/flamenet/obfs4/Elligator2.kt index e510c99..39f487a 100644 --- a/obfs4/src/main/kotlin/io/flamenet/obfs4/Elligator2.kt +++ b/obfs4/src/main/kotlin/io/flamenet/obfs4/Elligator2.kt @@ -4,39 +4,23 @@ import org.bouncycastle.math.ec.rfc7748.X25519Field /** * Elligator2 for Curve25519, in the flavour obfs4 uses. - * - * This is why an obfs4 handshake looks like random bytes. A Curve25519 - * public key on the wire is recognisable; its *representative* is not. Get this - * subtly wrong and nothing fails, handshakes still complete, keys still agree, - * the representatives are merely not quite uniform, which is exactly what an - * entropy test looks for. There is no runtime symptom, so the only defence is - * agreeing with the reference byte for byte on inputs neither side chose. See - * `Elligator2Test`, which runs the same vectors the Swift port does. - * - * Built on BouncyCastle's `X25519Field`, not on hand-written arithmetic. - * The Swift port had to write its own field because CryptoKit exposes nothing - * below X25519; the JVM does not have that hole, and taking a vetted - * constant-time implementation over rolling one is the same call `:engine` - * already made for Ed25519. - * - * The two ports therefore share no code, only vectors. That is the point: - * two independent implementations agreeing with the same reference is a - * stronger statement than one implementation agreeing with itself twice. */ object Elligator2 { - /** The Montgomery curve constant, A = 486662. */ - private val A: IntArray = X25519Field.create().also { - it[0] = 486662 - } + private val A: IntArray = + X25519Field.create().also { + it[0] = 486662 + } private val negA: IntArray = X25519Field.create().also { X25519Field.negate(A, it) } /** −2, the "non-square" the inverse map is built around. */ - private val negTwo: IntArray = X25519Field.create().also { - val two = X25519Field.create(); two[0] = 2 - X25519Field.negate(two, it) - } + private val negTwo: IntArray = + X25519Field.create().also { + val two = X25519Field.create() + two[0] = 2 + X25519Field.negate(two, it) + } private fun fe(bytes: ByteArray): IntArray { require(bytes.size == 32) { "a field element is 32 bytes" } @@ -57,21 +41,55 @@ object Elligator2 { private fun isNegative(z: IntArray): Boolean = (encode(z)[0].toInt() and 1) == 1 /** √−1 mod p. */ - private val SQRT_M1: IntArray = fe( - byteArrayOf( - 0xb0.toByte(), 0xa0.toByte(), 0x0e, 0x4a, 0x27, 0x1b, 0xee.toByte(), 0xc4.toByte(), - 0x78, 0xe4.toByte(), 0x2f, 0xad.toByte(), 0x06, 0x18, 0x43, 0x2f, - 0xa7.toByte(), 0xd7.toByte(), 0xfb.toByte(), 0x3d, 0x99.toByte(), 0x00, 0x4d, 0x2b, - 0x0b, 0xdf.toByte(), 0xc1.toByte(), 0x4f, 0x80.toByte(), 0x24, 0x83.toByte(), 0x2b, + private val SQRT_M1: IntArray = + fe( + byteArrayOf( + 0xb0.toByte(), + 0xa0.toByte(), + 0x0e, + 0x4a, + 0x27, + 0x1b, + 0xee.toByte(), + 0xc4.toByte(), + 0x78, + 0xe4.toByte(), + 0x2f, + 0xad.toByte(), + 0x06, + 0x18, + 0x43, + 0x2f, + 0xa7.toByte(), + 0xd7.toByte(), + 0xfb.toByte(), + 0x3d, + 0x99.toByte(), + 0x00, + 0x4d, + 0x2b, + 0x0b, + 0xdf.toByte(), + 0xc1.toByte(), + 0x4f, + 0x80.toByte(), + 0x24, + 0x83.toByte(), + 0x2b, + ), ) - ) /** x^((p−5)/8), the ladder every square root here is built on. */ private fun powP58(x: IntArray): IntArray { - fun sqrN(a: IntArray, n: Int): IntArray = - X25519Field.create().also { X25519Field.sqr(a, n, it) } - fun mul(a: IntArray, b: IntArray): IntArray = - X25519Field.create().also { X25519Field.mul(a, b, it) } + fun sqrN( + a: IntArray, + n: Int, + ): IntArray = X25519Field.create().also { X25519Field.sqr(a, n, it) } + + fun mul( + a: IntArray, + b: IntArray, + ): IntArray = X25519Field.create().also { X25519Field.mul(a, b, it) } val z2 = sqrN(x, 1) val z8 = sqrN(z2, 2) @@ -90,37 +108,37 @@ object Elligator2 { } /** `bit == 1 ? a : b`, with no branch and no data-dependent access. */ - private fun cselect(a: IntArray, b: IntArray, bit: Int): IntArray { - val mask = -(bit and 1) // 0 or -1, i.e. all bits + private fun cselect( + a: IntArray, + b: IntArray, + bit: Int, + ): IntArray { + val mask = -(bit and 1) // 0 or -1, i.e. all bits return IntArray(a.size) { (a[it] and mask) or (b[it] and mask.inv()) } } - private fun equal(a: IntArray, b: IntArray): Boolean { - val x = a.copyOf(); val y = b.copyOf() - X25519Field.normalize(x); X25519Field.normalize(y) + private fun equal( + a: IntArray, + b: IntArray, + ): Boolean { + val x = a.copyOf() + val y = b.copyOf() + X25519Field.normalize(x) + X25519Field.normalize(y) return X25519Field.areEqualVar(x, y) } /** * √(u/v), and whether the ratio was actually square. - * - * Written out rather than delegated to `X25519Field.sqrtRatioVar`, - * which is not a drop-in. Elligator needs the root *even when the ratio - * is not square*, it is √(i·u/v) then, and the algorithm uses it to pick a - * branch rather than to detect an error. BouncyCastle only promises a - * usable root in the square case. Handing its output to the map produced - * zeros for 120 of 256 vectors: half-right, entirely useless, and exactly - * the kind of thing that looks like a working implementation. - * - * The arithmetic underneath is still BouncyCastle's. What is written - * here is the exponent chain and the branch selection, not field ops. - * - * Canonicalised to the non-negative root: ±r map to the same point, so - * no round-trip test would catch the sign being left to chance. */ - private fun sqrtRatio(u: IntArray, v: IntArray): Pair { - fun mul(a: IntArray, b: IntArray): IntArray = - X25519Field.create().also { X25519Field.mul(a, b, it) } + private fun sqrtRatio( + u: IntArray, + v: IntArray, + ): Pair { + fun mul( + a: IntArray, + b: IntArray, + ): IntArray = X25519Field.create().also { X25519Field.mul(a, b, it) } val v3 = mul(X25519Field.create().also { X25519Field.sqr(v, it) }, v) val v7 = mul(X25519Field.create().also { X25519Field.sqr(v3, it) }, v) @@ -142,11 +160,6 @@ object Elligator2 { /** * A representative to the Montgomery `u`, a public key. - * - * The top two bits of a representative are not data. obfs4 encodes a - * representative in 254 bits and fills the rest with noise, so they are - * masked off here. Feeding the raw 32 bytes in produces a different point - * and a handshake that fails for no visible reason. */ fun publicKeyFromRepresentative(representative: ByteArray): ByteArray { require(representative.size == 32) { "a representative is 32 bytes" } @@ -157,19 +170,20 @@ object Elligator2 { /** `MontgomeryFlavor`: representative → (u, v). */ private fun montgomeryFlavor(r: IntArray): Pair { - val one = X25519Field.create(); X25519Field.one(one) + val one = X25519Field.create() + X25519Field.one(one) val t1 = X25519Field.create() X25519Field.sqr(r, t1) - X25519Field.add(t1, t1, t1) // 2r² + X25519Field.add(t1, t1, t1) // 2r² val u = X25519Field.create() - X25519Field.add(t1, one, u) // 2r² + 1 + X25519Field.add(t1, one, u) // 2r² + 1 val t2 = X25519Field.create() X25519Field.sqr(u, t2) - // numerator + // numerator. val t3 = X25519Field.create() val aSquared = X25519Field.create() X25519Field.sqr(A, aSquared) @@ -177,17 +191,12 @@ object Elligator2 { X25519Field.sub(t3, t2, t3) X25519Field.mul(t3, A, t3) - // denominator + // denominator. val denominator = X25519Field.create() X25519Field.mul(t2, u, denominator) X25519Field.mul(denominator, t3, denominator) - // The root replaces the denominator from here on. Both reference - // implementations write the result back over the argument - // (`invsqrt(t3, t3)`, `t1.SqrtRatio(ONE, t1)`), so every later line - // means √(1/denominator), not the denominator. Reading it the other way - // gave the Swift port 256 wrong answers out of 256, each a - // plausible-looking 32 bytes. + // The root replaces the denominator from here on. val (root, isSquare) = sqrtRatio(one, denominator) val uOut = X25519Field.create() @@ -213,8 +222,7 @@ object Elligator2 { X25519Field.mul(uOut, rootSquared, uOut) // The sign is chosen AGAINST `isSquare`: negate exactly when the two - // disagree. Reading this as "negate when not square" is the easy - // misreading and gives the wrong point half the time. + // disagree. if (isSquare != isNegative(vOut)) X25519Field.negate(vOut, vOut) return uOut to vOut } @@ -223,49 +231,36 @@ object Elligator2 { /** * A public key and a tweak byte to a uniform representative, or `null`. - * - * `null` is the normal case, not an error. Only about half of all - * Curve25519 points have a representative; obfs4 discards the keypair and - * generates another. An implementation that always succeeds is as broken as - * one that computes the wrong bytes, and rather harder to notice. */ - fun representativeForPublicKey(publicKey: ByteArray, tweak: Int): ByteArray? { + fun representativeForPublicKey( + publicKey: ByteArray, + tweak: Int, + ): ByteArray? { require(publicKey.size == 32) { "a public key is 32 bytes" } val u = fe(publicKey) - val one = X25519Field.create(); X25519Field.one(one) + val one = X25519Field.create() + X25519Field.one(one) val uPlusA = X25519Field.create() X25519Field.add(u, A, uPlusA) val t3 = X25519Field.create() X25519Field.mul(u, uPlusA, t3) - X25519Field.mul(t3, negTwo, t3) // −2·u·(u+A) + X25519Field.mul(t3, negTwo, t3) // −2·u·(u+A) val (isr, isSquare) = sqrtRatio(one, t3) - // The one variable-time branch, and it is deliberate: Monocypher - // makes the same call and explains why. It reveals only how many tries - // it took to find a representable key, which is safe as long as keys - // are tried at random. + // The one variable-time branch, and it is deliberate: Monocypher makes + // the same call and explains why. if (!isSquare) return null - // Multiply by u if v is positive, by u+A otherwise. Constant-time, - // because the tweak comes off the same keystream as the private key. - // - // Not `X25519Field.cmov`, which silently did nothing here. - // Passing `1` as its condition left the destination untouched, so both - // tweak parities produced the *same* representative, a map that still - // round-trips, still looks uniform, and only ever emits one of the two - // valid answers. It took a diagnostic printing both candidates to see - // it; the failing assertion said only "wrong bytes". Building the mask - // here makes the convention explicit instead of assumed. + // Multiply by u if v is positive, by u+A otherwise. val chosen = cselect(uPlusA, u, tweak and 1) val result = X25519Field.create() X25519Field.mul(chosen, isr, result) // Canonical sign: negate so that 2·result is even, pinning it to the - // lower half of the field. That is also what keeps a representative - // inside 254 bits, so the direct map's mask does not destroy it. + // lower half of the field. val doubled = X25519Field.create() X25519Field.add(result, result, doubled) X25519Field.cnegate(if (isNegative(doubled)) 1 else 0, result) @@ -276,21 +271,79 @@ object Elligator2 { return out } - private val U_FACTOR: IntArray = fe( - byteArrayOf( - 0x8d.toByte(), 0xbe.toByte(), 0xe2.toByte(), 0x6b, 0xb1.toByte(), 0xc9.toByte(), 0x23, 0x76, - 0x0e, 0x37, 0xa0.toByte(), 0xa5.toByte(), 0xf2.toByte(), 0xcf.toByte(), 0x79, 0xa1.toByte(), - 0xb1.toByte(), 0x50, 0x08, 0x84.toByte(), 0xcd.toByte(), 0xfe.toByte(), 0x65, 0xa9.toByte(), - 0xe9.toByte(), 0x41, 0x7c, 0x60, 0xff.toByte(), 0xb6.toByte(), 0xf9.toByte(), 0x28, + private val U_FACTOR: IntArray = + fe( + byteArrayOf( + 0x8d.toByte(), + 0xbe.toByte(), + 0xe2.toByte(), + 0x6b, + 0xb1.toByte(), + 0xc9.toByte(), + 0x23, + 0x76, + 0x0e, + 0x37, + 0xa0.toByte(), + 0xa5.toByte(), + 0xf2.toByte(), + 0xcf.toByte(), + 0x79, + 0xa1.toByte(), + 0xb1.toByte(), + 0x50, + 0x08, + 0x84.toByte(), + 0xcd.toByte(), + 0xfe.toByte(), + 0x65, + 0xa9.toByte(), + 0xe9.toByte(), + 0x41, + 0x7c, + 0x60, + 0xff.toByte(), + 0xb6.toByte(), + 0xf9.toByte(), + 0x28, + ), ) - ) - private val V_FACTOR: IntArray = fe( - byteArrayOf( - 0x3e, 0x5f, 0xf1.toByte(), 0xb5.toByte(), 0xd8.toByte(), 0xe4.toByte(), 0x11, 0x3b, - 0x87.toByte(), 0x1b, 0xd0.toByte(), 0x52, 0xf9.toByte(), 0xe7.toByte(), 0xbc.toByte(), 0xd0.toByte(), - 0x58, 0x28, 0x04, 0xc2.toByte(), 0x66, 0xff.toByte(), 0xb2.toByte(), 0xd4.toByte(), - 0xf4.toByte(), 0x20, 0x3e, 0xb0.toByte(), 0x7f, 0xdb.toByte(), 0x7c, 0x54, + private val V_FACTOR: IntArray = + fe( + byteArrayOf( + 0x3e, + 0x5f, + 0xf1.toByte(), + 0xb5.toByte(), + 0xd8.toByte(), + 0xe4.toByte(), + 0x11, + 0x3b, + 0x87.toByte(), + 0x1b, + 0xd0.toByte(), + 0x52, + 0xf9.toByte(), + 0xe7.toByte(), + 0xbc.toByte(), + 0xd0.toByte(), + 0x58, + 0x28, + 0x04, + 0xc2.toByte(), + 0x66, + 0xff.toByte(), + 0xb2.toByte(), + 0xd4.toByte(), + 0xf4.toByte(), + 0x20, + 0x3e, + 0xb0.toByte(), + 0x7f, + 0xdb.toByte(), + 0x7c, + 0x54, + ), ) - ) } diff --git a/obfs4/src/main/kotlin/io/flamenet/obfs4/Framing.kt b/obfs4/src/main/kotlin/io/flamenet/obfs4/Framing.kt index 4825a0c..22c2b1b 100644 --- a/obfs4/src/main/kotlin/io/flamenet/obfs4/Framing.kt +++ b/obfs4/src/main/kotlin/io/flamenet/obfs4/Framing.kt @@ -6,26 +6,20 @@ import org.bouncycastle.crypto.params.KeyParameter import org.bouncycastle.crypto.params.ParametersWithIV /** - * NaCl `crypto_secretbox`. XSalsa20-Poly1305, and obfs4's link framing. - * - * XSalsa20 and Poly1305 both come from BouncyCastle here. The Swift port - * hand-writes both because CryptoKit's AEADs are AES-GCM and ChaChaPoly and it - * exposes neither primitive. Poly1305 in particular is a MAC, so a bug in it is - * a forgery rather than a wrong answer, taking a vetted implementation is the - * right call wherever one exists. + * NaCl `crypto_secretbox`. */ object Secretbox { - const val OVERHEAD = 16 /** - * The first 32 bytes of the keystream are the Poly1305 key, and the - * message starts at stream offset 32. Encrypting from offset 0 produces a - * ciphertext that decrypts perfectly against itself and against nothing - * else. Prefixing 32 zero bytes and slicing is the same computation written - * so the offset cannot be forgotten. + * The first 32 bytes of the keystream are the Poly1305 key, and the message + * starts at stream offset 32. */ - fun seal(message: ByteArray, nonce: ByteArray, key: ByteArray): ByteArray { + fun seal( + message: ByteArray, + nonce: ByteArray, + key: ByteArray, + ): ByteArray { require(nonce.size == 24 && key.size == 32) val stream = keystream(ByteArray(32) + message, nonce, key) val polyKey = stream.copyOfRange(0, 32) @@ -34,7 +28,11 @@ object Secretbox { } /** `null` on a tag mismatch, which is fatal, never retried. */ - fun open(box: ByteArray, nonce: ByteArray, key: ByteArray): ByteArray? { + fun open( + box: ByteArray, + nonce: ByteArray, + key: ByteArray, + ): ByteArray? { require(nonce.size == 24 && key.size == 32) if (box.size < OVERHEAD) return null val tag = box.copyOfRange(0, OVERHEAD) @@ -43,27 +41,35 @@ object Secretbox { val stream = keystream(ByteArray(32) + ciphertext, nonce, key) val polyKey = stream.copyOfRange(0, 32) - // Authenticate BEFORE returning any plaintext. The stream is computed - // either way, but nothing derived from it may escape until the tag - // checks out. + // Authenticate BEFORE returning any plaintext. if (!constantTimeEquals(tag, mac(polyKey, ciphertext))) return null return stream.copyOfRange(32, stream.size) } - private fun keystream(input: ByteArray, nonce: ByteArray, key: ByteArray): ByteArray { + private fun keystream( + input: ByteArray, + nonce: ByteArray, + key: ByteArray, + ): ByteArray { val engine = XSalsa20Engine() engine.init(true, ParametersWithIV(KeyParameter(key), nonce)) return ByteArray(input.size).also { engine.processBytes(input, 0, input.size, it, 0) } } - private fun mac(key: ByteArray, message: ByteArray): ByteArray { + private fun mac( + key: ByteArray, + message: ByteArray, + ): ByteArray { val poly = Poly1305() poly.init(KeyParameter(key)) poly.update(message, 0, message.size) return ByteArray(16).also { poly.doFinal(it, 0) } } - fun constantTimeEquals(a: ByteArray, b: ByteArray): Boolean { + fun constantTimeEquals( + a: ByteArray, + b: ByteArray, + ): Boolean { if (a.size != b.size) return false var diff = 0 for (i in a.indices) diff = diff or (a[i].toInt() xor b[i].toInt()) @@ -73,26 +79,23 @@ object Secretbox { /** * obfs4 link framing: a two-byte obfuscated length followed by a secretbox. - * - * The nonce is never transmitted. It is a fixed 16-byte prefix from the - * key plus a big-endian 64-bit counter that starts at 1 and increments per - * frame. Both sides track it independently, so a frame processed out of order, - * twice, or not at all desynchronises the connection permanently, and the - * symptom is a Poly1305 failure on the *next* frame, not the one at fault. - * - * Starting the counter at 0 interoperates with nothing, and is also how the - * wraparound check works: the reference treats 0 as "wrapped" precisely because - * it can never legitimately be 0. */ -class FrameEncoder(key: ByteArray) { +class FrameEncoder( + key: ByteArray, +) { private val boxKey = key.copyOfRange(0, 32) private val noncePrefix = key.copyOfRange(32, 48) private val drbg = LengthDrbg(key.copyOfRange(48, 72)) private var counter = 1L - init { require(key.size == KEY_LENGTH) { "the framing key is $KEY_LENGTH bytes" } } + init { + require(key.size == KEY_LENGTH) { "the framing key is $KEY_LENGTH bytes" } + } + + class PayloadTooLong( + val length: Int, + ) : Exception("payload too long: $length") - class PayloadTooLong(val length: Int) : Exception("payload too long: $length") class NonceCounterWrapped : Exception("the nonce counter wrapped, fatal") fun encode(payload: ByteArray): ByteArray { @@ -108,7 +111,7 @@ class FrameEncoder(key: ByteArray) { } companion object { - const val KEY_LENGTH = 72 // 32 box key ‖ 16 nonce prefix ‖ 24 DRBG seed + const val KEY_LENGTH = 72 // 32 box key ‖ 16 nonce prefix ‖ 24 DRBG seed const val OVERHEAD = 2 + Secretbox.OVERHEAD const val MAX_SEGMENT_LENGTH = 1448 const val MAX_PAYLOAD_LENGTH = MAX_SEGMENT_LENGTH - OVERHEAD @@ -116,29 +119,31 @@ class FrameEncoder(key: ByteArray) { } /** - * The other half. Separate because the two directions of a connection use - * different keys and must never share DRBG or counter state. + * The other half. */ -class FrameDecoder(key: ByteArray) { +class FrameDecoder( + key: ByteArray, +) { private val boxKey = key.copyOfRange(0, 32) private val noncePrefix = key.copyOfRange(32, 48) private val drbg = LengthDrbg(key.copyOfRange(48, 72)) private var counter = 1L private var pendingLength: Int? = null - init { require(key.size == FrameEncoder.KEY_LENGTH) } + init { + require(key.size == FrameEncoder.KEY_LENGTH) + } class NeedMoreData : Exception("more data needed") + class TagMismatch : Exception("Poly1305 tag mismatch, fatal") - class InvalidLength(val length: Int) : Exception("invalid frame length: $length") + + class InvalidLength( + val length: Int, + ) : Exception("invalid frame length: $length") /** * Consumes one whole frame from `buffer`, removing it. - * - * The length must be unmasked exactly once per frame. The DRBG - * advances when read, so peeking at the length twice, say, on a short read - * that is retried, desynchronises the stream. The unmasked length is - * therefore remembered until its frame is consumed. */ fun decode(buffer: MutableList): ByteArray { if (pendingLength == null) { diff --git a/obfs4/src/main/kotlin/io/flamenet/obfs4/GoRandom.kt b/obfs4/src/main/kotlin/io/flamenet/obfs4/GoRandom.kt index dede86e..dad0702 100644 --- a/obfs4/src/main/kotlin/io/flamenet/obfs4/GoRandom.kt +++ b/obfs4/src/main/kotlin/io/flamenet/obfs4/GoRandom.kt @@ -4,22 +4,10 @@ import java.security.SecureRandom /** * Go's `math/rand` semantics, driven by obfs4's SipHash DRBG. - * - * This exists for indistinguishability, not interoperability. Padding is - * padding, a peer reads the declared payload length and ignores the rest, so - * *any* distribution talks to a real bridge. But obfs4's purpose is to look like - * other obfs4 traffic, and packet length is among the first things a censor - * would model. A port that pads differently works perfectly and is - * fingerprintable, which is the more dangerous kind of wrong. - * - * These are Go's algorithms, quirks included. `float64` divides an - * `int63` by 2^63 and resamples on the one-in-2^53 chance it rounds to 1.0, - * Go's own comment says a cleaner form exists and was rejected to preserve the - * value stream. `perm` swaps `m[0]` with itself on its first iteration for the - * same reason. Both quirks consume randomness, so "improving" either - * desynchronises every draw that follows. */ -class GoRandom(seed: ByteArray) { +class GoRandom( + seed: ByteArray, +) { private val drbg = LengthDrbg(seed) /** The DRBG block, big-endian, with the sign bit cleared. */ @@ -33,12 +21,11 @@ class GoRandom(seed: ByteArray) { private fun int31(): Int = (int63() ushr 32).toInt() /** - * Rejection sampling, exactly as Go does it. A simple `% n` would be - * biased *and* would consume a different number of draws. + * Rejection sampling, exactly as Go does it. */ private fun int31n(n: Int): Int { require(n > 0) - if (n and (n - 1) == 0) return int31() and (n - 1) // power of two + if (n and (n - 1) == 0) return int31() and (n - 1) // power of two val max = ((1L shl 31) - 1 - (1L shl 31) % n).toInt() var v = int31() while (v > max) v = int31() @@ -52,14 +39,13 @@ class GoRandom(seed: ByteArray) { fun float64(): Double { while (true) { - val f = int63().toDouble() / 9.223372036854776E18 // 2^63 + val f = int63().toDouble() / 9.223372036854776E18 // 2^63 if (f != 1.0) return f } } /** - * Starts at 0, so the first iteration swaps `m[0]` with itself. Useless - * work that consumes a draw, and removing it changes every later value. + * Starts at 0, so the first iteration swaps `m[0]` with itself. */ fun perm(n: Int): IntArray { val m = IntArray(n) @@ -75,18 +61,17 @@ class GoRandom(seed: ByteArray) { /** * obfs4's packet-length distribution: a weighted sample over a subset of * lengths, derived from a seed. - * - * The server sends its seed to the client inside the connection, so both - * ends pad from the same distribution. That is why the derivation must match - * lyrebird's exactly even though sampling need not. - * - * Sampling draws from the system CSPRNG, not the seed. The reference is - * explicit about this, and it matters: a seeded sampler would make one - * connection's padding predictable from another's. */ -class WeightedDist(seed: ByteArray, val minValue: Int, val maxValue: Int, val biased: Boolean) { - var values: IntArray = IntArray(0); private set - var weights: DoubleArray = DoubleArray(0); private set +class WeightedDist( + seed: ByteArray, + val minValue: Int, + val maxValue: Int, + val biased: Boolean, +) { + var values: IntArray = IntArray(0) + private set + var weights: DoubleArray = DoubleArray(0) + private set private var prob: DoubleArray = DoubleArray(0) private var alias: IntArray = IntArray(0) private val random = SecureRandom() @@ -106,9 +91,7 @@ class WeightedDist(seed: ByteArray, val minValue: Int, val maxValue: Int, val bi private fun genValues(rng: GoRandom) { var nValues = (maxValue + 1) - minValue val permuted = rng.perm(nValues) - // The clamp happens AFTER the full permutation is drawn. Permuting - // only the clamped count consumes far less randomness and every later - // draw would differ. + // The clamp happens AFTER the full permutation is drawn. nValues = maxOf(nValues, MIN_VALUES) nValues = minOf(nValues, MAX_VALUES) nValues = rng.intn(nValues) + 1 diff --git a/obfs4/src/main/kotlin/io/flamenet/obfs4/Handshake.kt b/obfs4/src/main/kotlin/io/flamenet/obfs4/Handshake.kt index e3753d2..1c28e6e 100644 --- a/obfs4/src/main/kotlin/io/flamenet/obfs4/Handshake.kt +++ b/obfs4/src/main/kotlin/io/flamenet/obfs4/Handshake.kt @@ -4,23 +4,13 @@ import javax.crypto.Mac import javax.crypto.spec.SecretKeySpec /** - * The obfs4 handshake messages. - * - * client → server: X ‖ P_C ‖ M_C ‖ MAC(X ‖ P_C ‖ M_C ‖ E) - * server → client: Y ‖ AUTH ‖ P_S ‖ M_S ‖ MAC(Y ‖ AUTH ‖ P_S ‖ M_S ‖ E) - * - * where `X`/`Y` are Elligator2 representatives, `P` is random padding, `M` is a - * 16-byte mark, `E` is the hours since the Unix epoch as a decimal string, + * The obfs4 handshake messages. client → server: X ‖ P_C ‖ M_C ‖ MAC(X ‖ P_C ‖ + * M_C ‖ E) server → client: Y ‖ AUTH ‖ P_S ‖ M_S ‖ MAC(Y ‖ AUTH ‖ P_S ‖ M_S ‖ + * E) where `X`/`Y` are Elligator2 representatives, `P` is random padding, `M` + * is a 16-byte mark, `E` is the hours since the Unix epoch as a decimal string, * and the MAC key is `serverIdentityPublic ‖ nodeId`. - * - * This layer is what makes obfs4 probe-resistant. A scanner that connects - * and sends anything cannot produce a valid mark without the bridge's public - * key, so the server never answers, it just waits, exactly like a service that - * is not there. Every rejection path here must stay silent to the *peer*: - * returning an error to the caller is right, writing anything back is not. */ object Obfs4Handshake { - const val MARK_LENGTH = 16 const val MAC_LENGTH = 16 const val MAX_HANDSHAKE_LENGTH = 8192 @@ -47,48 +37,54 @@ object Obfs4Handshake { MAX_HANDSHAKE_LENGTH - (SERVER_MIN_HANDSHAKE_LENGTH + INLINE_SEED_FRAME_LENGTH) /** - * The MAC key: the server's identity public key then the node id. Both - * are public, a client holding the bridge line can - * compute marks, and a scanner that does not, cannot. + * The MAC key: the server's identity public key then the node id. */ - fun macKey(serverIdentityPublic: ByteArray, nodeId: ByteArray): ByteArray = - serverIdentityPublic + nodeId + fun macKey( + serverIdentityPublic: ByteArray, + nodeId: ByteArray, + ): ByteArray = serverIdentityPublic + nodeId - private fun hmac128(key: ByteArray, vararg parts: ByteArray): ByteArray { + private fun hmac128( + key: ByteArray, + vararg parts: ByteArray, + ): ByteArray { val mac = Mac.getInstance("HmacSHA256") mac.init(SecretKeySpec(key, "HmacSHA256")) for (p in parts) mac.update(p) return mac.doFinal().copyOfRange(0, 16) } - fun mark(macKey: ByteArray, representative: ByteArray): ByteArray = - hmac128(macKey, representative) + fun mark( + macKey: ByteArray, + representative: ByteArray, + ): ByteArray = hmac128(macKey, representative) /** * The epoch hour enters the MAC as its decimal string, not as bytes. - * `496862` is six ASCII characters here; a binary encoding produces a MAC - * that is self-consistent and interoperates with nothing. */ private fun epochBytes(hour: Long): ByteArray = hour.toString().toByteArray() /** - * Everything below takes the hour as a parameter. A MAC that silently - * depends on the wall clock cannot be tested against a recorded handshake, - * because the recording rots within the hour. + * Everything below takes the hour as a parameter. */ - fun currentEpochHour(nowMillis: Long = System.currentTimeMillis()): Long = - nowMillis / 1000 / 3600 + fun currentEpochHour(nowMillis: Long = System.currentTimeMillis()): Long = nowMillis / 1000 / 3600 fun clientMessage( - representative: ByteArray, padding: ByteArray, macKey: ByteArray, epochHour: Long, + representative: ByteArray, + padding: ByteArray, + macKey: ByteArray, + epochHour: Long, ): ByteArray { val prefix = representative + padding + mark(macKey, representative) return prefix + hmac128(macKey, prefix, epochBytes(epochHour)) } fun serverMessage( - representative: ByteArray, auth: ByteArray, padding: ByteArray, - macKey: ByteArray, epochHour: Long, + representative: ByteArray, + auth: ByteArray, + padding: ByteArray, + macKey: ByteArray, + epochHour: Long, ): ByteArray { val prefix = representative + auth + padding + mark(macKey, representative) return prefix + hmac128(macKey, prefix, epochBytes(epochHour)) @@ -96,7 +92,9 @@ object Obfs4Handshake { /** Not a rejection. The mark may simply not have arrived yet. */ class NeedMoreData : Exception("more handshake data needed") + class InvalidHandshake : Exception("invalid handshake") + class MacMismatch : Exception("handshake MAC mismatch") class ParsedClient( @@ -118,17 +116,12 @@ object Obfs4Handshake { /** * The server's side. - * - * Searches only the tail, deliberately. The client cannot send - * anything valid after its MAC, it has no server ephemeral key yet, so the - * mark must sit exactly 32 bytes from the end. Trailing bytes make the - * handshake invalid, full stop. - * - * All three epoch hours are evaluated even after one matches. The - * reference does this to flatten timing, and so does this: breaking early - * would leak which hour a client's clock is on. */ - fun parseClientMessage(buffer: ByteArray, macKey: ByteArray, epochHour: Long): ParsedClient { + fun parseClientMessage( + buffer: ByteArray, + macKey: ByteArray, + epochHour: Long, + ): ParsedClient { if (buffer.size < CLIENT_MIN_HANDSHAKE_LENGTH) throw NeedMoreData() val representative = buffer.copyOfRange(0, REPRESENTATIVE_LENGTH) @@ -136,8 +129,7 @@ object Obfs4Handshake { val startPos = REPRESENTATIVE_LENGTH + CLIENT_MIN_PAD_LENGTH val endPos = minOf(buffer.size, MAX_HANDSHAKE_LENGTH) - fun giveUp(): Nothing = - if (buffer.size >= MAX_HANDSHAKE_LENGTH) throw InvalidHandshake() else throw NeedMoreData() + fun giveUp(): Nothing = if (buffer.size >= MAX_HANDSHAKE_LENGTH) throw InvalidHandshake() else throw NeedMoreData() if (endPos - startPos < MARK_LENGTH + MAC_LENGTH) giveUp() val pos = endPos - (MARK_LENGTH + MAC_LENGTH) @@ -156,25 +148,26 @@ object Obfs4Handshake { // Kept to match the reference, though the tail search is what actually // rejects trailing bytes: appending moves the window and the mark stops - // matching. This can only fire on an oversized buffer. + // matching. if (buffer.size != pos + MARK_LENGTH + MAC_LENGTH) throw InvalidHandshake() return ParsedClient( - representative, buffer.copyOfRange(REPRESENTATIVE_LENGTH, pos), - hour, pos + MARK_LENGTH + MAC_LENGTH, receivedMac, + representative, + buffer.copyOfRange(REPRESENTATIVE_LENGTH, pos), + hour, + pos + MARK_LENGTH + MAC_LENGTH, + receivedMac, ) } /** * The client's side. - * - * A substring search, not a tail check. The server *does* send framed - * traffic immediately after its handshake, its first data frame is usually - * in the same segment, so the mark can be anywhere from - * `SERVER_MIN_HANDSHAKE_LENGTH` onwards. Using the tail trick here, the - * obvious symmetry, fails against every real bridge. */ - fun parseServerMessage(buffer: ByteArray, macKey: ByteArray, epochHour: Long): ParsedServer { + fun parseServerMessage( + buffer: ByteArray, + macKey: ByteArray, + epochHour: Long, + ): ParsedServer { if (buffer.size < SERVER_MIN_HANDSHAKE_LENGTH) throw NeedMoreData() val representative = buffer.copyOfRange(0, REPRESENTATIVE_LENGTH) @@ -194,16 +187,28 @@ object Obfs4Handshake { if (!Secretbox.constantTimeEquals(computed, receivedMac)) throw MacMismatch() return ParsedServer( - representative, auth, buffer.copyOfRange(startPos, pos), + representative, + auth, + buffer.copyOfRange(startPos, pos), pos + MARK_LENGTH + MAC_LENGTH, ) } - private fun indexOf(needle: ByteArray, haystack: ByteArray, from: Int, to: Int): Int { + private fun indexOf( + needle: ByteArray, + haystack: ByteArray, + from: Int, + to: Int, + ): Int { if (from < 0 || to > haystack.size || to - from < needle.size) return -1 var i = from outer@ while (i <= to - needle.size) { - for (j in needle.indices) if (haystack[i + j] != needle[j]) { i++; continue@outer } + for (j in needle.indices) { + if (haystack[i + j] != needle[j]) { + i++ + continue@outer + } + } return i } return -1 diff --git a/obfs4/src/main/kotlin/io/flamenet/obfs4/Ladder.kt b/obfs4/src/main/kotlin/io/flamenet/obfs4/Ladder.kt index 5c68108..e1d0445 100644 --- a/obfs4/src/main/kotlin/io/flamenet/obfs4/Ladder.kt +++ b/obfs4/src/main/kotlin/io/flamenet/obfs4/Ladder.kt @@ -3,71 +3,80 @@ package io.flamenet.obfs4 import org.bouncycastle.math.ec.rfc7748.X25519Field /** - * X25519 scalar multiplication, including the cofactor-dirty variant obfs4 - * uses for key generation. - * - * The dirty variant is why obfs4 public keys differ from every normal - * X25519 implementation's. A conventional implementation clamps the scalar, - * clearing the cofactor and confining the result to the prime-order subgroup, - * and a point known to be in that subgroup is *distinguishable*, which undoes - * the point of Elligator2. obfs4 keeps the cofactor component so the - * public key ranges over the full curve. - * - * BouncyCastle's `X25519.scalarMult` cannot be used for it. That clamps - * internally and runs 255 bits; the dirty variant needs the *combined* scalar - * fed to a 256-bit ladder with no re-clamping. The field arithmetic here is - * still BouncyCastle's, only the ladder and the scalar handling are written - * out. - * - * Follows Monocypher's `crypto_x25519_dirty_small`, which decouples the - * cofactor from the main factor and multiplies a single combined scalar by a - * base point of order 8·L. lyrebird instead uses the Edwards route; the two - * agree, and the vectors check that rather than taking it on faith. + * X25519 scalar multiplication, including the cofactor-dirty variant obfs4 uses + * for key generation. */ object Ladder { - /** * A base point of order 8·L, so multiplying by it leaves the cofactor * intact. - * - * Its low-order component is not arbitrary, it was chosen so this route - * lands on the same public key as the Edwards route obfs4 actually uses. - * Any other order-8 point gives a self-consistent implementation that no - * longer interoperates. */ - private val DIRTY_BASE_POINT = byteArrayOf( - 0xd8.toByte(), 0x86.toByte(), 0x1a, 0xa2.toByte(), 0x78, 0x7a, 0xd9.toByte(), 0x26, - 0x8b.toByte(), 0x74, 0x74, 0xb6.toByte(), 0x82.toByte(), 0xe3.toByte(), 0xbe.toByte(), 0xc3.toByte(), - 0xce.toByte(), 0x36, 0x9a.toByte(), 0x1e, 0x5e, 0x31, 0x47, 0xa2.toByte(), - 0x6d, 0x37, 0x7c, 0xfd.toByte(), 0x20, 0xb5.toByte(), 0xdf.toByte(), 0x75, - ) + private val DIRTY_BASE_POINT = + byteArrayOf( + 0xd8.toByte(), + 0x86.toByte(), + 0x1a, + 0xa2.toByte(), + 0x78, + 0x7a, + 0xd9.toByte(), + 0x26, + 0x8b.toByte(), + 0x74, + 0x74, + 0xb6.toByte(), + 0x82.toByte(), + 0xe3.toByte(), + 0xbe.toByte(), + 0xc3.toByte(), + 0xce.toByte(), + 0x36, + 0x9a.toByte(), + 0x1e, + 0x5e, + 0x31, + 0x47, + 0xa2.toByte(), + 0x6d, + 0x37, + 0x7c, + 0xfd.toByte(), + 0x20, + 0xb5.toByte(), + 0xdf.toByte(), + 0x75, + ) private val BASE_POINT_9 = ByteArray(32).also { it[0] = 9 } /** L, the order of the prime-order subgroup, as eight 32-bit words. */ - private val GROUP_ORDER = longArrayOf( - 0x5cf5d3edL, 0x5812631aL, 0xa2f79cd6L, 0x14def9deL, - 0x00000000L, 0x00000000L, 0x00000000L, 0x10000000L, - ) + private val GROUP_ORDER = + longArrayOf( + 0x5cf5d3edL, + 0x5812631aL, + 0xa2f79cd6L, + 0x14def9deL, + 0x00000000L, + 0x00000000L, + 0x00000000L, + 0x10000000L, + ) /** The standard X25519 clamp. */ - fun trimScalar(s: ByteArray): ByteArray = s.copyOf().also { - it[0] = (it[0].toInt() and 248).toByte() - it[31] = (it[31].toInt() and 127).toByte() - it[31] = (it[31].toInt() or 64).toByte() - } + fun trimScalar(s: ByteArray): ByteArray = + s.copyOf().also { + it[0] = (it[0].toInt() and 248).toByte() + it[31] = (it[31].toInt() and 127).toByte() + it[31] = (it[31].toInt() or 64).toByte() + } /** * Add `(x and 7)·L` to a 256-bit little-endian scalar. - * - * This is the step that puts the cofactor back. Trimming cleared the - * three low bits, but those bits still carry a main-factor component that - * has to be removed for X25519 compatibility; adding `lsb·L` (mod 8·L) - * recombines the two into one scalar. Drop it and you get an ordinary clean - * public key, which works, agrees on shared secrets, and is exactly the - * distinguishable thing obfs4 avoids. */ - fun addGroupOrderTimes(x: Int, s: ByteArray): ByteArray { + fun addGroupOrderTimes( + x: Int, + s: ByteArray, + ): ByteArray { val out = s.copyOf() val mod8 = (x and 7).toLong() var carry = 0L @@ -82,11 +91,12 @@ object Ladder { } /** `bit == 1` swaps `a` and `b`, without branching on the bit. */ - private fun cswap(bit: Int, a: IntArray, b: IntArray) { - // An explicit mask, deliberately. `X25519Field.cmov` was found to do - // nothing at all when handed a plain `1`, so nothing in this file - // assumes BouncyCastle's conditional helpers take a boolean-shaped - // condition. See Elligator2.cselect. + private fun cswap( + bit: Int, + a: IntArray, + b: IntArray, + ) { + // An explicit mask, deliberately. val mask = -(bit and 1) for (i in a.indices) { val t = mask and (a[i] xor b[i]) @@ -97,13 +107,12 @@ object Ladder { /** * The Montgomery ladder in projective coordinates, ref10's, via Monocypher. - * - * `bits` is 256 for the dirty variant, not the usual 255. The - * combined scalar can exceed 2^255 once `lsb·L` has been added, so a ladder - * that stops at bit 254 silently drops the top bit and returns a wrong - * point for a fraction of keys, a bug any small sample would miss. */ - fun ladder(scalar: ByteArray, base: ByteArray, bits: Int): ByteArray { + fun ladder( + scalar: ByteArray, + base: ByteArray, + bits: Int, + ): ByteArray { val x1 = X25519Field.create().also { X25519Field.decode(base, 0, it) } val x2 = X25519Field.create().also { X25519Field.one(it) } val z2 = X25519Field.create() @@ -120,7 +129,7 @@ object Ladder { swap = swap xor bit cswap(swap, x2, x3) cswap(swap, z2, z3) - swap = bit // the xor trick: one swap deferred + swap = bit // the xor trick: one swap deferred X25519Field.sub(x3, z3, t0) X25519Field.sub(x2, z2, t1) @@ -141,8 +150,7 @@ object Ladder { X25519Field.mul(x1, z2, z3) X25519Field.mul(t1, t2, z2) } - // Compensates for the xor trick. Omitting it gives a result that is - // right for exactly the scalars whose bottom bit happens to agree. + // Compensates for the xor trick. cswap(swap, x2, x3) cswap(swap, z2, z3) @@ -154,10 +162,6 @@ object Ladder { /** * A private key to its cofactor-dirty X25519 public key. - * - * The private key must be the full 32 bytes of entropy. Pre-clamping it - * yields non-uniformly distributed representatives, which is the whole - * failure this module exists to avoid. */ fun dirtyPublicKey(privateKey: ByteArray): ByteArray { require(privateKey.size == 32) { "a private key is 32 bytes" } @@ -167,24 +171,14 @@ object Ladder { /** * The ordinary, cofactor-cleared public key. - * - * A bridge's long-term identity key is CLEAN. Only the ephemeral - * session keys keep their cofactor, because only those travel as - * representatives. (A dirty identity key does in fact still complete a - * handshake. X25519 clamps, so the order-8 component cancels on both - * sides, but every other implementation and every bridge-line tool expects - * the clean one.) */ - fun publicKey(privateKey: ByteArray): ByteArray = - ladder(trimScalar(privateKey), BASE_POINT_9, 255) + fun publicKey(privateKey: ByteArray): ByteArray = ladder(trimScalar(privateKey), BASE_POINT_9, 255) /** * Ordinary X25519. - * - * The result must be checked for all-zeros by the caller: a peer that - * sends a low-order point drives every shared secret to zero, which a - * handshake has to reject rather than key from. */ - fun sharedSecret(privateKey: ByteArray, peerPublicKey: ByteArray): ByteArray = - ladder(trimScalar(privateKey), peerPublicKey, 255) + fun sharedSecret( + privateKey: ByteArray, + peerPublicKey: ByteArray, + ): ByteArray = ladder(trimScalar(privateKey), peerPublicKey, 255) } diff --git a/obfs4/src/main/kotlin/io/flamenet/obfs4/Ntor.kt b/obfs4/src/main/kotlin/io/flamenet/obfs4/Ntor.kt index 9e2be52..32316b1 100644 --- a/obfs4/src/main/kotlin/io/flamenet/obfs4/Ntor.kt +++ b/obfs4/src/main/kotlin/io/flamenet/obfs4/Ntor.kt @@ -8,20 +8,8 @@ import javax.crypto.spec.SecretKeySpec /** * obfs4's ntor handshake. - * - * The reference's own comment contradicts the reference's own code, and - * the code is the protocol. lyrebird's `ntorCommon` documents its input - * ordering as `ID | B | Y | X | PROTOID`, then builds - * `B | B | X | Y | PROTOID | ID`, with `B` appearing twice, because the - * buffer is constructed with it and then written to again. Port the comment and - * you get a handshake that computes cleanly, yields a respectable-looking - * 32-byte KEY_SEED, and cannot talk to a single real bridge. - * - * This is not Tor's ntor. It is obfs4's variant, and the difference is - * exactly that quirk. */ object Ntor { - const val NODE_ID_LENGTH = 20 const val KEY_SEED_LENGTH = 32 const val AUTH_LENGTH = 32 @@ -32,24 +20,27 @@ object Ntor { private val T_VERIFY = PROTO_ID + ":key_verify".toByteArray() private val M_EXPAND = PROTO_ID + ":key_expand".toByteArray() - data class Result(val keySeed: ByteArray, val auth: ByteArray) { + data class Result( + val keySeed: ByteArray, + val auth: ByteArray, + ) { override fun equals(other: Any?): Boolean = other is Result && keySeed.contentEquals(other.keySeed) && auth.contentEquals(other.auth) + override fun hashCode(): Int = keySeed.contentHashCode() * 31 + auth.contentHashCode() } /** - * The client half. `null` means the handshake must be abandoned. - * - * The caller must also compare `auth` against the server's, in constant - * time, with [authMatches]. A non-null result proves only that this side - * computed something. + * The client half. */ fun clientHandshake( - clientPrivate: ByteArray, clientPublic: ByteArray, - serverPublic: ByteArray, idPublic: ByteArray, nodeId: ByteArray, + clientPrivate: ByteArray, + clientPublic: ByteArray, + serverPublic: ByteArray, + idPublic: ByteArray, + nodeId: ByteArray, ): Result? { - // EXP(Y,x) | EXP(B,x) + // EXP(Y,x) | EXP(B,x). val exp1 = Ladder.sharedSecret(clientPrivate, serverPublic) val exp2 = Ladder.sharedSecret(clientPrivate, idPublic) if (isZero(exp1) || isZero(exp2)) return null @@ -58,10 +49,14 @@ object Ntor { /** The server half. `null` means the handshake must be abandoned. */ fun serverHandshake( - clientPublic: ByteArray, serverPrivate: ByteArray, serverPublic: ByteArray, - idPrivate: ByteArray, idPublic: ByteArray, nodeId: ByteArray, + clientPublic: ByteArray, + serverPrivate: ByteArray, + serverPublic: ByteArray, + idPrivate: ByteArray, + idPublic: ByteArray, + nodeId: ByteArray, ): Result? { - // EXP(X,y) | EXP(X,b) + // EXP(X,y) | EXP(X,b). val exp1 = Ladder.sharedSecret(serverPrivate, clientPublic) val exp2 = Ladder.sharedSecret(idPrivate, clientPublic) if (isZero(exp1) || isZero(exp2)) return null @@ -69,11 +64,13 @@ object Ntor { } private fun common( - exponents: ByteArray, nodeId: ByteArray, - b: ByteArray, x: ByteArray, y: ByteArray, + exponents: ByteArray, + nodeId: ByteArray, + b: ByteArray, + x: ByteArray, + y: ByteArray, ): Result { - // `b` twice. Not a typo faithfully reproduced by accident, it is the - // wire format. + // `b` twice. val suffix = b + b + x + y + PROTO_ID + nodeId val secretInput = exponents + suffix @@ -85,24 +82,23 @@ object Ntor { /** * HKDF-SHA256 over KEY_SEED, for the framing layer's keys. - * - * The salt is `t_key`, the same string used as the HMAC key for - * KEY_SEED, and the info is `m_expand`. Swapping them produces key material - * that is perfectly random-looking and shared with nobody. */ - fun kdf(keySeed: ByteArray, outputByteCount: Int): ByteArray { + fun kdf( + keySeed: ByteArray, + outputByteCount: Int, + ): ByteArray { val hkdf = HKDFBytesGenerator(SHA256Digest()) hkdf.init(HKDFParameters(keySeed, T_KEY, M_EXPAND)) return ByteArray(outputByteCount).also { hkdf.generateBytes(it, 0, it.size) } } /** - * Constant time, and it has to be. `auth` arrives from the network - * and is compared against a locally computed value; a byte-at-a-time - * comparison that returns early hands an attacker a way to construct a - * valid-looking one a byte at a time. + * Constant time, and it has to be. */ - fun authMatches(a: ByteArray, b: ByteArray): Boolean { + fun authMatches( + a: ByteArray, + b: ByteArray, + ): Boolean { if (a.size != b.size) return false var diff = 0 for (i in a.indices) diff = diff or (a[i].toInt() xor b[i].toInt()) @@ -116,7 +112,10 @@ object Ntor { return acc == 0 } - private fun hmac(key: ByteArray, message: ByteArray): ByteArray = + private fun hmac( + key: ByteArray, + message: ByteArray, + ): ByteArray = Mac.getInstance("HmacSHA256").run { init(SecretKeySpec(key, "HmacSHA256")) doFinal(message) diff --git a/obfs4/src/main/kotlin/io/flamenet/obfs4/Obfs4Socket.kt b/obfs4/src/main/kotlin/io/flamenet/obfs4/Obfs4Socket.kt index c9fc9ae..55d3822 100644 --- a/obfs4/src/main/kotlin/io/flamenet/obfs4/Obfs4Socket.kt +++ b/obfs4/src/main/kotlin/io/flamenet/obfs4/Obfs4Socket.kt @@ -11,10 +11,6 @@ import javax.net.SocketFactory /** * A bridge, as it appears in a bridge line. - * - * Parsed from `cert=` rather than taking the two halves separately, because - * `cert=` is what an operator is actually handed. Splitting it here means the - * app never has to. */ data class Obfs4Bridge( val host: String, @@ -27,9 +23,10 @@ data class Obfs4Bridge( require(identityPublic.size == 32) { "an identity key is 32 bytes" } } - override fun equals(other: Any?) = other is Obfs4Bridge && host == other.host && - port == other.port && nodeId.contentEquals(other.nodeId) && - identityPublic.contentEquals(other.identityPublic) + override fun equals(other: Any?) = + other is Obfs4Bridge && host == other.host && + port == other.port && nodeId.contentEquals(other.nodeId) && + identityPublic.contentEquals(other.identityPublic) override fun hashCode() = (host.hashCode() * 31 + port) * 31 + nodeId.contentHashCode() @@ -37,21 +34,16 @@ data class Obfs4Bridge( /** * `cert=` is base64 of `nodeId ‖ identityPublic` with its trailing `==` * stripped by lyrebird. - * - * Decodes the bare string rather than re-appending `==`. Java's - * decoder is strict about padding, and appending `==` to anything but a - * full-length cert throws for most lengths, 20, 29, 30, 50 and 51 bytes - * among them. That turned a SHORT paste, which is the ordinary mistake, - * into an exception carrying nothing anyone could act on. Decoding the - * bare form succeeds at every length and lets the size check below say - * what is actually wrong. Both forms give identical bytes for a real - * 52-byte cert, so nothing that worked before changes. - * - * For anything user-facing use [BridgeLine.parse] instead, which - * reports the reason rather than throwing. */ - fun fromCert(host: String, port: Int, cert: String): Obfs4Bridge { - val raw = java.util.Base64.getDecoder().decode(cert.trimEnd('=')) + fun fromCert( + host: String, + port: Int, + cert: String, + ): Obfs4Bridge { + val raw = + java.util.Base64 + .getDecoder() + .decode(cert.trimEnd('=')) require(raw.size == 52) { "a cert decodes to 52 bytes, not ${raw.size}" } return Obfs4Bridge(host, port, raw.copyOfRange(0, 20), raw.copyOfRange(20, 52)) } @@ -60,53 +52,62 @@ data class Obfs4Bridge( /** * A `Socket` that speaks obfs4 to a bridge and plaintext to its caller. - * - * The handshake happens inside `connect`, before any stream is handed - * out. OkHttp will start writing an HTTP request the moment it has a - * connected socket, so a handshake deferred to the first write would interleave - * with request bytes and desynchronise the frame decoder on the first request - * of every connection. - * - * A rejected handshake looks like a hang, not an error. obfs4 bridges do - * not answer what they cannot verify, so the read timeout below is what turns a - * wire-format problem into a diagnosable failure instead of a stuck app. */ -class Obfs4Socket(private val bridge: Obfs4Bridge) : Socket() { +class Obfs4Socket( + private val bridge: Obfs4Bridge, +) : Socket() { private var plane: Obfs4DataPlane? = null private val random = SecureRandom() private var pending = ByteArray(0) - override fun connect(endpoint: SocketAddress?, timeout: Int) { + override fun connect( + endpoint: SocketAddress?, + timeout: Int, + ) { // The endpoint OkHttp asks for is the RELAY's address, and it is - // deliberately ignored: the socket goes to the bridge, which proxies on. - // Connecting to the endpoint here would defeat the entire transport. + // deliberately ignored: the socket goes to the bridge, which proxies + // on. super.connect(InetSocketAddress(bridge.host, bridge.port), timeout) soTimeout = if (timeout > 0) timeout else HANDSHAKE_TIMEOUT_MILLIS performHandshake() } private fun performHandshake() { - var priv: ByteArray; var pub: ByteArray; var repr: ByteArray + var priv: ByteArray + var pub: ByteArray + var repr: ByteArray while (true) { - // Fresh entropy each attempt, never a re-tweak of a key that had - // no representative: representability is a property of the key, so + // Fresh entropy each attempt, never a re-tweak of a key that had no + // representative: representability is a property of the key, so // retrying the same one differently would bias which keys are used. val candidate = ByteArray(32).also { random.nextBytes(it) } val p = Ladder.dirtyPublicKey(candidate) val r = Elligator2.representativeForPublicKey(p, random.nextInt(256)) - if (r != null) { priv = candidate; pub = p; repr = r; break } + if (r != null) { + priv = candidate + pub = p + repr = r + break + } } - val handshake = Obfs4ClientHandshake( - bridge.nodeId, bridge.identityPublic, priv, pub, repr, - Obfs4Handshake.currentEpochHour()) + val handshake = + Obfs4ClientHandshake( + bridge.nodeId, + bridge.identityPublic, + priv, + pub, + repr, + Obfs4Handshake.currentEpochHour(), + ) - // Padding length must come from a CSPRNG. A fixed or predictable - // handshake length is a fingerprint on its own, however well disguised - // the bytes inside it are. - val padLength = Obfs4Handshake.CLIENT_MIN_PAD_LENGTH + - random.nextInt(Obfs4Handshake.CLIENT_MAX_PAD_LENGTH - - Obfs4Handshake.CLIENT_MIN_PAD_LENGTH) + // Padding length must come from a CSPRNG. + val padLength = + Obfs4Handshake.CLIENT_MIN_PAD_LENGTH + + random.nextInt( + Obfs4Handshake.CLIENT_MAX_PAD_LENGTH - + Obfs4Handshake.CLIENT_MIN_PAD_LENGTH, + ) val padding = ByteArray(minOf(padLength, 1200)).also { random.nextBytes(it) } val out = super.getOutputStream() @@ -127,14 +128,11 @@ class Obfs4Socket(private val bridge: Obfs4Bridge) : Socket() { val keys = Obfs4Session.directionKeys(session.keySeed, isClient = true) val dataPlane = Obfs4DataPlane(keys.first, keys.second) // Whatever followed the handshake in the same read is already framed - // traffic, usually the bridge's PRNG seed. Dropping it desynchronises - // the frame decoder immediately. + // traffic, usually the bridge's PRNG seed. pending = dataPlane.receive(session.leftover, isClient = true) plane = dataPlane // Counted at the ONLY point that proves the transport carried - // something. A settings screen reading the stored line would report - // protection whether or not a single byte ever went through a bridge; - // this is the difference between the preference and the fact. + // something. Obfs4Stats.recordHandshake() } @@ -143,9 +141,15 @@ class Obfs4Socket(private val bridge: Obfs4Bridge) : Socket() { val raw = super.getOutputStream() return object : OutputStream() { override fun write(b: Int) = write(byteArrayOf(b.toByte()), 0, 1) - override fun write(b: ByteArray, off: Int, len: Int) { + + override fun write( + b: ByteArray, + off: Int, + len: Int, + ) { raw.write(dataPlane.send(b.copyOfRange(off, off + len))) } + override fun flush() = raw.flush() } } @@ -162,11 +166,12 @@ class Obfs4Socket(private val bridge: Obfs4Bridge) : Socket() { return if (read(one, 0, 1) == 1) one[0].toInt() and 0xff else -1 } - override fun read(b: ByteArray, off: Int, len: Int): Int { - // Loops until a frame yields application bytes. A frame can - // decode to nothing at all, padding-only packets are cover - // traffic, and returning 0 there would look like end-of-stream - // to most callers. + override fun read( + b: ByteArray, + off: Int, + len: Int, + ): Int { + // Loops until a frame yields application bytes. while (offset >= buffered.size) { val chunk = ByteArray(16384) val n = raw.read(chunk) @@ -190,46 +195,44 @@ class Obfs4Socket(private val bridge: Obfs4Bridge) : Socket() { } /** - * The bridge said nothing, which is what a bridge does when it cannot verify - * a handshake, or when it is not there at all. The two are indistinguishable - * on purpose, so this message must not claim to know which. + * The bridge said nothing, which is what a bridge does when it cannot verify a + * handshake, or when it is not there at all. */ -class Obfs4HandshakeRefused : java.io.IOException( - "the bridge did not complete a handshake. obfs4 bridges stay silent when a handshake " + - "is invalid, so this means either the bridge line is wrong or nothing is listening.") +class Obfs4HandshakeRefused : + java.io.IOException( + "the bridge did not complete a handshake. obfs4 bridges stay silent when a handshake " + + "is invalid, so this means either the bridge line is wrong or nothing is listening.", + ) /** * Hands OkHttp obfs4 sockets. - * - * Every overload must be implemented, not just the one OkHttp happens to - * call. `SocketFactory` has four `createSocket` shapes; leaving any of them - * returning a plain socket means some code path silently bypasses the transport - * the traffic still works, which is exactly why nobody would notice. */ -class Obfs4SocketFactory(private val bridgeFor: () -> Obfs4Bridge?) : SocketFactory() { - +class Obfs4SocketFactory( + private val bridgeFor: () -> Obfs4Bridge?, +) : SocketFactory() { /** A factory pinned to one bridge, as the interop tests use it. */ constructor(bridge: Obfs4Bridge) : this({ bridge }) /** - * `null` means a PLAIN socket, not a failure. The factory is - * installed on the app's HTTP client whether or not a bridge is configured, - * because a client is built once and a setting changes later, so the - * no-bridge path has to be the ordinary direct connection, byte for byte - * what OkHttp would have done on its own. Delegating to - * `SocketFactory.getDefault()` rather than constructing a `Socket` by hand - * is what guarantees that. + * `null` means a PLAIN socket, not a failure. */ private fun plain(): SocketFactory = getDefault() - override fun createSocket(): Socket = - bridgeFor()?.let { Obfs4Socket(it) } ?: plain().createSocket() + override fun createSocket(): Socket = bridgeFor()?.let { Obfs4Socket(it) } ?: plain().createSocket() - override fun createSocket(host: String, port: Int): Socket = + override fun createSocket( + host: String, + port: Int, + ): Socket = bridgeFor()?.let { Obfs4Socket(it).apply { connect(InetSocketAddress(host, port)) } } ?: plain().createSocket(host, port) - override fun createSocket(host: String, port: Int, localHost: InetAddress, localPort: Int): Socket = + override fun createSocket( + host: String, + port: Int, + localHost: InetAddress, + localPort: Int, + ): Socket = bridgeFor()?.let { Obfs4Socket(it).apply { bind(InetSocketAddress(localHost, localPort)) @@ -237,16 +240,23 @@ class Obfs4SocketFactory(private val bridgeFor: () -> Obfs4Bridge?) : SocketFact } } ?: plain().createSocket(host, port, localHost, localPort) - override fun createSocket(host: InetAddress, port: Int): Socket = + override fun createSocket( + host: InetAddress, + port: Int, + ): Socket = bridgeFor()?.let { Obfs4Socket(it).apply { connect(InetSocketAddress(host, port)) } } ?: plain().createSocket(host, port) override fun createSocket( - address: InetAddress, port: Int, localAddress: InetAddress, localPort: Int, - ): Socket = bridgeFor()?.let { - Obfs4Socket(it).apply { - bind(InetSocketAddress(localAddress, localPort)) - connect(InetSocketAddress(address, port)) - } - } ?: plain().createSocket(address, port, localAddress, localPort) + address: InetAddress, + port: Int, + localAddress: InetAddress, + localPort: Int, + ): Socket = + bridgeFor()?.let { + Obfs4Socket(it).apply { + bind(InetSocketAddress(localAddress, localPort)) + connect(InetSocketAddress(address, port)) + } + } ?: plain().createSocket(address, port, localAddress, localPort) } diff --git a/obfs4/src/main/kotlin/io/flamenet/obfs4/Obfs4Stats.kt b/obfs4/src/main/kotlin/io/flamenet/obfs4/Obfs4Stats.kt index aef0623..1b0697c 100644 --- a/obfs4/src/main/kotlin/io/flamenet/obfs4/Obfs4Stats.kt +++ b/obfs4/src/main/kotlin/io/flamenet/obfs4/Obfs4Stats.kt @@ -5,25 +5,8 @@ import java.util.concurrent.atomic.AtomicLong /** * Whether the transport has actually carried anything. - * - * Because a settings screen must not read the preference and call it a - * status. A saved bridge line and a working bridge are different facts, and - * the gap between them is where this whole feature fails quietly: a line that - * parses but points at nothing leaves the app connecting directly, with the - * settings screen still showing the line. Only a completed handshake proves - * otherwise, so only a completed handshake is counted. - * - * This is the same counter, in production form, that the socket-factory test - * needed before it meant anything, a test fetching an upstream URL passes - * whether or not the transport was used, because a bypassed connection returns - * the same bytes. - * - * Process-lifetime only, deliberately. Persisting it would let a screen - * report "working" on the strength of a handshake from a different network - * three weeks ago, which is the reassuring answer rather than the true one. */ object Obfs4Stats { - private val completed = AtomicInteger(0) private val lastAt = AtomicLong(0L) diff --git a/obfs4/src/main/kotlin/io/flamenet/obfs4/SipHash.kt b/obfs4/src/main/kotlin/io/flamenet/obfs4/SipHash.kt index 1798001..c0954e3 100644 --- a/obfs4/src/main/kotlin/io/flamenet/obfs4/SipHash.kt +++ b/obfs4/src/main/kotlin/io/flamenet/obfs4/SipHash.kt @@ -2,17 +2,10 @@ package io.flamenet.obfs4 /** * SipHash-2-4, and obfs4's length-obfuscation DRBG. - * - * Hand-written despite BouncyCastle shipping a SipHash, which is worth - * justifying because every other primitive in this module is BouncyCastle's. - * BC's `SipHash` is a `Mac`: `doFinal` resets the state. obfs4's DRBG - * writes eight bytes, finalises, writes eight more and finalises again, - * without ever resetting. Emulating that with a resetting MAC means re-hashing - * the entire history for every block, which is O(n²) in the number of frames on - * a connection. So the state machine is written out; it is fifty lines and - * there are vectors for it. */ -class SipHash24(key: ByteArray) { +class SipHash24( + key: ByteArray, +) { private var v0: Long private var v1: Long private var v2: Long @@ -31,7 +24,10 @@ class SipHash24(key: ByteArray) { } private constructor(other: SipHash24) : this(ByteArray(16)) { - v0 = other.v0; v1 = other.v1; v2 = other.v2; v3 = other.v3 + v0 = other.v0 + v1 = other.v1 + v2 = other.v2 + v3 = other.v3 buffer = other.buffer.copyOf() totalBytes = other.totalBytes } @@ -43,7 +39,8 @@ class SipHash24(key: ByteArray) { while (buffer.size - offset >= 8) { val m = load64(buffer, offset) v3 = v3 xor m - round(); round() + round() + round() v0 = v0 xor m offset += 8 } @@ -52,21 +49,20 @@ class SipHash24(key: ByteArray) { /** * The digest so far. - * - * Finalises a COPY. The DRBG keeps writing after each read, so a - * `sum` that mutated the running state would produce a keystream correct - * for exactly one block, and the first frame of a connection would decode - * perfectly before everything after it failed. */ fun sum(): Long { val c = SipHash24(this) var b = (c.totalBytes.toLong() and 0xff) shl 56 for ((i, byte) in c.buffer.withIndex()) b = b or ((byte.toLong() and 0xff) shl (8 * i)) c.v3 = c.v3 xor b - c.round(); c.round() + c.round() + c.round() c.v0 = c.v0 xor b c.v2 = c.v2 xor 0xff - c.round(); c.round(); c.round(); c.round() + c.round() + c.round() + c.round() + c.round() return c.v0 xor c.v1 xor c.v2 xor c.v3 } @@ -77,39 +73,45 @@ class SipHash24(key: ByteArray) { } private fun round() { - v0 += v1; v1 = java.lang.Long.rotateLeft(v1, 13); v1 = v1 xor v0 + v0 += v1 + v1 = java.lang.Long.rotateLeft(v1, 13) + v1 = v1 xor v0 v0 = java.lang.Long.rotateLeft(v0, 32) - v2 += v3; v3 = java.lang.Long.rotateLeft(v3, 16); v3 = v3 xor v2 - v0 += v3; v3 = java.lang.Long.rotateLeft(v3, 21); v3 = v3 xor v0 - v2 += v1; v1 = java.lang.Long.rotateLeft(v1, 17); v1 = v1 xor v2 + v2 += v3 + v3 = java.lang.Long.rotateLeft(v3, 16) + v3 = v3 xor v2 + v0 += v3 + v3 = java.lang.Long.rotateLeft(v3, 21) + v3 = v3 xor v0 + v2 += v1 + v1 = java.lang.Long.rotateLeft(v1, 17) + v1 = v1 xor v2 v2 = java.lang.Long.rotateLeft(v2, 32) } - private fun load64(b: ByteArray, offset: Int): Long { + private fun load64( + b: ByteArray, + offset: Int, + ): Long { var v = 0L for (i in 7 downTo 0) v = (v shl 8) or (b[offset + i].toLong() and 0xff) return v } companion object { - fun hash(key: ByteArray, message: ByteArray): ByteArray = - SipHash24(key).also { it.update(message) }.sumBytes() + fun hash( + key: ByteArray, + message: ByteArray, + ): ByteArray = SipHash24(key).also { it.update(message) }.sumBytes() } } /** * obfs4's length-obfuscation DRBG: SipHash-2-4 run in OFB. - * - * The reference's comment describes a different algorithm from its code. - * The comment says `IV[n] = H(K, IV[n-1])`, a fresh hash of the previous block - * each time. The code never resets the hash, so block `n` is really - * `SipHash(K, IV[0] ‖ … ‖ IV[n-1])`, cumulative. - * - * The two agree on block one and diverge from block two. An implementation - * built from the comment decodes the first frame of a connection perfectly and - * then fails, which is about the least helpful symptom available. */ -class LengthDrbg(seed: ByteArray) { +class LengthDrbg( + seed: ByteArray, +) { private val hash: SipHash24 private var ofb: ByteArray diff --git a/obfs4/src/test/kotlin/io/flamenet/obfs4/BridgeLineTest.kt b/obfs4/src/test/kotlin/io/flamenet/obfs4/BridgeLineTest.kt index 59e1482..d55d6b7 100644 --- a/obfs4/src/test/kotlin/io/flamenet/obfs4/BridgeLineTest.kt +++ b/obfs4/src/test/kotlin/io/flamenet/obfs4/BridgeLineTest.kt @@ -9,17 +9,12 @@ import kotlin.test.assertTrue /** * Reading a pasted bridge line, and refusing one with a reason. - * - * The bug these exist for: a wrong line is indistinguishable from no - * line. Anything that fails to parse becomes "no bridge", and "no bridge" is - * a direct connection, which is exactly the path somebody pasting a bridge is - * trying to leave. Every test below is about making a failure say which failure - * it is. */ class BridgeLineTest { - private fun certOf(bytes: Int) = - java.util.Base64.getEncoder().encodeToString(ByteArray(bytes) { 0xAB.toByte() }) + java.util.Base64 + .getEncoder() + .encodeToString(ByteArray(bytes) { 0xAB.toByte() }) .trimEnd('=') private fun line( @@ -28,7 +23,7 @@ class BridgeLineTest { iatMode: String = "0", ) = "obfs4 $address 0123456789ABCDEF0123456789ABCDEF01234567 cert=$cert iat-mode=$iatMode" - // ---- Accepting what is right + // ---- Accepting what is right. @Test fun `a well formed line parses`() { @@ -41,9 +36,8 @@ class BridgeLineTest { } /** - * The fingerprint and `iat-mode` appear in every real line and are used - * by neither this client nor the check above. A parser that choked on - * unrecognised fields would reject every line an operator hands out. + * The fingerprint and `iat-mode` appear in every real line and are used by + * neither this client nor the check above. */ @Test fun `unknown fields are ignored`() { @@ -51,20 +45,21 @@ class BridgeLineTest { assertTrue(result is BridgeLineResult.Ok) } - // ---- Refusing what is wrong, and saying why + // ---- Refusing what is wrong, and saying why. @Test fun `each kind of bad line names its own problem`() { - val cases = listOf>( - "" to BridgeLineProblem.Empty, - "obfs4 cert=abc" to BridgeLineProblem.NoAddress, - line(address = "198.51.100.7:notaport") to BridgeLineProblem.BadPort("notaport"), - "obfs4 198.51.100.7:4444 0123456789ABCDEF" to BridgeLineProblem.NoCert, - // No spaces: fields split on whitespace, so a cert with a space - // is truncated at the space and is a LENGTH problem, not an - // encoding one. This is the character-invalid case. - line(cert = "!!!!not@@@@valid####base64$$$$") to BridgeLineProblem.CertNotReadable, - ) + val cases = + listOf>( + "" to BridgeLineProblem.Empty, + "obfs4 cert=abc" to BridgeLineProblem.NoAddress, + line(address = "198.51.100.7:notaport") to BridgeLineProblem.BadPort("notaport"), + "obfs4 198.51.100.7:4444 0123456789ABCDEF" to BridgeLineProblem.NoCert, + // No spaces: fields split on whitespace, so a cert with a space is + // truncated at the space and is a LENGTH problem, not an encoding + // one. + line(cert = "!!!!not@@@@valid####base64$$$$") to BridgeLineProblem.CertNotReadable, + ) for ((input, expected) in cases) { val result = BridgeLine.parse(input) assertTrue(result is BridgeLineResult.Bad, "\"$input\" should not have been accepted") @@ -74,13 +69,6 @@ class BridgeLineTest { /** * The likeliest real mistake, and the one Java's decoder got wrong. - * Bridge lines wrap in every chat app and terminal, so copying only the - * first line is ordinary. The previous decode appended `==`, which THROWS - * for most cut positions, 20, 29, 30, 50 and 51 bytes among them, so a - * short paste came back as unreadable gibberish while iOS, whose decoder is - * padding-lenient, called the same paste "cut short". Swept across cut - * positions because a single length would prove it for that cut and nothing - * else. */ @Test fun `a truncated cert is reported as truncated rather than unreadable`() { @@ -101,22 +89,22 @@ class BridgeLineTest { } /** - * Every message is read by somebody who was handed a line and told to - * paste it. Protocol vocabulary in an error tells them they are not - * qualified to fix it. + * Every message is read by somebody who was handed a line and told to paste + * it. */ @Test fun `problem messages avoid protocol jargon`() { val jargon = listOf("obfs4", "base64", "node id", "nodeid", "elligator", "ntor", "parse") - val problems = listOf( - BridgeLineProblem.Empty, - BridgeLineProblem.NoAddress, - BridgeLineProblem.BadPort("x"), - BridgeLineProblem.NoCert, - BridgeLineProblem.CertNotReadable, - BridgeLineProblem.CertWrongLength(30), - BridgeLineProblem.CertWrongLength(90), - ) + val problems = + listOf( + BridgeLineProblem.Empty, + BridgeLineProblem.NoAddress, + BridgeLineProblem.BadPort("x"), + BridgeLineProblem.NoCert, + BridgeLineProblem.CertNotReadable, + BridgeLineProblem.CertWrongLength(30), + BridgeLineProblem.CertWrongLength(90), + ) for (problem in problems) { val text = problem.message.lowercase() assertTrue(problem.message.isNotEmpty()) @@ -126,12 +114,11 @@ class BridgeLineTest { } } - // ---- iat-mode is a caveat, not a rejection + // ---- iat-mode is a caveat, not a rejection. /** * Modes 1 and 2 ask the CLIENT to space its writes; the bridge does not - * enforce them. Refusing such a line would take a working bridge away from - * somebody who has no other one. + * enforce them. */ @Test fun `an unsupported iat mode still connects and says what is missing`() { @@ -148,10 +135,7 @@ class BridgeLineTest { } /** - * The two apps are handed the same lines by the same operators. These - * sentences are duplicated in the iOS repo's `Obfs4Settings.Problem`, and - * this pins the exact strings so a change on one side is a visible decision - * rather than a silent divergence. If you edit a message, edit both. + * The two apps are handed the same lines by the same operators. */ @Test fun `messages match the ones iOS shows`() { @@ -172,7 +156,7 @@ class BridgeLineTest { ) } - // ---- The address + // ---- The address. /** Split from the LAST colon, or an IPv6 literal loses its own. */ @Test diff --git a/obfs4/src/test/kotlin/io/flamenet/obfs4/ConnectionTest.kt b/obfs4/src/test/kotlin/io/flamenet/obfs4/ConnectionTest.kt index 8dfdc42..b4e7c5e 100644 --- a/obfs4/src/test/kotlin/io/flamenet/obfs4/ConnectionTest.kt +++ b/obfs4/src/test/kotlin/io/flamenet/obfs4/ConnectionTest.kt @@ -12,26 +12,18 @@ import kotlin.test.assertTrue /** * Go's RNG semantics, the packet layer, the replay filter, and a whole session. - * - * The session tests are self-consistency, a weaker guarantee than the rest - * of this module. Everything below the connection layer is checked against - * lyrebird's own bytes; this layer is checked against itself, because - * `makePacket` is unexported and session keys are internal to a running - * connection. Two identically-wrong ends agree perfectly, the definitive check - * is a live handshake against a real bridge, which the Swift port has and this - * one does not yet. */ class ConnectionTest { - private fun randVectors(): JSONObject { val s = javaClass.classLoader.getResourceAsStream("rand-vectors.json") assertNotNull(s, "rand-vectors.json is missing") return JSONObject(s.readBytes().decodeToString()) } - private fun hex(s: String) = ByteArray(s.length / 2) { - s.substring(it * 2, it * 2 + 2).toInt(16).toByte() - } + private fun hex(s: String) = + ByteArray(s.length / 2) { + s.substring(it * 2, it * 2 + 2).toInt(16).toByte() + } // MARK: - Go RNG parity @@ -49,9 +41,8 @@ class ConnectionTest { } /** - * `intn(100)` takes the rejection-sampling branch and `intn(1)` the - * power-of-two mask branch. They consume different amounts of randomness, so - * both are covered. + * `intn(100)` takes the rejection-sampling branch and `intn(1)` the power- + * of-two mask branch. */ @Test fun `intn matches go on both branches`() { @@ -69,10 +60,8 @@ class ConnectionTest { } /** - * Go divides `Int63` by 2^63 rather than the cleaner `Int63n(1<<53)` - * form, deliberately, to preserve its value stream. Bit equality is - * asserted: a "close enough" double here is a different padding length - * later. + * Go divides `Int63` by 2^63 rather than the cleaner `Int63n(1<<53)` form, + * deliberately, to preserve its value stream. */ @Test fun `float64 matches go exactly`() { @@ -82,8 +71,11 @@ class ConnectionTest { val rng = GoRandom(hex(o.getString("seed"))) val want = o.getJSONArray("float64") for (j in 0 until want.length()) { - assertEquals(want.getDouble(j).toRawBits(), rng.float64().toRawBits(), - "draw $j differs in its bits") + assertEquals( + want.getDouble(j).toRawBits(), + rng.float64().toRawBits(), + "draw $j differs in its bits", + ) } } } @@ -108,8 +100,13 @@ class ConnectionTest { val mismatches = mutableListOf() for (i in 0 until v.length()) { val o = v.getJSONObject(i) - val d = WeightedDist(hex(o.getString("seed")), o.getInt("min"), o.getInt("max"), - o.getBoolean("biased")) + val d = + WeightedDist( + hex(o.getString("seed")), + o.getInt("min"), + o.getInt("max"), + o.getBoolean("biased"), + ) val sb = StringBuilder("[ ") for (j in d.values.indices) { if (d.weights[j] > 0.01) sb.append("%d: %f ".format(d.values[j], d.weights[j])) @@ -119,9 +116,11 @@ class ConnectionTest { mismatches += "biased=${o.getBoolean("biased")}\n want ${o.getString("string")}\n got $sb" } } - assertTrue(mismatches.isEmpty(), - "${mismatches.size} of ${v.length()} distributions differed.\n" + - mismatches.take(2).joinToString("\n")) + assertTrue( + mismatches.isEmpty(), + "${mismatches.size} of ${v.length()} distributions differed.\n" + + mismatches.take(2).joinToString("\n"), + ) } // MARK: - Packets @@ -143,13 +142,13 @@ class ConnectionTest { /** * A declared length longer than the packet is the classic parser bug, - * handed to you by whoever is on the other end. In the Swift port, removing - * this guard turned one malformed packet into a process kill. + * handed to you by whoever is on the other end. */ @Test fun `a declared length longer than the packet is rejected`() { val packet = Obfs4Packet.encode(Obfs4Packet.TYPE_PAYLOAD, byteArrayOf(1, 2, 3), 0) - packet[1] = 0xff.toByte(); packet[2] = 0xff.toByte() + packet[1] = 0xff.toByte() + packet[2] = 0xff.toByte() assertFailsWith { Obfs4Packet.decode(packet) } assertFailsWith { Obfs4Packet.decode(byteArrayOf(0, 0)) } } @@ -188,8 +187,7 @@ class ConnectionTest { // MARK: - A whole session /** - * The key halves swap by role. Two ends that both take the first half - * each work perfectly alone and cannot hear each other. + * The key halves swap by role. */ @Test fun `the direction keys are mirrored`() { @@ -251,8 +249,7 @@ class ConnectionTest { } /** - * Only a client adopts a PRNG seed. A server that honoured one would let - * any peer dictate its padding. + * Only a client adopts a PRNG seed. */ @Test fun `only the client adopts a prng seed`() { @@ -273,7 +270,10 @@ class ConnectionTest { val (client, server) = pair() val wire = client.sendPadding(500) assertTrue(wire.size > 500) - assertContentEquals(ByteArray(0), server.receive(wire, isClient = false), - "cover traffic must not surface as application data") + assertContentEquals( + ByteArray(0), + server.receive(wire, isClient = false), + "cover traffic must not surface as application data", + ) } } diff --git a/obfs4/src/test/kotlin/io/flamenet/obfs4/Elligator2Test.kt b/obfs4/src/test/kotlin/io/flamenet/obfs4/Elligator2Test.kt index b3f1e23..7bd6e20 100644 --- a/obfs4/src/test/kotlin/io/flamenet/obfs4/Elligator2Test.kt +++ b/obfs4/src/test/kotlin/io/flamenet/obfs4/Elligator2Test.kt @@ -9,29 +9,18 @@ import kotlin.test.assertTrue /** * The Kotlin map, against the same vectors the Swift port uses. - * - * These are the only check that means anything here. Elligator2 can be - * wrong without failing: a subtly incorrect map still produces 32 bytes, still - * completes a handshake, and is merely not quite uniform, which is exactly what - * a censor's entropy test looks for and exactly what no runtime symptom will - * reveal. - * - * These vectors come from lyrebird, not from the Swift port. Two - * independent implementations agreeing with the same *reference* is a real - * statement; two of our own implementations agreeing with each other is not, - * and would hide any mistake shared between them. */ class Elligator2Test { - private fun vectors(): JSONObject { val stream = javaClass.classLoader.getResourceAsStream("elligator-vectors.json") assertNotNull(stream, "the vectors are missing from test resources") return JSONObject(stream.readBytes().decodeToString()) } - private fun hex(s: String) = ByteArray(s.length / 2) { - s.substring(it * 2, it * 2 + 2).toInt(16).toByte() - } + private fun hex(s: String) = + ByteArray(s.length / 2) { + s.substring(it * 2, it * 2 + 2).toInt(16).toByte() + } private fun hex(b: ByteArray) = b.joinToString("") { "%02x".format(it) } @@ -60,13 +49,15 @@ class Elligator2Test { @Test fun `the map is not degenerate`() { val reverse = vectors().getJSONArray("reverse") - val outputs = (0 until minOf(64, reverse.length())).map { - hex( - Elligator2.publicKeyFromRepresentative( - hex(reverse.getJSONObject(it).getString("representative")) - ) - ) - }.toSet() + val outputs = + (0 until minOf(64, reverse.length())) + .map { + hex( + Elligator2.publicKeyFromRepresentative( + hex(reverse.getJSONObject(it).getString("representative")), + ), + ) + }.toSet() assertTrue(outputs.size > 60, "the map is collapsing distinct inputs onto one output") } @@ -86,9 +77,7 @@ class Elligator2Test { } /** - * The test that pins the bytes rather than merely their validity. Both - * branches are legitimate representatives of the same key, so the reference's - * own answer must be one of the two we produce. + * The test that pins the bytes rather than merely their validity. */ @Test fun `one tweak reproduces the reference representative exactly`() { @@ -96,11 +85,13 @@ class Elligator2Test { val mismatches = mutableListOf() for (i in 0 until reverse.length()) { val v = reverse.getJSONObject(i) - val candidates = listOf(0, 1).mapNotNull { tweak -> - Elligator2.representativeForPublicKey(hex(v.getString("public")), tweak) - ?.also { it[31] = (it[31].toInt() and 0x3f).toByte() } - ?.let { hex(it) } - } + val candidates = + listOf(0, 1).mapNotNull { tweak -> + Elligator2 + .representativeForPublicKey(hex(v.getString("public")), tweak) + ?.also { it[31] = (it[31].toInt() and 0x3f).toByte() } + ?.let { hex(it) } + } val want = hex(v.getString("representative")) want[31] = (want[31].toInt() and 0x3f).toByte() if (hex(want) !in candidates) { @@ -123,6 +114,7 @@ class Elligator2Test { fun `about half of all points have no representative`() { // A fixed seed, so a failure is reproducible rather than a rumour. var state = 0x9E3779B97F4A7C15uL + fun next(): ULong { state += 0x9E3779B97F4A7C15uL var z = state @@ -174,8 +166,7 @@ class Elligator2Test { @Test fun `it agrees with the Swift port, via the same reference`() { // Not a comparison against Swift, a restatement that both are pinned to - // the same lyrebird output. Kept as a named test so the relationship is - // visible rather than folklore. + // the same lyrebird output. val keygen = vectors().getJSONArray("keygen") assertTrue(keygen.length() >= 256, "the shared vector file changed shape") assertEquals( diff --git a/obfs4/src/test/kotlin/io/flamenet/obfs4/FramingTest.kt b/obfs4/src/test/kotlin/io/flamenet/obfs4/FramingTest.kt index 9f3fcad..1c8cbc4 100644 --- a/obfs4/src/test/kotlin/io/flamenet/obfs4/FramingTest.kt +++ b/obfs4/src/test/kotlin/io/flamenet/obfs4/FramingTest.kt @@ -10,30 +10,23 @@ import kotlin.test.assertTrue /** * The framing layer, against lyrebird. - * - * Standalone vectors for XSalsa20 and Poly1305 sit beside the whole-frame - * ones deliberately: without them a failing secretbox says only "one of these - * two is wrong" and the next hour goes into finding out which. */ class FramingTest { - private fun vectors(): JSONObject { val stream = javaClass.classLoader.getResourceAsStream("framing-vectors.json") assertNotNull(stream, "the framing vectors are missing") return JSONObject(stream.readBytes().decodeToString()) } - private fun hex(s: String) = ByteArray(s.length / 2) { - s.substring(it * 2, it * 2 + 2).toInt(16).toByte() - } + private fun hex(s: String) = + ByteArray(s.length / 2) { + s.substring(it * 2, it * 2 + 2).toInt(16).toByte() + } + private fun hex(b: ByteArray) = b.joinToString("") { "%02x".format(it) } /** - * This tests our CALL, not BouncyCastle's arithmetic. Poly1305 is - * vetted; what is unproven is whether the key and parameters are being - * handed over correctly, which is exactly where a wrapper goes wrong. - * Lengths straddle the 16-byte block, where the final partial block has its - * own padding rule. + * This tests our CALL, not BouncyCastle's arithmetic. */ @Test fun `poly1305 matches the reference`() { @@ -41,15 +34,22 @@ class FramingTest { val mismatches = mutableListOf() for (i in 0 until v.length()) { val o = v.getJSONObject(i) - val poly = org.bouncycastle.crypto.macs.Poly1305() - poly.init(org.bouncycastle.crypto.params.KeyParameter(hex(o.getString("key")))) + val poly = + org.bouncycastle.crypto.macs + .Poly1305() + poly.init( + org.bouncycastle.crypto.params + .KeyParameter(hex(o.getString("key"))), + ) val msg = hex(o.getString("message")) poly.update(msg, 0, msg.size) val tag = ByteArray(16).also { poly.doFinal(it, 0) } if (hex(tag) != o.getString("tag")) mismatches += "len ${msg.size}" } - assertTrue(mismatches.isEmpty(), - "${mismatches.size} Poly1305 tags wrong (${mismatches.joinToString()})") + assertTrue( + mismatches.isEmpty(), + "${mismatches.size} Poly1305 tags wrong (${mismatches.joinToString()})", + ) } /** Same rationale: this pins how Salsa20 is invoked, not that it is correct. */ @@ -60,16 +60,25 @@ class FramingTest { for (i in 0 until v.length()) { val o = v.getJSONObject(i) val want = hex(o.getString("keystream")) - val engine = org.bouncycastle.crypto.engines.Salsa20Engine() - engine.init(true, org.bouncycastle.crypto.params.ParametersWithIV( - org.bouncycastle.crypto.params.KeyParameter(hex(o.getString("key"))), - hex(o.getString("nonce")))) + val engine = + org.bouncycastle.crypto.engines + .Salsa20Engine() + engine.init( + true, + org.bouncycastle.crypto.params.ParametersWithIV( + org.bouncycastle.crypto.params + .KeyParameter(hex(o.getString("key"))), + hex(o.getString("nonce")), + ), + ) val got = ByteArray(want.size) engine.processBytes(ByteArray(want.size), 0, want.size, got, 0) if (!got.contentEquals(want)) mismatches += "len ${want.size}" } - assertTrue(mismatches.isEmpty(), - "${mismatches.size} Salsa20 keystreams wrong (${mismatches.joinToString()})") + assertTrue( + mismatches.isEmpty(), + "${mismatches.size} Salsa20 keystreams wrong (${mismatches.joinToString()})", + ) } @Test @@ -78,16 +87,22 @@ class FramingTest { val mismatches = mutableListOf() for (i in 0 until v.length()) { val o = v.getJSONObject(i) - val got = Secretbox.seal(hex(o.getString("plaintext")), - hex(o.getString("nonce")), hex(o.getString("key"))) + val got = + Secretbox.seal( + hex(o.getString("plaintext")), + hex(o.getString("nonce")), + hex(o.getString("key")), + ) if (hex(got) != o.getString("box")) { mismatches += "len ${o.getString("plaintext").length / 2}" } } - assertTrue(mismatches.isEmpty(), - "${mismatches.size} secretboxes wrong (${mismatches.joinToString()}). " + - "If the primitives pass on their own, the fault is the 32-byte " + - "stream offset or the HSalsa20 subkey.") + assertTrue( + mismatches.isEmpty(), + "${mismatches.size} secretboxes wrong (${mismatches.joinToString()}). " + + "If the primitives pass on their own, the fault is the 32-byte " + + "stream offset or the HSalsa20 subkey.", + ) } @Test @@ -95,9 +110,16 @@ class FramingTest { val v = vectors().getJSONArray("secretbox") for (i in 0 until v.length()) { val o = v.getJSONObject(i) - assertEquals(o.getString("plaintext"), - hex(Secretbox.open(hex(o.getString("box")), hex(o.getString("nonce")), - hex(o.getString("key")))!!)) + assertEquals( + o.getString("plaintext"), + hex( + Secretbox.open( + hex(o.getString("box")), + hex(o.getString("nonce")), + hex(o.getString("key")), + )!!, + ), + ) } } @@ -107,32 +129,38 @@ class FramingTest { val v = vectors().getJSONArray("secretbox") var o: JSONObject? = null for (i in 0 until v.length()) { - if (v.getJSONObject(i).getString("plaintext").isNotEmpty()) { o = v.getJSONObject(i); break } + if (v.getJSONObject(i).getString("plaintext").isNotEmpty()) { + o = v.getJSONObject(i) + break + } } assertNotNull(o) for (pos in listOf(0, 8, 15, 16, 17)) { val box = hex(o.getString("box")) if (pos >= box.size) continue box[pos] = (box[pos].toInt() xor 1).toByte() - assertNull(Secretbox.open(box, hex(o.getString("nonce")), hex(o.getString("key"))), - "a box with byte $pos flipped must not open") + assertNull( + Secretbox.open(box, hex(o.getString("nonce")), hex(o.getString("key"))), + "a box with byte $pos flipped must not open", + ) } val nonce = hex(o.getString("nonce")).also { it[0] = (it[0].toInt() xor 1).toByte() } - assertNull(Secretbox.open(hex(o.getString("box")), nonce, hex(o.getString("key"))), - "the wrong nonce must not open the box") + assertNull( + Secretbox.open(hex(o.getString("box")), nonce, hex(o.getString("key"))), + "the wrong nonce must not open the box", + ) } /** - * A run of frames, not one frame. The nonce counter and the DRBG both - * advance per frame, so a single-frame test passes for an implementation - * whose counter never increments and whose length mask is recomputed from - * scratch every time. + * A run of frames, not one frame. */ @Test fun `frame runs match lyrebird byte for byte`() { val runs = vectors().getJSONArray("frames") - assertTrue(runs.getJSONObject(0).getJSONArray("frames").length() > 3, - "too few frames per run") + assertTrue( + runs.getJSONObject(0).getJSONArray("frames").length() > 3, + "too few frames per run", + ) val mismatches = mutableListOf() for (i in 0 until runs.length()) { @@ -148,10 +176,12 @@ class FramingTest { } } } - assertTrue(mismatches.isEmpty(), - "${mismatches.size} of ${runs.length()} runs diverged. A failure at frame 0 " + - "is the secretbox or the first length mask; at frame 1+ it is the nonce " + - "counter or the DRBG advance.\n" + mismatches.take(2).joinToString("\n")) + assertTrue( + mismatches.isEmpty(), + "${mismatches.size} of ${runs.length()} runs diverged. A failure at frame 0 " + + "is the secretbox or the first length mask; at frame 1+ it is the nonce " + + "counter or the DRBG advance.\n" + mismatches.take(2).joinToString("\n"), + ) } @Test @@ -172,9 +202,7 @@ class FramingTest { } /** - * Real sockets deliver frames in pieces. One byte at a time must give the - * same result, and must not advance the DRBG twice on a length read across - * two reads. + * Real sockets deliver frames in pieces. */ @Test fun `decoding survives byte-at-a-time delivery`() { @@ -189,8 +217,11 @@ class FramingTest { for (b in wire) { buffer.add(b) while (true) { - try { decoded.add(hex(dec.decode(buffer))) } - catch (e: FrameDecoder.NeedMoreData) { break } + try { + decoded.add(hex(dec.decode(buffer))) + } catch (e: FrameDecoder.NeedMoreData) { + break + } } } val payloads = run.getJSONArray("payloads") diff --git a/obfs4/src/test/kotlin/io/flamenet/obfs4/HandshakeTest.kt b/obfs4/src/test/kotlin/io/flamenet/obfs4/HandshakeTest.kt index f94f512..feb171a 100644 --- a/obfs4/src/test/kotlin/io/flamenet/obfs4/HandshakeTest.kt +++ b/obfs4/src/test/kotlin/io/flamenet/obfs4/HandshakeTest.kt @@ -9,46 +9,35 @@ import kotlin.test.assertTrue /** * The handshake messages, against handshakes captured off the wire. - * - * These vectors are recordings, not computed values. The handshake - * contains random padding chosen inside lyrebird and a MAC over the current - * epoch hour, so there is nothing to ask a reference to compute. The generator - * runs a real lyrebird client against a real lyrebird server over a pipe, - * proves they round-trip actual traffic, and records what each side sent. - * - * The padding is recoverable, which makes these a test of generation too. - * It sits between a fixed-length prefix and the mark, so finding the mark - * recovers it, and rebuilding from the recovered representative, padding and - * hour must reproduce the recording byte for byte. */ class HandshakeTest { - private fun vectors(): JSONObject { val stream = javaClass.classLoader.getResourceAsStream("handshake-vectors.json") assertNotNull(stream, "the handshake vectors are missing") return JSONObject(stream.readBytes().decodeToString()) } - private fun hex(s: String) = ByteArray(s.length / 2) { - s.substring(it * 2, it * 2 + 2).toInt(16).toByte() - } + private fun hex(s: String) = + ByteArray(s.length / 2) { + s.substring(it * 2, it * 2 + 2).toInt(16).toByte() + } + private fun hex(b: ByteArray) = b.joinToString("") { "%02x".format(it) } - private fun key(o: JSONObject) = - Obfs4Handshake.macKey(hex(o.getString("server_public_key")), hex(o.getString("node_id"))) + private fun key(o: JSONObject) = Obfs4Handshake.macKey(hex(o.getString("server_public_key")), hex(o.getString("node_id"))) /** * Feed bytes until the client handshake parses, the way a server reads a - * socket. The recording contains the client's later traffic too, so there - * is exactly one prefix length at which the tail check succeeds. + * socket. */ private fun parseIncrementally(o: JSONObject): Pair? { val wire = hex(o.getString("client_to_server")) for (n in Obfs4Handshake.CLIENT_MIN_HANDSHAKE_LENGTH..wire.size) { try { - return Obfs4Handshake.parseClientMessage( - wire.copyOfRange(0, n), key(o), o.getLong("epoch_hour")) to n - } catch (_: Exception) { /* keep reading */ } + return Obfs4Handshake.parseClientMessage(wire.copyOfRange(0, n), key(o), o.getLong("epoch_hour")) to n + } catch (_: Exception) { + // keep reading + } } return null } @@ -61,23 +50,27 @@ class HandshakeTest { for (i in 0 until v.length()) { val o = v.getJSONObject(i) val parsed = parseIncrementally(o) - if (parsed == null) { failures += "node ${o.getString("node_id").take(12)}…"; continue } + if (parsed == null) { + failures += "node ${o.getString("node_id").take(12)}…" + continue + } val (p, n) = parsed assertEquals(n, p.consumed) - assertTrue(p.padding.size >= Obfs4Handshake.CLIENT_MIN_PAD_LENGTH && - p.padding.size <= Obfs4Handshake.CLIENT_MAX_PAD_LENGTH, - "padding ${p.padding.size} outside the permitted range") - assertTrue(n < hex(o.getString("client_to_server")).size, - "the handshake swallowed the traffic that followed it") + assertTrue( + p.padding.size >= Obfs4Handshake.CLIENT_MIN_PAD_LENGTH && + p.padding.size <= Obfs4Handshake.CLIENT_MAX_PAD_LENGTH, + "padding ${p.padding.size} outside the permitted range", + ) + assertTrue( + n < hex(o.getString("client_to_server")).size, + "the handshake swallowed the traffic that followed it", + ) } assertTrue(failures.isEmpty(), "${failures.size} never parsed: $failures") } /** - * The client must substring-search. The server sends framed data - * immediately after its handshake, so the mark is not at the tail, a tail - * check here fails against every real bridge, and these vectors contain - * exactly that trailing data. + * The client must substring-search. */ @Test fun `the client side parses real server handshakes`() { @@ -94,15 +87,15 @@ class HandshakeTest { failures += "node ${o.getString("node_id").take(12)}…: $e" } } - assertTrue(failures.isEmpty(), "${failures.size} did not parse:\n" + - failures.take(3).joinToString("\n")) + assertTrue( + failures.isEmpty(), + "${failures.size} did not parse:\n" + + failures.take(3).joinToString("\n"), + ) } /** - * The generation test. Rebuilding from the values recovered by - * parsing must reproduce lyrebird's bytes exactly, proving the mark, the - * MAC, the field order and the decimal epoch encoding all match, not merely - * that parsing is self-consistent. + * The generation test. */ @Test fun `regenerating reproduces lyrebirds bytes`() { @@ -127,8 +120,8 @@ class HandshakeTest { val o = v.getJSONObject(i) val wire = hex(o.getString("server_to_client")) val p = Obfs4Handshake.parseServerMessage(wire, key(o), o.getLong("epoch_hour")) - val rebuilt = Obfs4Handshake.serverMessage( - p.representative, p.auth, p.padding, key(o), o.getLong("epoch_hour")) + val rebuilt = + Obfs4Handshake.serverMessage(p.representative, p.auth, p.padding, key(o), o.getLong("epoch_hour")) if (!rebuilt.contentEquals(wire.copyOfRange(0, p.consumed))) mismatches++ } assertEquals(0, mismatches, "$mismatches server handshakes did not regenerate") @@ -152,7 +145,6 @@ class HandshakeTest { /** * A scanner without the bridge's public key cannot produce a valid mark. - * This is the whole of obfs4's probe resistance. */ @Test fun `a wrong identity key never finds the mark`() { @@ -169,9 +161,7 @@ class HandshakeTest { /** * `NeedMoreData` and `InvalidHandshake` are different answers: the first - * keeps the connection open and silent, the second closes it. A lengthened - * buffer reads as unfinished, which is the silence probe resistance depends - * on. + * keeps the connection open and silent, the second closes it. */ @Test fun `trailing bytes read as an unfinished handshake, not a rejection`() { @@ -180,8 +170,11 @@ class HandshakeTest { val valid = hex(o.getString("client_to_server")).copyOfRange(0, n) for (extra in listOf(1, 2, 64, 500)) { assertFailsWith("$extra trailing bytes") { - Obfs4Handshake.parseClientMessage(valid + ByteArray(extra) { 0x41 }, - key(o), o.getLong("epoch_hour")) + Obfs4Handshake.parseClientMessage( + valid + ByteArray(extra) { 0x41 }, + key(o), + o.getLong("epoch_hour"), + ) } } } @@ -192,7 +185,9 @@ class HandshakeTest { assertFailsWith { Obfs4Handshake.parseClientMessage( hex(o.getString("client_to_server")).copyOfRange(0, 10), - key(o), o.getLong("epoch_hour")) + key(o), + o.getLong("epoch_hour"), + ) } } @@ -204,6 +199,7 @@ class HandshakeTest { fun `the client padding floor covers the server handshake`() { assertTrue( Obfs4Handshake.CLIENT_MIN_HANDSHAKE_LENGTH + Obfs4Handshake.CLIENT_MIN_PAD_LENGTH >= - Obfs4Handshake.SERVER_MIN_HANDSHAKE_LENGTH + Obfs4Handshake.INLINE_SEED_FRAME_LENGTH) + Obfs4Handshake.SERVER_MIN_HANDSHAKE_LENGTH + Obfs4Handshake.INLINE_SEED_FRAME_LENGTH, + ) } } diff --git a/obfs4/src/test/kotlin/io/flamenet/obfs4/InteropTest.kt b/obfs4/src/test/kotlin/io/flamenet/obfs4/InteropTest.kt index 638c1d5..41938a8 100644 --- a/obfs4/src/test/kotlin/io/flamenet/obfs4/InteropTest.kt +++ b/obfs4/src/test/kotlin/io/flamenet/obfs4/InteropTest.kt @@ -1,59 +1,41 @@ package io.flamenet.obfs4 +import org.json.JSONObject import java.io.File import java.net.InetSocketAddress import java.net.ServerSocket import java.net.Socket import java.net.SocketTimeoutException import java.security.SecureRandom -import org.json.JSONObject import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertTrue /** - * Handshakes against a real lyrebird bridge, and a real lyrebird client - * against this one. - * - * These are the only tests here that prove interoperability. Everything - * else compares against recorded reference output or against itself, and a - * Kotlin-to-Kotlin test cannot catch a mistake both ends share, two - * identically-wrong implementations agree perfectly. Here the counterparty is - * the Tor Project's own code, deciding for itself whether to answer. - * - * A bug shows up as SILENCE, not an error. obfs4 is probe-resistant: a - * bridge that dislikes a handshake says nothing at all. So every read has a - * timeout and a timeout is reported as *"the bridge refused to answer"*, the - * one diagnosis that is actually true, rather than as a network fault. - * - * Both directions are needed. Swapping the framing key halves by role is - * fatal for a server and leaves the client test green, because a client takes - * the first half either way. Only the reverse direction catches it. - * - * Run with `scripts/obfs4-interop.sh`, which builds and starts the counterparty. + * Handshakes against a real lyrebird bridge, and a real lyrebird client against + * this one. */ class InteropTest { - private val random = SecureRandom() /** - * Never skips quietly. A silent skip is how a suite reports success - * for a test that has not run in months. Absent configuration is a hard - * failure unless the environment explicitly opts out, which forces the - * decision to be visible in the runner rather than buried here. + * Never skips quietly. */ private fun env(name: String): String? { System.getenv(name)?.let { return it } if (System.getenv("OBFS4_INTEROP_OPTOUT") == "1") return null throw AssertionError( "$name is not set and OBFS4_INTEROP_OPTOUT=1 was not given either. " + - "Run scripts/obfs4-interop.sh, or opt out explicitly.") + "Run scripts/obfs4-interop.sh, or opt out explicitly.", + ) } - private fun hex(s: String) = ByteArray(s.length / 2) { - s.substring(it * 2, it * 2 + 2).toInt(16).toByte() - } + private fun hex(s: String) = + ByteArray(s.length / 2) { + s.substring(it * 2, it * 2 + 2).toInt(16).toByte() + } + private fun hex(b: ByteArray) = b.joinToString("") { "%02x".format(it) } private fun representableKeypair(): Triple { @@ -72,13 +54,24 @@ class InteropTest { } } - private fun readSome(socket: Socket, stage: String): ByteArray { + private fun readSome( + socket: Socket, + stage: String, + ): ByteArray { val buf = ByteArray(16384) - val n = try { socket.getInputStream().read(buf) } catch (e: SocketTimeoutException) { -1 } - if (n <= 0) throw AssertionError( - "the bridge refused to answer during $stage. obfs4 bridges stay silent when a " + - "handshake is invalid, so this is what a wire-format bug looks like, not a " + - "network fault.") + val n = + try { + socket.getInputStream().read(buf) + } catch (e: SocketTimeoutException) { + -1 + } + if (n <= 0) { + throw AssertionError( + "the bridge refused to answer during $stage. obfs4 bridges stay silent when a " + + "handshake is invalid, so this is what a wire-format bug looks like, not a " + + "network fault.", + ) + } return buf.copyOfRange(0, n) } @@ -93,8 +86,8 @@ class InteropTest { val address = params.getString("address").split(":") val (priv, pub, repr) = representableKeypair() - val handshake = Obfs4ClientHandshake( - nodeId, identity, priv, pub, repr, Obfs4Handshake.currentEpochHour()) + val handshake = + Obfs4ClientHandshake(nodeId, identity, priv, pub, repr, Obfs4Handshake.currentEpochHour()) val padLength = Obfs4Handshake.CLIENT_MIN_PAD_LENGTH + random.nextInt(500) val padding = ByteArray(padLength).also { random.nextBytes(it) } @@ -102,7 +95,8 @@ class InteropTest { Socket().use { socket -> socket.connect(InetSocketAddress(address[0], address[1].toInt()), 8000) // Without a read timeout a rejected handshake hangs the suite - // forever, because the bridge's correct behaviour is to say nothing. + // forever, because the bridge's correct behaviour is to say + // nothing. socket.soTimeout = 8000 socket.getOutputStream().write(handshake.opening(padding)) socket.getOutputStream().flush() @@ -117,8 +111,7 @@ class InteropTest { val keys = Obfs4Session.directionKeys(established.keySeed, isClient = true) val plane = Obfs4DataPlane(keys.first, keys.second) // Whatever followed the handshake in the same read is already - // framed traffic, usually the bridge's PRNG seed. Dropping it - // desynchronises the frame decoder immediately. + // framed traffic, usually the bridge's PRNG seed. plane.receive(established.leftover, isClient = true) val message = "flamenet obfs4 kotlin interop".toByteArray() @@ -130,14 +123,16 @@ class InteropTest { if (echoed.size >= message.size) break echoed += plane.receive(readSome(socket, "the echo"), isClient = true) } - assertEquals(hex(message), hex(echoed), - "the bridge accepted the handshake but the data plane disagreed") + assertEquals( + hex(message), + hex(echoed), + "the bridge accepted the handshake but the data plane disagreed", + ) } } /** - * The mirror image: a handshake the bridge should refuse. It must go - * silent rather than answer, which is what probe resistance means. + * The mirror image: a handshake the bridge should refuse. */ @Test fun `a bridge ignores a handshake it cannot verify`() { @@ -150,46 +145,66 @@ class InteropTest { socket.soTimeout = 6000 socket.getOutputStream().write(ByteArray(700).also { random.nextBytes(it) }) socket.getOutputStream().flush() - val n = try { socket.getInputStream().read(ByteArray(1024)) } - catch (e: SocketTimeoutException) { -1 } - assertTrue(n <= 0, - "the bridge replied to an invalid handshake with $n bytes. A bridge that " + - "answers unverified clients is one a scanner can confirm exists.") + val n = + try { + socket.getInputStream().read(ByteArray(1024)) + } catch (e: SocketTimeoutException) { + -1 + } + assertTrue( + n <= 0, + "the bridge replied to an invalid handshake with $n bytes. A bridge that " + + "answers unverified clients is one a scanner can confirm exists.", + ) } } // MARK: - Real lyrebird client → Kotlin bridge /** - * The only thing that can prove the server role. The client test - * stays green through bugs that are fatal for a server. + * The only thing that can prove the server role. */ @Test fun `a real lyrebird client accepts the kotlin bridge`() { val clientPath = env("OBFS4_INTEROP_CLIENT") ?: return val identityPrivate = ByteArray(32).also { random.nextBytes(it) } - // CLEAN, not dirty. Only session keys keep their cofactor, because - // only those travel as representatives. + // CLEAN, not dirty. val identityPublic = Ladder.publicKey(identityPrivate) val nodeId = ByteArray(20).also { random.nextBytes(it) } val (sessionPriv, sessionPub, sessionRepr) = representableKeypair() val filter = ReplayFilter() ServerSocket(0, 4, java.net.InetAddress.getByName("127.0.0.1")).use { listener -> - val cert = java.util.Base64.getEncoder() - .encodeToString(nodeId + identityPublic).dropLast(2) + val cert = + java.util.Base64 + .getEncoder() + .encodeToString(nodeId + identityPublic) + .dropLast(2) val message = "kotlin bridge, real client" - val process = ProcessBuilder( - clientPath, "-address", "127.0.0.1:${listener.localPort}", - "-cert", cert, "-message", message, - ).redirectErrorStream(true).start() + val process = + ProcessBuilder( + clientPath, + "-address", + "127.0.0.1:${listener.localPort}", + "-cert", + cert, + "-message", + message, + ).redirectErrorStream(true).start() listener.accept().use { conn -> conn.soTimeout = 15000 - val handshake = Obfs4ServerHandshake( - nodeId, identityPrivate, identityPublic, - sessionPriv, sessionPub, sessionRepr, Obfs4Handshake.currentEpochHour()) + val handshake = + Obfs4ServerHandshake( + nodeId, + identityPrivate, + identityPublic, + sessionPriv, + sessionPub, + sessionRepr, + Obfs4Handshake.currentEpochHour(), + ) val padding = ByteArray(random.nextInt(400)).also { random.nextBytes(it) } var accepted: Obfs4ServerHandshake.Accepted? = null @@ -205,14 +220,16 @@ class InteropTest { val plane = Obfs4DataPlane(keys.first, keys.second) // Faithful to the reference: a bridge's first frame hands the // client a seed so both ends pad from the same distribution. - conn.getOutputStream().write( - plane.sendPrngSeed(ByteArray(24).also { random.nextBytes(it) })) + conn.getOutputStream().write(plane.sendPrngSeed(ByteArray(24).also { random.nextBytes(it) })) conn.getOutputStream().flush() var echoed = 0 while (echoed < message.length) { - val payload = plane.receive(readSome(conn, "the client's traffic"), - isClient = false) + val payload = + plane.receive( + readSome(conn, "the client's traffic"), + isClient = false, + ) if (payload.isNotEmpty()) { conn.getOutputStream().write(plane.send(payload, 63)) conn.getOutputStream().flush() diff --git a/obfs4/src/test/kotlin/io/flamenet/obfs4/LadderTest.kt b/obfs4/src/test/kotlin/io/flamenet/obfs4/LadderTest.kt index bb5da9a..bd13d18 100644 --- a/obfs4/src/test/kotlin/io/flamenet/obfs4/LadderTest.kt +++ b/obfs4/src/test/kotlin/io/flamenet/obfs4/LadderTest.kt @@ -8,32 +8,24 @@ import kotlin.test.assertTrue /** * Cofactor-dirty scalar base multiplication, against lyrebird. - * - * The wrong answer sits right next to the right one. The clean X25519 - * public key, what every library computes, is a perfectly good Curve25519 - * key. It agrees on shared secrets. It round-trips. It is simply confined to - * the prime-order subgroup and therefore *distinguishable*, which defeats the - * entire purpose. Clamping the private key before the call is all it takes to - * get one, so this checks structure as well as bytes. */ class LadderTest { - private fun vectors(): JSONObject { val stream = javaClass.classLoader.getResourceAsStream("elligator-vectors.json") assertNotNull(stream, "the vectors are missing from test resources") return JSONObject(stream.readBytes().decodeToString()) } - private fun hex(s: String) = ByteArray(s.length / 2) { - s.substring(it * 2, it * 2 + 2).toInt(16).toByte() - } + private fun hex(s: String) = + ByteArray(s.length / 2) { + s.substring(it * 2, it * 2 + 2).toInt(16).toByte() + } + private fun hex(b: ByteArray) = b.joinToString("") { "%02x".format(it) } /** * The `private` field is the 32 bytes lyrebird actually fed to * `ScalarBaseMult`, already past its SHA-512 pass, and *not* clamped. - * Clamping before this call would clear the cofactor and quietly turn this - * into the clean function. */ @Test fun `the dirty public key matches lyrebird`() { @@ -57,10 +49,7 @@ class LadderTest { } /** - * The structural check. If the cofactor were being cleared this would be - * the clean key, so it must differ from the clean key exactly when the low - * three bits of the private key are non-zero, and match it exactly when - * they are not. + * The structural check. */ @Test fun `it differs from the clean key exactly when the cofactor is set`() { @@ -82,8 +71,8 @@ class LadderTest { } } assertTrue(wrong.isEmpty(), "${wrong.size} keys had the wrong relationship to the clean key") - // Both arms must actually occur, or the assertion above is vacuous - // for one of them. + // Both arms must actually occur, or the assertion above is vacuous for + // one of them. assertTrue(withCofactor > 10, "no keys with a cofactor component") assertTrue(without > 10, "no keys without a cofactor component") } @@ -95,13 +84,16 @@ class LadderTest { for (i in 0 until keygen.length()) { val v = keygen.getJSONObject(i) val pub = Ladder.dirtyPublicKey(hex(v.getString("private"))) - val want = hex(v.getString("representative")) - .also { it[31] = (it[31].toInt() and 0x3f).toByte() } - val matched = listOf(0, 1).any { tweak -> - Elligator2.representativeForPublicKey(pub, tweak) - ?.also { it[31] = (it[31].toInt() and 0x3f).toByte() } - ?.contentEquals(want) == true - } + val want = + hex(v.getString("representative")) + .also { it[31] = (it[31].toInt() and 0x3f).toByte() } + val matched = + listOf(0, 1).any { tweak -> + Elligator2 + .representativeForPublicKey(pub, tweak) + ?.also { it[31] = (it[31].toInt() and 0x3f).toByte() } + ?.contentEquals(want) == true + } if (!matched || hex(pub) != v.getString("public")) mismatches++ } assertEquals(0, mismatches, "$mismatches keypairs did not match lyrebird end to end") @@ -119,8 +111,10 @@ class LadderTest { @Test fun `adding the group order is driven by the low three bits`() { val s = ByteArray(32) { 0x11 } - assertTrue(Ladder.addGroupOrderTimes(0xf8, s).contentEquals(s), - "low three bits clear must leave the scalar alone") + assertTrue( + Ladder.addGroupOrderTimes(0xf8, s).contentEquals(s), + "low three bits clear must leave the scalar alone", + ) for (x in 1..7) { assertTrue(!Ladder.addGroupOrderTimes(x, s).contentEquals(s), "x=$x must change it") } diff --git a/obfs4/src/test/kotlin/io/flamenet/obfs4/NtorTest.kt b/obfs4/src/test/kotlin/io/flamenet/obfs4/NtorTest.kt index 12a5452..eb5cbaa 100644 --- a/obfs4/src/test/kotlin/io/flamenet/obfs4/NtorTest.kt +++ b/obfs4/src/test/kotlin/io/flamenet/obfs4/NtorTest.kt @@ -10,26 +10,19 @@ import kotlin.test.assertTrue /** * The ntor handshake, against lyrebird, from both sides. - * - * This is the suite that decides whether the port can talk to a real - * bridge. Everything below it can be right while this is wrong: the maps - * round-trip, the keys are correct, and the handshake still derives a KEY_SEED - * no bridge in the world shares. obfs4 orders its transcript - * `B | B | X | Y | PROTOID | ID`, `B` twice, while the comment immediately - * above it in the reference claims `ID | B | Y | X | PROTOID`. Both orderings - * produce a well-formed 32-byte result. Only one interoperates. */ class NtorTest { - private fun vectors(): JSONObject { val stream = javaClass.classLoader.getResourceAsStream("elligator-vectors.json") assertNotNull(stream, "the vectors are missing from test resources") return JSONObject(stream.readBytes().decodeToString()) } - private fun hex(s: String) = ByteArray(s.length / 2) { - s.substring(it * 2, it * 2 + 2).toInt(16).toByte() - } + private fun hex(s: String) = + ByteArray(s.length / 2) { + s.substring(it * 2, it * 2 + 2).toInt(16).toByte() + } + private fun hex(b: ByteArray) = b.joinToString("") { "%02x".format(it) } @Test @@ -39,21 +32,29 @@ class NtorTest { val mismatches = mutableListOf() for (i in 0 until v.length()) { val o = v.getJSONObject(i) - val r = Ntor.clientHandshake( - hex(o.getString("client_private")), hex(o.getString("client_public")), - hex(o.getString("server_public")), hex(o.getString("id_public")), - hex(o.getString("node_id")), - ) - if (r == null) { mismatches += "node ${o.getString("node_id")}: refused"; continue } + val r = + Ntor.clientHandshake( + hex(o.getString("client_private")), + hex(o.getString("client_public")), + hex(o.getString("server_public")), + hex(o.getString("id_public")), + hex(o.getString("node_id")), + ) + if (r == null) { + mismatches += "node ${o.getString("node_id")}: refused" + continue + } if (hex(r.keySeed) != o.getString("key_seed")) { mismatches += "node ${o.getString("node_id")} KEY_SEED\n" + " want ${o.getString("key_seed")}\n got ${hex(r.keySeed)}" } if (hex(r.auth) != o.getString("auth")) mismatches += "AUTH mismatch" } - assertTrue(mismatches.isEmpty(), - "${mismatches.size} of ${v.length()} client handshakes disagreed:\n" + - mismatches.take(2).joinToString("\n")) + assertTrue( + mismatches.isEmpty(), + "${mismatches.size} of ${v.length()} client handshakes disagreed:\n" + + mismatches.take(2).joinToString("\n"), + ) } @Test @@ -62,39 +63,52 @@ class NtorTest { var mismatches = 0 for (i in 0 until v.length()) { val o = v.getJSONObject(i) - val r = Ntor.serverHandshake( - hex(o.getString("client_public")), - hex(o.getString("server_private")), hex(o.getString("server_public")), - hex(o.getString("id_private")), hex(o.getString("id_public")), - hex(o.getString("node_id")), - ) + val r = + Ntor.serverHandshake( + hex(o.getString("client_public")), + hex(o.getString("server_private")), + hex(o.getString("server_public")), + hex(o.getString("id_private")), + hex(o.getString("id_public")), + hex(o.getString("node_id")), + ) if (r == null || hex(r.keySeed) != o.getString("key_seed") || hex(r.auth) != o.getString("auth") - ) mismatches++ + ) { + mismatches++ + } } assertEquals(0, mismatches, "$mismatches server handshakes disagreed with lyrebird") } /** * Both sides agreeing with each other is not the same as either being - * right, two identically-wrong implementations agree perfectly. This runs - * alongside the reference comparisons, never instead of them. + * right, two identically-wrong implementations agree perfectly. */ @Test fun `the two sides agree with each other`() { val v = vectors().getJSONArray("ntor") for (i in 0 until v.length()) { val o = v.getJSONObject(i) - val c = Ntor.clientHandshake( - hex(o.getString("client_private")), hex(o.getString("client_public")), - hex(o.getString("server_public")), hex(o.getString("id_public")), - hex(o.getString("node_id"))) - val s = Ntor.serverHandshake( - hex(o.getString("client_public")), - hex(o.getString("server_private")), hex(o.getString("server_public")), - hex(o.getString("id_private")), hex(o.getString("id_public")), - hex(o.getString("node_id"))) - assertNotNull(c); assertEquals(c, s, "the two sides disagreed") + val c = + Ntor.clientHandshake( + hex(o.getString("client_private")), + hex(o.getString("client_public")), + hex(o.getString("server_public")), + hex(o.getString("id_public")), + hex(o.getString("node_id")), + ) + val s = + Ntor.serverHandshake( + hex(o.getString("client_public")), + hex(o.getString("server_private")), + hex(o.getString("server_public")), + hex(o.getString("id_private")), + hex(o.getString("id_public")), + hex(o.getString("node_id")), + ) + assertNotNull(c) + assertEquals(c, s, "the two sides disagreed") } } @@ -111,34 +125,52 @@ class NtorTest { /** * A peer that sends a low-order point drives every shared secret to zero. - * Keying from that is keying from a value the attacker chose. */ @Test fun `low order points are rejected`() { val o = vectors().getJSONArray("ntor").getJSONObject(0) val identity = ByteArray(32) - assertNull(Ntor.clientHandshake( - hex(o.getString("client_private")), hex(o.getString("client_public")), - identity, hex(o.getString("id_public")), hex(o.getString("node_id"))), - "a zero server key must be refused") - assertNull(Ntor.serverHandshake( - identity, hex(o.getString("server_private")), hex(o.getString("server_public")), - hex(o.getString("id_private")), hex(o.getString("id_public")), - hex(o.getString("node_id"))), - "a zero client key must be refused") + assertNull( + Ntor.clientHandshake( + hex(o.getString("client_private")), + hex(o.getString("client_public")), + identity, + hex(o.getString("id_public")), + hex(o.getString("node_id")), + ), + "a zero server key must be refused", + ) + assertNull( + Ntor.serverHandshake( + identity, + hex(o.getString("server_private")), + hex(o.getString("server_public")), + hex(o.getString("id_private")), + hex(o.getString("id_public")), + hex(o.getString("node_id")), + ), + "a zero client key must be refused", + ) } /** - * `clientPublic` and `nodeId` do not affect the shared secrets at all, - * they enter only through the transcript. If the suffix were built wrongly - * they could vanish without any other test noticing. + * `clientPublic` and `nodeId` do not affect the shared secrets at all, they + * enter only through the transcript. */ @Test fun `every input is bound into the transcript`() { val o = vectors().getJSONArray("ntor").getJSONObject(0) - fun run(pub: ByteArray, id: ByteArray) = Ntor.clientHandshake( - hex(o.getString("client_private")), pub, - hex(o.getString("server_public")), hex(o.getString("id_public")), id) + + fun run( + pub: ByteArray, + id: ByteArray, + ) = Ntor.clientHandshake( + hex(o.getString("client_private")), + pub, + hex(o.getString("server_public")), + hex(o.getString("id_public")), + id, + ) val base = run(hex(o.getString("client_public")), hex(o.getString("node_id"))) val otherPub = hex(o.getString("client_public")).also { it[0] = (it[0].toInt() xor 1).toByte() } diff --git a/obfs4/src/test/kotlin/io/flamenet/obfs4/SipHashTest.kt b/obfs4/src/test/kotlin/io/flamenet/obfs4/SipHashTest.kt index 02329bd..2b79fb2 100644 --- a/obfs4/src/test/kotlin/io/flamenet/obfs4/SipHashTest.kt +++ b/obfs4/src/test/kotlin/io/flamenet/obfs4/SipHashTest.kt @@ -8,22 +8,19 @@ import kotlin.test.assertTrue /** * SipHash-2-4 and the DRBG, against the same vectors the Swift port uses. - * - * Hand-written crypto without vectors is indefensible. A subtly wrong - * hash produces well-distributed output that passes every statistical glance - * and matches nobody. */ class SipHashTest { - private fun vectors(): JSONObject { val stream = javaClass.classLoader.getResourceAsStream("framing-vectors.json") assertNotNull(stream, "the framing vectors are missing from test resources") return JSONObject(stream.readBytes().decodeToString()) } - private fun hex(s: String) = ByteArray(s.length / 2) { - s.substring(it * 2, it * 2 + 2).toInt(16).toByte() - } + private fun hex(s: String) = + ByteArray(s.length / 2) { + s.substring(it * 2, it * 2 + 2).toInt(16).toByte() + } + private fun hex(b: ByteArray) = b.joinToString("") { "%02x".format(it) } /** @@ -44,9 +41,11 @@ class SipHashTest { "want ${o.getString("sum")} got ${hex(got)}" } } - assertTrue(mismatches.isEmpty(), - "${mismatches.size} of ${v.length()} results wrong:\n" + - mismatches.take(4).joinToString("\n")) + assertTrue( + mismatches.isEmpty(), + "${mismatches.size} of ${v.length()} results wrong:\n" + + mismatches.take(4).joinToString("\n"), + ) } @Test @@ -58,22 +57,26 @@ class SipHashTest { if (msg.isEmpty()) continue val h = SipHash24(hex(o.getString("key"))) for (b in msg) h.update(byteArrayOf(b)) - assertEquals(o.getString("sum"), hex(h.sumBytes()), - "byte-at-a-time disagreed for a ${msg.size}-byte input") + assertEquals( + o.getString("sum"), + hex(h.sumBytes()), + "byte-at-a-time disagreed for a ${msg.size}-byte input", + ) } } /** - * The test that catches the reference's own documentation. obfs4's - * comment says each block is a fresh `H(K, previous)`; the code never resets - * the hash, so blocks are cumulative. The two agree on block one and diverge - * afterwards, so ten blocks are checked, not one. + * The test that catches the reference's own documentation. obfs4's comment + * says each block is a fresh `H(K, previous)`; the code never resets the + * hash, so blocks are cumulative. */ @Test fun `the drbg is cumulative not reset`() { val v = vectors().getJSONArray("drbg") - assertTrue(v.getJSONObject(0).getJSONArray("blocks").length() > 5, - "one block would not distinguish the two readings") + assertTrue( + v.getJSONObject(0).getJSONArray("blocks").length() > 5, + "one block would not distinguish the two readings", + ) val mismatches = mutableListOf() for (i in 0 until v.length()) { @@ -89,10 +92,12 @@ class SipHashTest { } } } - assertTrue(mismatches.isEmpty(), - "${mismatches.size} DRBG streams diverged. A failure at block 0 means " + - "SipHash is wrong; at block 1 the hash is being reset between blocks.\n" + - mismatches.take(3).joinToString("\n")) + assertTrue( + mismatches.isEmpty(), + "${mismatches.size} DRBG streams diverged. A failure at block 0 means " + + "SipHash is wrong; at block 1 the hash is being reset between blocks.\n" + + mismatches.take(3).joinToString("\n"), + ) } @Test @@ -101,7 +106,9 @@ class SipHashTest { val a = LengthDrbg(hex(o.getString("seed"))) val b = LengthDrbg(hex(o.getString("seed"))) val block = a.nextBlock() - assertEquals(((block[0].toInt() and 0xff) shl 8) or (block[1].toInt() and 0xff), - b.nextLengthMask()) + assertEquals( + ((block[0].toInt() and 0xff) shl 8) or (block[1].toInt() and 0xff), + b.nextLengthMask(), + ) } } diff --git a/obfs4/src/test/kotlin/io/flamenet/obfs4/SocketFactoryTest.kt b/obfs4/src/test/kotlin/io/flamenet/obfs4/SocketFactoryTest.kt index b3518ab..98c0ea2 100644 --- a/obfs4/src/test/kotlin/io/flamenet/obfs4/SocketFactoryTest.kt +++ b/obfs4/src/test/kotlin/io/flamenet/obfs4/SocketFactoryTest.kt @@ -13,31 +13,16 @@ import kotlin.test.fail /** * A real HTTP request carried inside obfs4, end to end. - * - * This tests the PLUMBING, not the wire format. The protocol is already - * pinned to real lyrebird in both directions by `InteropTest`; what is unproven - * until here is the `SocketFactory` and stream wiring, that the handshake - * happens before any stream is handed out, that the bytes arriving with the - * handshake are not dropped, and that a frame decoding to nothing does not look - * like end-of-stream. Those are the places to lose data without producing a - * wrong byte anywhere. - * - * So both ends here are ours, and that is deliberate rather than a shortcut. */ class SocketFactoryTest { - private val random = SecureRandom() /** * How many sessions actually completed a handshake at the bridge. - * - * Without this, a test fetching `http://127.0.0.1:/probe` passes - * whether or not the socket factory was used at all, a bypassed transport - * connects straight to the upstream and returns the same body. Asserting on - * the response alone proves the HTTP server works, not that obfs4 was in the - * path. */ - private val completedSessions = java.util.concurrent.atomic.AtomicInteger(0) + private val completedSessions = + java.util.concurrent.atomic + .AtomicInteger(0) private fun representableKeypair(): Triple { while (true) { @@ -56,26 +41,46 @@ class SocketFactoryTest { val listener = ServerSocket(0, 8, java.net.InetAddress.getByName("127.0.0.1")) val filter = ReplayFilter() - val worker = thread(isDaemon = true) { - while (!listener.isClosed) { - val conn = try { listener.accept() } catch (e: Exception) { return@thread } - thread(isDaemon = true) { serve(conn, nodeId, identityPrivate, identityPublic, filter, upstreamPort) } + val worker = + thread(isDaemon = true) { + while (!listener.isClosed) { + val conn = + try { + listener.accept() + } catch (e: Exception) { + return@thread + } + thread(isDaemon = true) { serve(conn, nodeId, identityPrivate, identityPublic, filter, upstreamPort) } + } } - } - return Triple(Obfs4Bridge("127.0.0.1", listener.localPort, nodeId, identityPublic), - listener, worker) + return Triple( + Obfs4Bridge("127.0.0.1", listener.localPort, nodeId, identityPublic), + listener, + worker, + ) } private fun serve( - conn: Socket, nodeId: ByteArray, identityPrivate: ByteArray, identityPublic: ByteArray, - filter: ReplayFilter, upstreamPort: Int, + conn: Socket, + nodeId: ByteArray, + identityPrivate: ByteArray, + identityPublic: ByteArray, + filter: ReplayFilter, + upstreamPort: Int, ) { conn.use { conn.soTimeout = 15000 val (sessionPriv, sessionPub, sessionRepr) = representableKeypair() - val handshake = Obfs4ServerHandshake( - nodeId, identityPrivate, identityPublic, - sessionPriv, sessionPub, sessionRepr, Obfs4Handshake.currentEpochHour()) + val handshake = + Obfs4ServerHandshake( + nodeId, + identityPrivate, + identityPublic, + sessionPriv, + sessionPub, + sessionRepr, + Obfs4Handshake.currentEpochHour(), + ) val buf = ByteArray(16384) var accepted: Obfs4ServerHandshake.Accepted? = null @@ -85,7 +90,7 @@ class SocketFactoryTest { accepted = handshake.receive(buf.copyOfRange(0, n), ByteArray(0), filter) if (accepted != null) break } - val session = accepted ?: return // Silent. No reply, no close reason. + val session = accepted ?: return // Silent. No reply, no close reason. completedSessions.incrementAndGet() conn.getOutputStream().write(session.reply) @@ -103,8 +108,12 @@ class SocketFactoryTest { conn.getOutputStream().write(plane.send(up.copyOfRange(0, n))) conn.getOutputStream().flush() } - } catch (_: Exception) { } - try { conn.close() } catch (_: Exception) { } + } catch (_: Exception) { + } + try { + conn.close() + } catch (_: Exception) { + } } try { while (true) { @@ -116,7 +125,8 @@ class SocketFactoryTest { upstream.getOutputStream().flush() } } - } catch (_: Exception) { } + } catch (_: Exception) { + } } } } @@ -134,29 +144,30 @@ class SocketFactoryTest { val (bridge, listener, _) = startBridge(http.address.port) try { - val client = okhttp3.OkHttpClient.Builder() - .socketFactory(Obfs4SocketFactory(bridge)) - .callTimeout(30, java.util.concurrent.TimeUnit.SECONDS) - .build() + val client = + okhttp3.OkHttpClient + .Builder() + .socketFactory(Obfs4SocketFactory(bridge)) + .callTimeout(30, java.util.concurrent.TimeUnit.SECONDS) + .build() - // The URL is the RELAY's address. The socket factory ignores it - // and dials the bridge, and the thing - // that would silently not happen if the factory were bypassed. - val request = okhttp3.Request.Builder() - .url("http://127.0.0.1:${http.address.port}/probe") - .build() + // The URL is the RELAY's address. + val request = + okhttp3.Request + .Builder() + .url("http://127.0.0.1:${http.address.port}/probe") + .build() client.newCall(request).execute().use { response -> assertEquals(200, response.code) assertEquals(body, response.body?.string()) } - // The assertion that makes this test mean anything. The URL - // points at the upstream, so a request that bypassed the socket - // factory would return the same body and this test would pass while - // proving nothing. - assertTrue(completedSessions.get() > 0, - "the response arrived but the bridge never completed a handshake, " + - "so the request went DIRECT to the upstream, the transport was " + - "not in the path") + // The assertion that makes this test mean anything. + assertTrue( + completedSessions.get() > 0, + "the response arrived but the bridge never completed a handshake, " + + "so the request went DIRECT to the upstream, the transport was " + + "not in the path", + ) } finally { listener.close() http.stop(0) @@ -171,21 +182,28 @@ class SocketFactoryTest { @Test fun `a wrong bridge key fails rather than hanging`() { val http = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0) - http.createContext("/probe") { it.sendResponseHeaders(200, -1); it.close() } + http.createContext("/probe") { + it.sendResponseHeaders(200, -1) + it.close() + } http.start() val (bridge, listener, _) = startBridge(http.address.port) try { - val wrong = bridge.copy( - identityPublic = bridge.identityPublic.copyOf().also { it[0] = (it[0].toInt() xor 1).toByte() }) + val wrong = + bridge.copy(identityPublic = bridge.identityPublic.copyOf().also { it[0] = (it[0].toInt() xor 1).toByte() }) val socket = Obfs4Socket(wrong) socket.soTimeout = 4000 try { socket.connect(InetSocketAddress("127.0.0.1", http.address.port), 4000) fail("a wrong bridge key must not produce a usable socket") } catch (e: Exception) { - assertTrue(e is Obfs4HandshakeRefused || e is java.io.IOException, - "expected a refusal, got ${e::class.simpleName}: ${e.message}") - } finally { socket.close() } + assertTrue( + e is Obfs4HandshakeRefused || e is java.io.IOException, + "expected a refusal, got ${e::class.simpleName}: ${e.message}", + ) + } finally { + socket.close() + } } finally { listener.close() http.stop(0) @@ -203,7 +221,11 @@ class SocketFactoryTest { fun `a bridge line cert round trips`() { val nodeId = ByteArray(20) { it.toByte() } val key = ByteArray(32) { (it * 3).toByte() } - val cert = java.util.Base64.getEncoder().encodeToString(nodeId + key).dropLast(2) + val cert = + java.util.Base64 + .getEncoder() + .encodeToString(nodeId + key) + .dropLast(2) val parsed = Obfs4Bridge.fromCert("example.org", 443, cert) assertTrue(parsed.nodeId.contentEquals(nodeId)) assertTrue(parsed.identityPublic.contentEquals(key))