Android: the app, and the two things only a phone could tell us

There is now a Flamenet Messenger app that installs, launches, creates an
account on a self-hosted relay, registers a device, publishes prekeys, and
stores all of it in an Android Keystore-backed vault. Verified on an emulator
through the actual screens, not by unit test.

92 JVM tests, 37 cross-engine, 9 on-device, 3 against live relays.

Two findings, both of which the JVM suite was structurally incapable of
producing.

**Android has no ordinary Ed25519 provider.** `KeyPairGenerator.Ed25519` and
`KeyFactory.Ed25519` come from `AndroidKeyStore` and from nowhere else -- probed
on API 35 rather than assumed -- and Keystore only makes keys it keeps. Every
Ed25519 key in this engine is one we must hold: a device identity in our own
vault, an account key derived from a passphrase. So the JCA route could never
have worked. It compiled, passed 81 tests on a laptop, and threw "Not
initialized" on the first launch on a phone. Worse, it was LATENT: a wrong
password failed before any key was minted, so the error path worked perfectly
while the happy path was broken.

Ed25519 now uses BouncyCastle's `math.ec.rfc8032` core directly -- the engine's
one real dependency, present because the platform leaves a hole, and a strictly
better answer than the non-constant-time keygen it replaces. ProviderProbeTest
records the measurement so nobody guesses again.

**The prekey field is `prekeys`, not `opks`.** I guessed. The relay accepted the
registration, returned 200, and stored nothing -- so the device was published
and could not be started with, a failure with no error anywhere. Caught by a
live test against the throwaway fleet, which exists because the same path
through the emulator takes four minutes to say "timed out" and nothing about
where.

Two testing traps, recorded where they bite:

An indeterminate progress spinner animates for ever, so Compose's test clock
never goes idle and `waitUntil` never evaluates its condition -- it fails on the
full timeout, reading as "sign-up is broken" when sign-up works. Auto-advance is
now disabled around the wait only; disabling it for the whole test is the next
mistake, because the app opens on a spinner while it reads the vault.

Wiping the Keystore in `@Before` deletes the key the already-launched activity
is holding, and every later write fails against a key that no longer exists. The
test signs out through the UI instead, which is both safe and what a person does.

Failure messages now include what the screen was showing. A bare timeout sends
you to the network, the relay and the crypto in turn while the app has been
displaying the reason the whole time.

Also corrects a note that said this Mac had no Android SDK. It does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-23 17:02:16 -04:00
co-authored by Claude Opus 5
parent b1ba9cdc58
commit 5f983c1455
29 changed files with 1949 additions and 215 deletions
+31 -12
View File
@@ -15,9 +15,9 @@ screen renders.
| | iOS | Android |
|---|---|---|
| Protocol engine | `Sources/E2E`, 15 files | `engine`, 10 files |
| App layer | ~9,700 lines, 45 files | transport only, no UI |
| Tests | XCUITest + JS interop | 81 unit + 37 cross-engine + 2 live-relay |
| Ships to | nobody (no signing team) | nobody (no app module yet) |
| App layer | ~9,700 lines, 45 files | sign-in, sign-up, sign-out |
| Tests | XCUITest + JS interop | 92 unit + 37 cross-engine + 9 on-device + 3 live-relay |
| Ships to | nobody (no signing team) | a debug APK that installs and runs |
Android's engine is **verified against the web engine in both directions**
(`engine/test/interop-kotlin.mjs`), which is a stronger claim than the iOS app
@@ -31,7 +31,7 @@ everything above the protocol.
| Feature | iOS | Android | Note |
|---|:--:|:--:|---|
| X25519, HKDF, AES-GCM | ✅ | ✅ | Android verified against RFC 5869 / NIST vectors |
| Ed25519 | ✅ | ✅ | RFC 8032 vectors; keygen-from-seed done here, signing via the provider |
| Ed25519 | ✅ | ✅ | RFC 8032 vectors, on the JVM **and on device**; see the provider note below |
| X3DH v1 | ✅ | ✅ | cross-engine verified |
| Double Ratchet | ✅ | ✅ | cross-engine verified, including reordering and loss |
| Safety numbers | ✅ | ✅ | cross-engine verified |
@@ -41,7 +41,7 @@ everything above the protocol.
| Envelope + wire codec | ✅ | ✅ | cross-engine verified at the envelope level |
| Session archive / restore | ✅ | ✅ | cross-engine verified, in-flight messages included |
| Device identity + prekey rotation | ✅ | ✅ | retired prekeys retained past the relay window |
| Vault (encryption at rest) | ✅ | ⚪ | `E2E/KeychainE2EStore.swift` → Android Keystore |
| Vault (encryption at rest) | ✅ | ✅ | Android Keystore, AES-GCM, record name bound into the AAD |
| Attachment encryption | ✅ | ⚪ | `E2E/E2EAttachment.swift` |
| ML-KEM-768 (PQ agreement) | ⚠️ | ⚪ | see below |
| ML-DSA-65 (PQ identity) | ⚠️ | ⚪ | see below |
@@ -63,9 +63,10 @@ From `Sources/Auth/*` and `Sources/Views/Sign*`.
| Feature | Android |
|---|:--:|
| Sign up (managed, shared self-hosted, local self-hosted) | ⚪ |
| Sign in, session restore | ⚪ |
| Sign out | ⚪ |
| Sign up — local self-hosted | ✅ verified against `relay1` through the UI |
| Sign up — managed / shared self-hosted | 🟡 built, not yet exercised end to end |
| Sign in, session restore | ✅ |
| Sign out (erases the vault key) | ✅ |
| Password reset request + confirm | ⚪ |
| Change password | ⚪ |
| Delete account | ⚪ |
@@ -134,8 +135,26 @@ order and not a nicer-looking one.
2. ~~**`:client`** — REST, relay auth, device registration, prekey upload,
inbox polling, attachments, groups, transparency log.~~ ✅ 13 tests against
a real HTTP server, plus an opt-in check against the deployed relay.
3. **Vault** — Android Keystore, encryption at rest for the session archive.
4. **`:app`** — Compose UI, in the order above: sign-in, buddy list, chat, then
the rest.
5. **Instrumented tests** — the JVM tests prove nothing about Conscrypt. This
3. ~~**Vault** — Android Keystore, encryption at rest.~~ ✅
4. ~~**`:app` scaffolding** — Compose, sign-in/up/out, on-device tests.~~ ✅
5. **Buddy list and chat** — the rest of the UI, in that order.
6. **More instrumented tests** — the JVM tests prove nothing about Conscrypt. This
engine has already been bitten once by exactly that.
---
## Two findings worth keeping
**Android has no ordinary Ed25519 provider.** Measured on API 35, not assumed:
`KeyPairGenerator.Ed25519` and `KeyFactory.Ed25519` are served by
`AndroidKeyStore` and by nothing else, and Keystore only ever makes keys it
keeps. Every Ed25519 key here is one we must hold — a device identity in our own
vault, an account key derived from a passphrase — so the JCA route cannot work
by construction. It compiled, passed 81 JVM tests, and threw
`IllegalStateException: Not initialized` on a phone. The engine now uses
BouncyCastle's `math.ec.rfc8032.Ed25519` directly: its one real dependency, and
it is there because the platform leaves a hole. `ProviderProbeTest` records the
measurement so nobody has to guess again.
**XDH is fine** — Conscrypt serves it from API 33, which is why `minSdk` is 33.
+100
View File
@@ -0,0 +1,100 @@
import java.io.File
import java.util.Properties
// The Android app shell. Everything below it — the cryptography and the wire
// layer — lives in :engine and :client, which are plain JVM libraries so they
// can be tested without any of this.
plugins {
id("com.android.application")
kotlin("android")
id("org.jetbrains.kotlin.plugin.compose")
}
android {
namespace = "io.flamenet.messenger"
compileSdk = 35
defaultConfig {
applicationId = "io.flamenet.messenger"
// Android exposes the XDH algorithm from API 33. Below that a provider
// such as BouncyCastle has to be registered at startup; the engine is
// provider-agnostic, but nothing here does that registration yet, so
// claiming a lower floor would be claiming something untrue.
minSdk = 33
targetSdk = 35
versionCode = 1
versionName = "0.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
sourceSets {
getByName("main") { java.srcDir("src/main/kotlin") }
getByName("androidTest") { java.srcDir("src/androidTest/kotlin") }
}
buildFeatures { compose = true }
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlin { compilerOptions { jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) } }
// Read from a properties file outside the repository, so the keystore and
// its password are never in git and a checkout without them still builds
// (unsigned, which is exactly what an unsigned build should be).
val signingProperties = Properties()
val signingFile = File(System.getProperty("user.home"), ".flamenet-android-signing.properties")
if (signingFile.exists()) {
signingFile.inputStream().use { signingProperties.load(it) }
}
signingConfigs {
if (signingProperties.isNotEmpty()) {
create("release") {
storeFile = File(signingProperties.getProperty("storeFile"))
storePassword = signingProperties.getProperty("storePassword")
keyAlias = signingProperties.getProperty("keyAlias")
keyPassword = signingProperties.getProperty("keyPassword")
}
}
}
buildTypes {
release {
isMinifyEnabled = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
signingConfig = signingConfigs.findByName("release")
}
}
packaging {
resources.excludes += setOf("META-INF/*.kotlin_module", "META-INF/DEPENDENCIES")
}
}
dependencies {
implementation(project(":engine"))
implementation(project(":client"))
implementation(platform("androidx.compose:compose-bom:2024.12.01"))
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-extended")
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-tooling-preview")
debugImplementation("androidx.compose.ui:ui-tooling")
implementation("androidx.activity:activity-compose:1.9.3")
implementation("androidx.core:core-ktx:1.15.0")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
// On-device tests. The JVM suites cannot see a provider difference, and a
// provider difference is what broke X25519 the first time this engine's
// sibling ran on a phone.
androidTestImplementation("androidx.test.ext:junit:1.2.1")
androidTestImplementation("androidx.test:runner:1.6.2")
androidTestImplementation(platform("androidx.compose:compose-bom:2024.12.01"))
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
debugImplementation("androidx.compose.ui:ui-test-manifest")
androidTestImplementation("androidx.tracing:tracing:1.2.0")
}
@@ -0,0 +1,132 @@
package io.flamenet.messenger.app
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import io.flamenet.e2e.AccountKdf
import io.flamenet.e2e.DeviceIdentity
import io.flamenet.e2e.E2ESession
import io.flamenet.e2e.Ed25519
import io.flamenet.e2e.PeerBundle
import io.flamenet.e2e.Vault
import io.flamenet.e2e.X25519
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import java.security.Security
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))))
}
@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.
assertNotNull(java.security.KeyPairGenerator.getInstance("XDH"))
}
@Test
fun x25519AgreesOnDevice() {
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),
)
}
/**
* 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.
*/
@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))
assertEquals(expected, pair.publicKey.joinToString("") { "%02x".format(it) })
// And it can actually sign, which is the half AndroidKeyStore could
// never do for a key we derived ourselves.
val message = "signed prekey bytes".toByteArray()
assertTrue(Ed25519.verify(pair.publicKey, Ed25519.sign(pair, message), message))
}
@Test
fun aConversationRunsOnDevice() {
val alice = DeviceIdentity.create()
val bob = DeviceIdentity.create()
val a = E2ESession.initiate(alice, bundleFor(bob))
val first = a.encrypt("hello from a phone")
val b = E2ESession.respond(bob, first)
assertEquals("hello from a phone", b.decrypt(first))
assertEquals("and back", a.decrypt(b.encrypt("and back")))
}
/** The vault, against the real Android Keystore rather than a generated key. */
@Test
fun theKeystoreVaultRoundTrips() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
val vault = KeystoreVault.open(context)
vault.forgetEverything()
val identity = DeviceIdentity.create()
vault.saveIdentity(identity)
assertEquals(identity.deviceId, KeystoreVault.open(context).loadIdentity()?.deviceId)
vault.forgetEverything()
assertTrue(KeystoreVault.open(context).loadIdentity() == null)
}
/**
* 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)
}
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",
elapsed < 20_000,
)
}
}
@@ -0,0 +1,34 @@
package io.flamenet.messenger.app
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
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",
)
for (service in interesting) {
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}")
}
}
@@ -0,0 +1,156 @@
package io.flamenet.messenger.app
import androidx.compose.ui.test.assertIsDisplayed
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.ext.junit.runners.AndroidJUnit4
import org.junit.Before
import org.junit.Rule
import org.junit.Test
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 {
@get:Rule val compose = createAndroidComposeRule<MainActivity>()
private val relay = "relay1.flamenet.io"
private lateinit var handle: String
@Before
fun startSignedOut() {
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.
if (nodesWithTag("sign-out").isNotEmpty()) {
compose.onNodeWithTag("sign-out").performClick()
awaitTag("submit", 30_000)
}
}
private fun nodesWithTag(tag: String): List<Any> = 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) {
// ⚠️ 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)
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()}")
} 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 relayReachable(): 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)
@Test
fun aPersonCanCreateAnAccountOnASelfHostedRelay() {
if (!relayReachable()) return
compose.onNodeWithTag("relay").performTextClearance()
compose.onNodeWithTag("relay").performTextInput(relay)
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.
compose.onNodeWithTag("email").assertIsDisplayed()
compose.onNodeWithTag("email").performTextInput("$handle@example.invalid")
compose.onNodeWithTag("handle").performTextInput(handle)
compose.onNodeWithTag("passphrase").performTextInput("a-long-enough-passphrase")
compose.onNodeWithTag("submit").performClick()
// Generous: sign-up is a network round trip plus a device registration
// publishing twenty prekeys, on an emulator.
awaitTag("device-id", 120_000)
compose.onNodeWithTag("device-id").assertIsDisplayed()
}
/**
* 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() {
if (!relayReachable()) return
aPersonCanCreateAnAccountOnASelfHostedRelay()
compose.onNodeWithTag("sign-out").performClick()
awaitTag("submit", 30_000)
compose.onNodeWithTag("handle").assertIsDisplayed()
}
}
+30
View File
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:allowBackup="false"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.FlamenetMessenger">
<!--
allowBackup is off deliberately, and it is load-bearing here rather
than tidy. The vault's key lives in the Android Keystore and is never
backed up; the encrypted records would be. Restoring those onto a
different device would produce a vault nothing can open, which
presents as a corrupt install rather than as a missing key.
-->
<activity
android:name=".app.MainActivity"
android:exported="true"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,125 @@
package io.flamenet.messenger.app
import android.content.Context
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import io.flamenet.e2e.Vault
import java.io.File
import java.security.KeyStore
import javax.crypto.KeyGenerator
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"
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,
)
.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()
}
/** Erase the key itself. Sign-out, and the only way the records become unrecoverable. */
fun destroy(context: Context) {
Vault(FileStorage(File(context.filesDir, DIRECTORY)), key()).forgetEverything()
KeyStore.getInstance(KEYSTORE).apply { load(null) }.deleteEntry(ALIAS)
}
/**
* 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 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,
))
override fun read(name: String): ByteArray? =
fileFor(name).takeIf { it.exists() }?.readBytes()
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".
val target = fileFor(name)
val staging = File(root, target.name + ".tmp")
staging.writeBytes(bytes)
if (!staging.renameTo(target)) {
staging.delete()
error("could not commit the vault record")
}
}
override fun delete(name: String) { fileFor(name).delete() }
override fun names(): List<String> =
root.listFiles().orEmpty()
.filterNot { it.name.endsWith(".tmp") }
.mapNotNull {
runCatching {
String(
android.util.Base64.decode(it.name, android.util.Base64.URL_SAFE),
Charsets.UTF_8,
)
}.getOrNull()
}
}
}
@@ -0,0 +1,201 @@
package io.flamenet.messenger.app
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MessengerTheme {
Surface(modifier = Modifier.fillMaxSize()) { Root() }
}
}
}
}
@Composable
private fun Root(model: MessengerViewModel = viewModel()) {
val phase by model.phase.collectAsStateWithLifecycle()
Scaffold { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(24.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
when (val current = phase) {
is MessengerViewModel.Phase.Starting -> CircularProgressIndicator()
is MessengerViewModel.Phase.SignedOut -> SignIn(model, current)
is MessengerViewModel.Phase.SignedIn -> SignedIn(model, current)
is MessengerViewModel.Phase.VaultUnreadable -> VaultUnreadable(model, current)
}
}
}
}
@Composable
private fun SignIn(model: MessengerViewModel, phase: MessengerViewModel.Phase.SignedOut) {
val relay by model.relayInput.collectAsStateWithLifecycle()
var handle by remember { mutableStateOf("") }
var passphrase by remember { mutableStateOf("") }
var email by remember { mutableStateOf("") }
var registering by remember { mutableStateOf(false) }
val needsEmail = registering &&
model.site?.accountProvider == SiteConfig.AccountProvider.LOCAL
Text("Flamenet Messenger", style = MaterialTheme.typography.headlineMedium)
OutlinedTextField(
value = relay,
onValueChange = model::setRelay,
label = { Text("Server") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next),
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.
model.site?.let {
Text(it.identityDescription, style = MaterialTheme.typography.bodySmall)
}
OutlinedTextField(
value = handle,
onValueChange = { handle = it },
label = { Text("Handle") },
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
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.
if (needsEmail) {
OutlinedTextField(
value = email,
onValueChange = { email = it },
label = { Text("Email") },
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Email, imeAction = ImeAction.Next),
modifier = Modifier.fillMaxWidth().testTag("email"),
)
Text(
"Used only by this server, to send a password reset.",
style = MaterialTheme.typography.bodySmall,
)
}
OutlinedTextField(
value = passphrase,
onValueChange = { passphrase = it },
label = { Text("Passphrase") },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password, imeAction = ImeAction.Done),
modifier = Modifier.fillMaxWidth().testTag("passphrase"),
)
phase.error?.let {
Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.testTag("error"))
}
Button(
onClick = {
model.signIn(handle.trim(), passphrase, register = registering, email = email.trim())
},
enabled = !phase.busy && handle.isNotBlank() && passphrase.isNotBlank() &&
(!needsEmail || email.isNotBlank()),
modifier = Modifier.fillMaxWidth().testTag("submit"),
) {
if (phase.busy) {
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
} else {
Text(if (registering) "Create account" else "Sign in")
}
}
TextButton(
onClick = { registering = !registering },
modifier = Modifier.fillMaxWidth().testTag("toggle-register"),
) {
Text(if (registering) "I already have an account" else "Create an account")
}
}
@Composable
private fun SignedIn(model: MessengerViewModel, phase: MessengerViewModel.Phase.SignedIn) {
Text("Signed in", style = MaterialTheme.typography.headlineMedium)
Text("Relay: ${phase.relay}")
Text("Device: ${phase.deviceId}", modifier = Modifier.testTag("device-id"))
Text(phase.status, style = MaterialTheme.typography.bodySmall)
Button(onClick = model::signOut, modifier = Modifier.testTag("sign-out")) {
Text("Sign out")
}
// Signing out destroys the Keystore key, and every stored conversation with
// it. Said here rather than in a dialog nobody reads.
Text(
"Signing out erases this phone's keys and stored messages. They cannot be recovered.",
style = MaterialTheme.typography.bodySmall,
)
}
@Composable
private fun VaultUnreadable(
model: MessengerViewModel,
phase: MessengerViewModel.Phase.VaultUnreadable,
) {
Text("Stored keys unreadable", style = MaterialTheme.typography.headlineMedium)
Text(
"This phone has keys saved for Flamenet Messenger, but they cannot be opened. " +
"That usually means the app was restored onto a different device, where the " +
"key that protects them could not follow.",
)
Text(phase.detail, style = MaterialTheme.typography.bodySmall)
Text(
"Starting fresh publishes a new device. Everyone you talk to will see " +
"\"security keys changed\", so tell them to expect it.",
)
Button(onClick = model::signOut, modifier = Modifier.testTag("start-fresh")) {
Text("Erase and start fresh")
}
}
@@ -0,0 +1,199 @@
package io.flamenet.messenger.app
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import io.flamenet.client.AccountsClient
import io.flamenet.client.RelayClient
import io.flamenet.client.RelayAccounts
import io.flamenet.client.RelayException
import io.flamenet.e2e.DeviceIdentity
import io.flamenet.e2e.Vault
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* 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.
*/
class MessengerViewModel(app: Application) : AndroidViewModel(app) {
sealed interface Phase {
/** 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 SignedIn(
val handle: String,
val deviceId: String,
val relay: String,
val status: String,
) : Phase
/**
* 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
}
private val _phase = MutableStateFlow<Phase>(Phase.Starting)
val phase: StateFlow<Phase> = _phase.asStateFlow()
private val _relayInput = MutableStateFlow("relay.flamenet.io")
val relayInput: StateFlow<String> = _relayInput.asStateFlow()
private var vault: Vault? = null
private var identity: DeviceIdentity? = null
val site: SiteConfig? get() = SiteConfig.from(_relayInput.value)
fun setRelay(value: String) { _relayInput.value = value }
init { restore() }
/** 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
v.loadIdentity()
}
}
opened.fold(
onSuccess = { stored ->
identity = stored
_phase.value = if (stored == null) {
Phase.SignedOut()
} else {
Phase.SignedIn(
handle = "",
deviceId = stored.deviceId,
relay = _relayInput.value,
status = "Device restored from this phone's vault.",
)
}
},
onFailure = { failure ->
_phase.value = Phase.VaultUnreadable(
failure.message ?: failure::class.simpleName.orEmpty())
},
)
}
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 {
val result = withContext(Dispatchers.IO) {
runCatching { authenticate(config, handle, passphrase, register, email) }
}
result.fold(
onSuccess = { _phase.value = it },
onFailure = { _phase.value = Phase.SignedOut(error = describe(it)) },
)
}
}
/**
* 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,
handle: String,
passphrase: String,
register: Boolean,
email: String,
): Phase {
val relayAccounts = RelayAccounts(config.baseUrl)
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(SiteConfig.SHARED_ACCOUNT_SERVICE)
if (register) accounts.register(handle, passphrase)
relayAccounts.sharedSession(accounts.signIn(handle, passphrase).token)
}
// 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 credential = account.appPassword
val vault = vault ?: error("the vault is not open")
val device = identity ?: 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.
val preKeys = device.generateOneTimePreKeys(20)
relay.registerDevice(device, preKeys)
vault.saveIdentity(device)
return Phase.SignedIn(
handle = account.username,
deviceId = device.deviceId,
relay = config.host,
status = "Registered with ${preKeys.size} one-time prekeys published.",
)
}
fun signOut() = viewModelScope.launch {
withContext(Dispatchers.IO) { KeystoreVault.destroy(getApplication()) }
identity = null
vault = KeystoreVault.open(getApplication())
_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.
*/
private fun describe(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."
is AccountsClient.Failure.RateLimited -> "Too many attempts. Wait a few minutes."
is AccountsClient.Failure.Transport -> failure.message.orEmpty()
is RelayException.Unauthorized -> "That handle or passphrase isn't right."
is RelayException.NotFound -> "This server doesn't look like a Flamenet relay."
is RelayException.RateLimited -> "Too many attempts. Wait a few minutes."
is Vault.Corrupt -> "This phone's stored keys could not be read."
else -> failure.message ?: "Something went wrong reaching that server."
}
}
@@ -0,0 +1,94 @@
package io.flamenet.messenger.app
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) {
enum class AccountProvider {
/** The shared FlameNet account service. One credential across products. */
SHARED,
/** The relay's own account table — what keeps self-hosting one service, not two. */
LOCAL,
}
companion object {
/**
* The hosts whose accounts live in the shared service.
*
* ⚠️ **A fixed list, decided here rather than asked of the relay.**
* Letting a relay declare its own identity provider would mean a
* compromised relay could name a host it controls and collect
* passphrase-derived secrets for accounts that are not its own. A
* self-hoster is unaffected: an unlisted host is treated as a relay
* with its own provider, which is the correct default.
*/
private val SHARED_ACCOUNT_HOSTS = setOf(
"flamenet.io",
"www.flamenet.io",
"messenger.flamenet.io",
// The same service under a second name, not a second service. It
// has to be listed explicitly — an unlisted host is treated as
// self-hosted, and that would silently be the wrong provider.
"relay.flamenet.io",
)
const val SHARED_ACCOUNT_SERVICE = "https://accounts.flamenet.io"
/**
* 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("/")
if (trimmed.isEmpty()) return null
val withScheme = if (trimmed.contains("://")) trimmed else "https://$trimmed"
val uri = runCatching { URI(withScheme) }.getOrNull() ?: return null
val host = uri.host ?: return null
// A host with no dot is a typo or a LAN name; either way it is not
// something to silently accept as a public relay.
if (host.isBlank() || !host.contains('.')) return null
if (uri.scheme !in setOf("https", "http")) return null
val port = if (uri.port > 0) ":${uri.port}" else ""
return SiteConfig("${uri.scheme}://$host$port")
}
}
val host: String get() = runCatching { URI(baseUrl).host }.getOrNull().orEmpty()
/**
* Derived from the host every time rather than stored: a persisted value
* would outlive a change to the list above and quietly keep using the
* wrong provider.
*/
val accountProvider: AccountProvider
get() = if (host.lowercase() in SHARED_ACCOUNT_HOSTS) {
AccountProvider.SHARED
} else {
AccountProvider.LOCAL
}
val usesSharedAccounts: Boolean get() = accountProvider == AccountProvider.SHARED
/** What to tell the user this relay does about identity, in their words. */
val identityDescription: String
get() = when (accountProvider) {
AccountProvider.SHARED -> "One Flamenet account, shared with Flamenet Files."
AccountProvider.LOCAL -> "This server keeps its own accounts."
}
}
@@ -0,0 +1,44 @@
package io.flamenet.messenger.app
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
/**
* The Flamenet palette, matched to the iOS client's `Theme.swift` so the two
* apps look like the same product rather than two takes on it.
*/
private val Ember = Color(0xFFE2571F)
private val EmberDim = Color(0xFFB33F12)
private val Ash = Color(0xFF16130F)
private val Parchment = Color(0xFFFBF7F2)
@Composable
fun MessengerTheme(content: @Composable () -> Unit) {
val dark = isSystemInDarkTheme()
MaterialTheme(
colorScheme = if (dark) {
darkColorScheme(
primary = Ember,
onPrimary = Color.White,
background = Ash,
surface = Color(0xFF1F1B16),
onBackground = Parchment,
onSurface = Parchment,
)
} else {
lightColorScheme(
primary = EmberDim,
onPrimary = Color.White,
background = Parchment,
surface = Color.White,
onBackground = Ash,
onSurface = Ash,
)
},
content = content,
)
}
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Flamenet Messenger</string>
</resources>
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!--
A bare Material theme. Compose paints everything; this exists so the
window has a background before the first frame, which is the difference
between launching and flashing white.
-->
<style name="Theme.FlamenetMessenger" parent="android:Theme.Material.NoActionBar">
<item name="android:statusBarColor">@android:color/transparent</item>
</style>
</resources>
+10
View File
@@ -1,3 +1,13 @@
// Plugin versions live here, applied per module below.
//
// They have to be in one place: `:engine` and `:client` are Kotlin/JVM while
// `:app` is Kotlin/Android, and declaring a version in a subproject when the
// same plugin is already on the root classpath fails with "already on the
// classpath with an unknown version" — which reads as a dependency conflict
// rather than as a misplaced version number.
plugins {
kotlin("jvm") version "2.4.0" apply false
kotlin("android") version "2.4.0" apply false
id("com.android.application") version "8.7.3" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.4.0" apply false
}
@@ -0,0 +1,138 @@
package io.flamenet.client
import io.flamenet.e2e.AccountKdf
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
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 entire 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 name this client asks for a session under. */
private val product = "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 Session(val token: String, val pseudonym: String, val expiresAt: String)
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")
object HandleTaken : Failure("that handle is already taken")
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")
}
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())
return Params(
salt = Wire.unb64(json.getString("kdfSalt")),
kdfId = json.getInt("kdfID"),
iterations = json.getInt("kdfIterations"),
)
}
fun signIn(handle: String, passphrase: String): 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,
)
val secret = AccountKdf.authSecret(passphrase, params.salt, effective)
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", ""),
expiresAt = json.optString("expiresAt", ""),
)
}
/** Create an account. The salt is generated here, not by the service. */
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))
}
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()
)
private fun send(request: Request): 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")
}
401, 403 -> throw Failure.BadCredentials
409 -> throw Failure.HandleTaken
429 -> throw Failure.RateLimited
else -> throw Failure.Service(it.code)
}
}
}
private companion object {
val JSON = "application/json".toMediaType()
}
}
@@ -0,0 +1,132 @@
package io.flamenet.client
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
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. */
data class Account(
val userId: Long,
val username: String,
val displayName: String,
val appPassword: String,
)
fun signUp(username: String, email: String, password: String, displayName: String? = null): Account =
account(post("account/signup", JSONObject()
.put("username", username)
.put("email", email)
.put("password", password)
.put("display_name", displayName ?: username)))
/**
* ⚠️ 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.
*/
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.
*/
fun sharedSession(accountToken: String): Account =
account(post("api/flamenet/v1/messenger/session", JSONObject().put("token", accountToken)))
/**
* 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))
}
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 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")
}
}
// 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 }
throw when (it.code) {
401, 403 -> RelayException.Unauthorized(message)
404 -> RelayException.NotFound(path)
409 -> RelayException.Http(409, message)
429 -> RelayException.RateLimited(it.header("Retry-After")?.toIntOrNull())
else -> RelayException.Http(it.code, message)
}
}
}
private companion object {
val JSON = "application/json".toMediaType()
}
}
@@ -2,7 +2,6 @@ package io.flamenet.client
import io.flamenet.e2e.Ed25519
import java.security.MessageDigest
import java.security.PrivateKey
import java.util.UUID
/**
@@ -28,8 +27,7 @@ import java.util.UUID
*/
class RelayAuth(
private val deviceId: String,
private val identityKey: PrivateKey,
private val identityPublic: ByteArray,
private val identityKey: Ed25519.KeyPair,
private val mint: (deviceId: String, identityPublicB64: String) -> Minted?,
) {
@@ -56,7 +54,7 @@ class RelayAuth(
if (unavailable) return null
cached?.let { if (it.expiresAt - SLACK_SECONDS > now) return it.token }
val minted = try {
mint(deviceId, Wire.b64(identityPublic))
mint(deviceId, Wire.b64(identityKey.publicKey))
} catch (_: RelayException.NotFound) {
unavailable = true
return null
@@ -68,8 +68,7 @@ class RelayClient(
fun useRelayAuth(identity: DeviceIdentity) {
auth = RelayAuth(
deviceId = identity.deviceId,
identityKey = identity.identitySigning.private,
identityPublic = identity.identitySigningPublic,
identityKey = identity.identitySigning,
mint = ::mintToken,
)
}
@@ -84,7 +83,7 @@ class RelayClient(
fun uploadPreKeys(deviceId: String, preKeys: List<DeviceIdentity.PreKey>) {
postJson("/e2e/prekeys", JSONObject()
.put("device_id", deviceId)
.put("opks", JSONArray().also { array ->
.put("prekeys", JSONArray().also { array ->
preKeys.forEach {
array.put(JSONObject().put("id", it.id).put("pub", Wire.b64(it.publicKey)))
}
@@ -0,0 +1,52 @@
package io.flamenet.client
import io.flamenet.e2e.DeviceIdentity
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")
@Test
fun `a new account can register a device and publish prekeys`() {
val base = relay ?: return
val handle = "jvmt" + (System.currentTimeMillis() % 100_000_000)
val account = RelayAccounts(base).signUp(
username = handle,
email = "$handle@example.invalid",
password = "a-long-enough-passphrase",
)
assertEquals(handle, account.username)
assertTrue(account.appPassword.isNotEmpty())
val identity = DeviceIdentity.create()
val client = RelayClient(base, username = account.username, appPassword = account.appPassword)
client.useRelayAuth(identity)
val preKeys = identity.generateOneTimePreKeys(20)
client.registerDevice(identity, preKeys)
// 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)
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")
}
}
@@ -86,8 +86,11 @@ 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"))
assertEquals(2, body.getJSONArray("opks").length())
assertEquals(preKeys[0].id, body.getJSONArray("opks").getJSONObject(0).getInt("id"))
// ⚠️ `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.
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.
+16
View File
@@ -18,6 +18,22 @@ dependencies {
// ratchet header and the canonical log leaf -- are both built by hand.
compileOnly("org.json:json:20240303")
testImplementation("org.json:json:20240303")
// The engine's ONE real dependency, and it is here because the platform
// leaves a hole rather than because it was convenient.
//
// Android serves Ed25519 key generation from `AndroidKeyStore` and nowhere
// else -- probed on API 35, see ProviderProbeTest -- and Keystore only ever
// makes keys it keeps. This engine needs Ed25519 keys it holds: a device
// identity that lives in our own vault, and an account key derived from a
// passphrase. Neither can be a Keystore key by definition.
//
// That left implementing RFC 8032 by hand, which for SIGNING means
// non-constant-time arithmetic over a secret scalar, or taking a vetted
// constant-time one. Only the low-level `math.ec.rfc8032.Ed25519` class is
// used -- not the JCA provider, which is awkward to register on Android --
// so R8 shrinks nearly all of it away.
implementation("org.bouncycastle:bcprov-jdk18on:1.79")
}
// Bytecode level 17, which is what Android's toolchain consumes, produced by
@@ -27,7 +27,7 @@ import java.security.KeyPair
class DeviceIdentity private constructor(
val deviceId: String,
val identityDH: KeyPair,
val identitySigning: KeyPair,
val identitySigning: Ed25519.KeyPair,
var signedPreKey: KeyPair,
var signedPreKeyId: Int,
var signedPreKeyCreatedAt: Long,
@@ -98,7 +98,7 @@ class DeviceIdentity private constructor(
}
val identityDHPublic: ByteArray get() = X25519.rawFromPublicKey(identityDH.public)
val identitySigningPublic: ByteArray get() = Ed25519.rawFromPublicKey(identitySigning.public)
val identitySigningPublic: ByteArray get() = identitySigning.publicKey
/**
* The signature a peer checks before trusting the signed prekey.
@@ -108,7 +108,7 @@ class DeviceIdentity private constructor(
* why [E2ESession.initiate] does, unconditionally.
*/
fun signedPreKeySignature(): ByteArray =
Ed25519.sign(identitySigning.private, X25519.rawFromPublicKey(signedPreKey.public))
Ed25519.sign(identitySigning, X25519.rawFromPublicKey(signedPreKey.public))
fun generateOneTimePreKeys(count: Int): List<PreKey> = (0 until count).map {
val id = nextPreKeyId++
@@ -172,7 +172,11 @@ class DeviceIdentity private constructor(
.put("spk", Bytes.b64(X25519.rawFromPublicKey(signedPreKey.public)))
.put("spk_sig", Bytes.b64(signedPreKeySignature()))
.put("proto", 1)
.put("opks", org.json.JSONArray().also { array ->
// ⚠️ `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)))
}
@@ -206,7 +210,7 @@ class DeviceIdentity private constructor(
// because that is the one form both the JCA and a passphrase-derived
// account key agree on.
.put("ikSig", JSONObject()
.put("seed", Bytes.b64(Ed25519.seedOf(identitySigning.private)))
.put("seed", Bytes.b64(identitySigning.seed))
.put("pub", Bytes.b64(identitySigningPublic)))
.put("spk", x(signedPreKey))
.put("spkId", signedPreKeyId)
+49 -177
View File
@@ -1,216 +1,88 @@
package io.flamenet.e2e
import java.security.KeyFactory
import java.security.KeyPair
import java.security.KeyPairGenerator
import java.security.Signature
import java.security.spec.PKCS8EncodedKeySpec
import java.security.spec.X509EncodedKeySpec
import org.bouncycastle.math.ec.rfc8032.Ed25519 as Rfc8032
import java.security.SecureRandom
/**
* 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. An **account** signs its home records and its
* 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`.
*
* Keys move as raw 32-byte values on the wire and as JCA objects in memory, for
* the same reason as [X25519]: Android's provider will not build an EdDSA key
* from raw bytes, it wants a DER-encoded spec. An implementation that passed
* raw bytes to the JCA compiles, passes on a laptop, and throws on a phone.
* ⚠️ **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*, and every implementation must expand it the same way — which they do,
* because RFC 8032 fixes it. Feeding a seed somewhere 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.
* 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 = 32
const val SIG_LEN = 64
const val KEY_LEN = Rfc8032.SECRET_KEY_SIZE // 32
const val SIG_LEN = Rfc8032.SIGNATURE_SIZE // 64
private val random = SecureRandom()
/**
* X.509 wrapper for a public key: SEQUENCE, the id-Ed25519 OID
* (1.3.101.112), then a 33-byte BIT STRING. Identical in shape to X25519's,
* and differing in one byte of the OID — which is precisely why it is
* written out here rather than shared with a `when`: a single wrong byte
* yields the *other* algorithm's key type, and the JCA reports that as an
* opaque `InvalidKeySpecException`.
* 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`.
*/
private val SPKI_PREFIX = byteArrayOf(
0x30, 0x2a, // SEQUENCE, 42 bytes
0x30, 0x05, // SEQUENCE, 5 bytes
0x06, 0x03, 0x2b, 0x65, 0x70, // OID 1.3.101.112
0x03, 0x21, 0x00, // BIT STRING, 33 bytes, 0 unused
)
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)
/**
* PKCS#8 wrapper for a seed. The seed sits inside an OCTET STRING nested in
* another OCTET STRING — a quirk of RFC 8410, not a mistake here.
*/
private val PKCS8_PREFIX = byteArrayOf(
0x30, 0x2e, // SEQUENCE, 46 bytes
0x02, 0x01, 0x00, // INTEGER 0 (version)
0x30, 0x05, // SEQUENCE, 5 bytes
0x06, 0x03, 0x2b, 0x65, 0x70, // OID 1.3.101.112
0x04, 0x22, // OCTET STRING, 34 bytes
0x04, 0x20, // OCTET STRING, 32 bytes
)
override fun hashCode(): Int = publicKey.contentHashCode()
}
fun generate(): KeyPair = KeyPairGenerator.getInstance("Ed25519").generateKeyPair()
fun generate(): KeyPair = fromSeed(ByteArray(KEY_LEN).also(random::nextBytes))
/** A keypair from a 32-byte seed — the bridge from [AccountKdf] to a usable key. */
/** The bridge from [AccountKdf] — and from the vault — to a usable key. */
fun fromSeed(seed: ByteArray): KeyPair {
require(seed.size == KEY_LEN) { "an Ed25519 seed is $KEY_LEN bytes, got ${seed.size}" }
val factory = KeyFactory.getInstance("Ed25519")
val private = factory.generatePrivate(PKCS8EncodedKeySpec(PKCS8_PREFIX + seed))
// The public half is not recoverable from a PKCS#8 seed through the JCA
// without re-deriving it, so it is derived the only portable way: sign
// nothing and let the provider hand back the matching public key.
return KeyPair(publicFromPrivate(private), private)
val publicKey = ByteArray(Rfc8032.PUBLIC_KEY_SIZE)
Rfc8032.generatePublicKey(seed, 0, publicKey, 0)
return KeyPair(seed.copyOf(), publicKey)
}
fun publicKeyFromRaw(raw: ByteArray): java.security.PublicKey {
require(raw.size == KEY_LEN) { "an Ed25519 public key is $KEY_LEN bytes, got ${raw.size}" }
return KeyFactory.getInstance("Ed25519")
.generatePublic(X509EncodedKeySpec(SPKI_PREFIX + raw))
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 rawFromPublicKey(key: java.security.PublicKey): ByteArray {
val encoded = key.encoded
require(encoded.size == SPKI_PREFIX.size + KEY_LEN) {
"unexpected Ed25519 SPKI length ${encoded.size}"
}
return encoded.copyOfRange(SPKI_PREFIX.size, encoded.size)
}
fun sign(privateKey: java.security.PrivateKey, message: ByteArray): ByteArray {
val signer = Signature.getInstance("Ed25519")
signer.initSign(privateKey)
signer.update(message)
return signer.sign()
}
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 that has to wrap every check in a try/catch
* eventually wraps one badly and treats a thrown exception as a pass.
* 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(publicKeyRaw: ByteArray, signature: ByteArray, message: ByteArray): Boolean =
fun verify(publicKey: ByteArray, signature: ByteArray, message: ByteArray): Boolean =
try {
if (signature.size != SIG_LEN) false else {
val verifier = Signature.getInstance("Ed25519")
verifier.initVerify(publicKeyFromRaw(publicKeyRaw))
verifier.update(message)
verifier.verify(signature)
}
signature.size == SIG_LEN && publicKey.size == KEY_LEN &&
Rfc8032.verify(signature, 0, publicKey, 0, message, 0, message.size)
} catch (_: Exception) {
false
}
/**
* Derive the public half of a private key.
*
* The JCA offers no route from a private EdDSA key to its public one, so
* this does the RFC 8032 §5.1.5 derivation itself: hash the seed, clamp the
* low half into a scalar, and multiply the base point by it.
*/
private fun publicFromPrivate(privateKey: java.security.PrivateKey): java.security.PublicKey =
publicKeyFromRaw(Rfc8032.publicKeyFromSeed(seedOf(privateKey)))
/**
* The 32 seed bytes back out of a PKCS#8 encoding.
*
* Public because the vault stores a seed, not a DER blob: it is the one
* representation the JCA, CryptoKit and a passphrase-derived account key
* all agree on.
*/
fun seedOf(privateKey: java.security.PrivateKey): ByteArray {
val encoded = privateKey.encoded
require(encoded.size == PKCS8_PREFIX.size + KEY_LEN) {
"unexpected Ed25519 PKCS#8 length ${encoded.size}"
}
return encoded.copyOfRange(PKCS8_PREFIX.size, encoded.size)
}
}
/**
* Just enough Ed25519 arithmetic to turn a seed into its public key.
*
* This exists for exactly one reason: an account's address is derived from a
* passphrase, so the client must be able to compute `A = a·B` for a seed the
* KDF produced. Nothing else here reimplements a primitive — signing and
* verification go to the platform provider, which is audited and
* constant-time. Keep it that way.
*
* ⚠️ **This is not constant-time.** `BigInteger` branches on values, so the
* scalar multiplication below leaks timing about a secret scalar. The exposure
* is bounded and deliberate: it runs once, at sign-in, in the user's own
* process, on a device where an attacker able to measure it can already read
* the passphrase. It would NOT be acceptable for signing, which is why signing
* is not here.
*
* Verified against the RFC 8032 §7.1 test vectors rather than against a reading
* of this code.
*/
private object Rfc8032 {
private val P = java.math.BigInteger.TWO.pow(255) - java.math.BigInteger.valueOf(19)
private val D = java.math.BigInteger(
"37095705934669439343138083508754565189542113879843219016388785533085940283555")
private val BX = java.math.BigInteger(
"15112221349535400772501151409588531511454012693041857206046113283949847762202")
private val BY = java.math.BigInteger(
"46316835694926478169428394003475163141307993866256225615783033603165251855960")
/** Affine points on -x² + y² = 1 + d·x²·y². The identity is (0, 1). */
private data class Point(val x: java.math.BigInteger, val y: java.math.BigInteger)
private fun add(a: Point, b: Point): Point {
val xx = a.x * b.x
val yy = a.y * b.y
val dxy = D * xx % P * yy % P
val x = (a.x * b.y + b.x * a.y) * (java.math.BigInteger.ONE + dxy).modInverse(P) % P
val y = (yy + xx) * (java.math.BigInteger.ONE - dxy).modInverse(P) % P
return Point(x.mod(P), y.mod(P))
}
fun publicKeyFromSeed(seed: ByteArray): ByteArray {
val h = Bytes.sha512(seed)
// Clamp (RFC 8032 §5.1.5): clearing the low three bits puts the scalar
// in the prime-order subgroup, and forcing bit 254 fixes its length so
// a ladder cannot leak it. Both are required; a key derived without
// them is a different key, not a weaker one.
val s = h.copyOfRange(0, 32)
s[0] = (s[0].toInt() and 0xF8).toByte()
s[31] = (s[31].toInt() and 0x7F or 0x40).toByte()
var scalar = java.math.BigInteger.ZERO
for (i in 31 downTo 0) {
scalar = scalar.shiftLeft(8) + java.math.BigInteger.valueOf((s[i].toInt() and 0xFF).toLong())
}
// Double-and-add, from the top bit down.
var result = Point(java.math.BigInteger.ZERO, java.math.BigInteger.ONE)
var addend = Point(BX, BY)
var bit = 0
while (bit < 255) {
if (scalar.testBit(bit)) result = add(result, addend)
addend = add(addend, addend)
bit += 1
}
// Encode: y little-endian, with the parity of x in the top bit.
val out = ByteArray(32)
val y = result.y
for (i in 0 until 32) out[i] = y.shiftRight(i * 8).toInt().toByte()
if (result.x.testBit(0)) out[31] = (out[31].toInt() or 0x80).toByte()
return out
}
}
@@ -0,0 +1,143 @@
package io.flamenet.e2e
import org.json.JSONObject
import java.security.SecureRandom
import javax.crypto.Cipher
import javax.crypto.SecretKey
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) {
/** 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 delete(name: String)
fun names(): List<String>
}
class Corrupt(name: String, cause: Throwable?) :
Exception("the vault record '$name' did not open", cause)
companion object {
private const val NONCE_LEN = 12
private const val TAG_BITS = 128
private const val IDENTITY = "identity"
private const val WATERMARK = "watermark"
private const val SESSION_PREFIX = "session/"
private val random = SecureRandom()
}
// -- Bytes ---------------------------------------------------------------
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))
cipher.updateAAD(name.toByteArray(Charsets.UTF_8))
return nonce + cipher.doFinal(plaintext)
}
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,
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.
throw Corrupt(name, e)
}
}
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)))
// -- 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.
*/
fun loadIdentity(): DeviceIdentity? = readJson(IDENTITY)?.let(DeviceIdentity::fromArchive)
fun saveIdentity(identity: DeviceIdentity) = writeJson(IDENTITY, identity.archive())
fun hasIdentity(): Boolean = storage.read(IDENTITY) != null
// -- Sessions ------------------------------------------------------------
private fun sessionName(peerDeviceId: String) = SESSION_PREFIX + peerDeviceId
fun loadSession(peerDeviceId: String): E2ESession? =
readJson(sessionName(peerDeviceId))?.let(E2ESession::fromArchive)
fun saveSession(peerDeviceId: String, session: E2ESession) =
writeJson(sessionName(peerDeviceId), session.archive())
fun knownPeers(): List<String> =
storage.names().filter { it.startsWith(SESSION_PREFIX) }.map { it.removePrefix(SESSION_PREFIX) }
// -- 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))
// -- Forgetting ----------------------------------------------------------
/** Forget one conversation. The identity and every other session survive. */
fun forget(peerDeviceId: String) = storage.delete(sessionName(peerDeviceId))
/**
* 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. */
fun forgetAllSessions() =
storage.names().filter { it.startsWith(SESSION_PREFIX) }.forEach(storage::delete)
}
@@ -42,7 +42,7 @@ class Ed25519Test {
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), Ed25519.rawFromPublicKey(pair.public),
assertContentEquals(hex(expected), pair.publicKey,
"seed $seed derived the wrong public key")
}
}
@@ -52,7 +52,7 @@ class Ed25519Test {
for ((seed, _, msgSig) in vectors) {
val (message, signature) = msgSig
val pair = Ed25519.fromSeed(hex(seed))
assertContentEquals(hex(signature), Ed25519.sign(pair.private, hex(message)))
assertContentEquals(hex(signature), Ed25519.sign(pair, hex(message)))
}
}
@@ -76,7 +76,7 @@ class Ed25519Test {
val (seed, publicKey, _) = vectors[2]
val pair = Ed25519.fromSeed(hex(seed))
val message = "prekey bytes".toByteArray()
val signature = Ed25519.sign(pair.private, message)
val signature = Ed25519.sign(pair, message)
for (i in signature.indices step 7) {
val bent = signature.copyOf()
@@ -104,9 +104,8 @@ class Ed25519Test {
@Test
fun `a generated keypair round-trips through its raw encoding`() {
val pair = Ed25519.generate()
val raw = Ed25519.rawFromPublicKey(pair.public)
assertEquals(32, raw.size)
assertEquals(32, pair.publicKey.size)
val message = "round trip".toByteArray()
assertTrue(Ed25519.verify(raw, Ed25519.sign(pair.private, message), message))
assertTrue(Ed25519.verify(pair.publicKey, Ed25519.sign(pair, message), message))
}
}
@@ -0,0 +1,191 @@
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.assertFailsWith
import kotlin.test.assertFalse
import kotlin.test.assertNotEquals
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<String, ByteArray>()
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 names() = records.keys.toList()
}
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))))
}
@Test
fun `an identity survives being stored and read back`() {
val storage = MapStorage()
val secret = key()
val identity = DeviceIdentity.create()
identity.generateOneTimePreKeys(2)
Vault(storage, secret).saveIdentity(identity)
val restored = Vault(storage, secret).loadIdentity()!!
assertEquals(identity.deviceId, restored.deviceId)
assertEquals(2, restored.oneTimePreKeyCount)
// The private halves came back too, which only a real handshake proves.
val peer = DeviceIdentity.create()
val session = E2ESession.initiate(peer, bundleFor(restored))
val envelope = session.encrypt("after a cold start")
assertEquals("after a cold start", E2ESession.respond(restored, envelope).decrypt(envelope))
}
@Test
fun `a conversation survives being stored and read back`() {
val storage = MapStorage()
val secret = key()
val alice = DeviceIdentity.create()
val bob = DeviceIdentity.create()
val a = E2ESession.initiate(alice, bundleFor(bob))
val first = a.encrypt("hello")
val b = E2ESession.respond(bob, first)
b.decrypt(first)
Vault(storage, secret).saveSession(alice.deviceId, b)
val restored = Vault(storage, secret).loadSession(alice.deviceId)!!
assertEquals("still talking", restored.decrypt(a.encrypt("still talking")))
assertEquals(listOf(alice.deviceId), Vault(storage, secret).knownPeers())
}
/** What is on disk must not be readable, or none of this was worth doing. */
@Test
fun `nothing recognisable is written in the clear`() {
val storage = MapStorage()
val identity = DeviceIdentity.create()
Vault(storage, key()).saveIdentity(identity)
val onDisk = String(storage.records.values.first(), Charsets.ISO_8859_1)
assertFalse(onDisk.contains(identity.deviceId))
assertFalse(onDisk.contains("ikDH"))
assertFalse(onDisk.contains(Bytes.b64(identity.identityDHPublic)))
}
/**
* ⚠️ 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.
*/
@Test
fun `a record moved to another slot does not open`() {
val storage = MapStorage()
val secret = key()
val vault = Vault(storage, secret)
val alice = DeviceIdentity.create()
val bob = DeviceIdentity.create()
val session = E2ESession.initiate(alice, bundleFor(bob))
vault.saveSession("peer-a", session)
val blob = storage.records.getValue("session/peer-a")
storage.records["session/peer-b"] = blob
assertFailsWith<Vault.Corrupt> { vault.loadSession("peer-b") }
// And the record in its own slot is untouched by the attempt.
assertTrue(vault.loadSession("peer-a") != null)
}
@Test
fun `a different key does not open the vault`() {
val storage = MapStorage()
Vault(storage, key()).saveIdentity(DeviceIdentity.create())
assertFailsWith<Vault.Corrupt> { Vault(storage, key()).loadIdentity() }
}
@Test
fun `a flipped bit is refused rather than half-read`() {
val storage = MapStorage()
val secret = key()
Vault(storage, secret).saveIdentity(DeviceIdentity.create())
val blob = storage.records.getValue("identity")
blob[blob.size / 2] = (blob[blob.size / 2].toInt() xor 1).toByte()
assertFailsWith<Vault.Corrupt> { Vault(storage, secret).loadIdentity() }
}
/**
* ⚠️ 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.
*/
@Test
fun `an unreadable identity is not mistaken for a missing one`() {
val storage = MapStorage()
val secret = key()
assertNull(Vault(storage, secret).loadIdentity())
assertFalse(Vault(storage, secret).hasIdentity())
Vault(storage, secret).saveIdentity(DeviceIdentity.create())
storage.records["identity"] = ByteArray(64)
assertTrue(Vault(storage, secret).hasIdentity())
assertFailsWith<Vault.Corrupt> { Vault(storage, secret).loadIdentity() }
}
@Test
fun `the inbox watermark round-trips and starts at zero`() {
val storage = MapStorage()
val secret = key()
assertEquals(0, Vault(storage, secret).inboxWatermark)
Vault(storage, secret).inboxWatermark = 4207
assertEquals(4207, Vault(storage, secret).inboxWatermark)
}
@Test
fun `forgetting removes rather than marks`() {
val storage = MapStorage()
val secret = key()
val vault = Vault(storage, secret)
val alice = DeviceIdentity.create()
vault.saveIdentity(alice)
vault.saveSession("peer-a", E2ESession.initiate(alice, bundleFor(DeviceIdentity.create())))
vault.saveSession("peer-b", E2ESession.initiate(alice, bundleFor(DeviceIdentity.create())))
vault.forget("peer-a")
assertFalse(storage.records.keys.any { it.contains("peer-a") })
assertEquals(listOf("peer-b"), vault.knownPeers())
vault.forgetAllSessions()
assertEquals(emptyList(), vault.knownPeers())
assertTrue(vault.hasIdentity(), "signing out of a chat is not signing out of the app")
// Sign-out takes the identity too: the next person to use this phone
// must not inherit a device the relay still has messages for.
vault.forgetEverything()
assertFalse(vault.hasIdentity())
assertEquals(emptyList(), storage.names())
}
@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.
val storage = MapStorage()
val secret = key()
val identity = DeviceIdentity.create()
Vault(storage, secret).saveIdentity(identity)
val first = storage.records.getValue("identity").copyOf()
Vault(storage, secret).saveIdentity(identity)
assertNotEquals(Bytes.b64(first), Bytes.b64(storage.records.getValue("identity")))
}
}
+8
View File
@@ -0,0 +1,8 @@
# The Android Gradle Plugin needs more than the default heap once Compose's
# annotation processing is in the picture.
org.gradle.jvmargs=-Xmx3g -Dfile.encoding=UTF-8
org.gradle.parallel=true
org.gradle.caching=true
android.useAndroidX=true
android.nonTransitiveRClass=true
@@ -45,7 +45,7 @@ private var counter = 0
private class Device(
val deviceId: String,
val ikDH: KeyPair,
val ikSig: KeyPair,
val ikSig: Ed25519.KeyPair,
val signedPreKeyId: Int,
val signedPreKey: KeyPair,
val oneTimePreKeyId: Int,
@@ -136,10 +136,10 @@ private fun newDevice(): JSONObject {
val bundle = JSONObject()
.put("device_id", id)
.put("ik_dh", b64(X25519.rawFromPublicKey(device.ikDH.public)))
.put("ik_sig", b64(Ed25519.rawFromPublicKey(device.ikSig.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.private, 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))))
@@ -173,7 +173,7 @@ private fun initiate(req: JSONObject): JSONObject {
)
// v1 associated data: the two signing identities, initiator first.
val ad = Ed25519.rawFromPublicKey(me.ikSig.public) + theirIkSig
val ad = me.ikSig.publicKey + theirIkSig
val ratchet = DoubleRatchet.initiator(initiated.sharedSecret, spk)
val id = next("kotlin-session-")
sessions[id] = Session(ratchet, ad)
@@ -204,7 +204,7 @@ private fun respond(req: JSONObject): JSONObject {
initiatorEphemeral = req.bytes("ek"),
)
val ad = theirIkSig + Ed25519.rawFromPublicKey(me.ikSig.public)
val ad = theirIkSig + me.ikSig.publicKey
val ratchet = DoubleRatchet.responder(sharedSecret, me.signedPreKey)
val id = next("kotlin-session-")
sessions[id] = Session(ratchet, ad)
@@ -326,7 +326,7 @@ private fun accountKdf(req: JSONObject): JSONObject {
// 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.
.put("account_public", b64(Ed25519.rawFromPublicKey(Ed25519.fromSeed(seed).public)))
.put("account_public", b64(Ed25519.fromSeed(seed).publicKey))
}
private fun logLeaf(req: JSONObject): JSONObject {
+20
View File
@@ -1,3 +1,18 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
}
}
// Flamenet Messenger — Android.
//
// `engine` is deliberately a plain Kotlin/JVM library, not an Android library:
@@ -11,5 +26,10 @@ include(":engine")
// The relay transport: HTTP, auth, and the §8 routes.
include(":client")
// The Compose app. Everything below it -- the protocol and the transport --
// lives in `:engine` and `:client`, which are plain JVM libraries so they can
// be tested without an emulator.
include(":app")
// The cross-engine interop driver. Not shipped; see `engine/test/interop-kotlin.mjs`.
include(":interop")