Android can show an address, and the Account screen looks like the app

Two gaps, one in each direction.

Android could ENCODE an address and had no way to DERIVE one, so it
could not show yours and nobody on another relay could be given it --
the cross-relay work was iOS-only in practice. The engine was missing
`localAccountSalt` and a way to get both sign-in secrets from one
stretch; asking for the account key separately re-runs 650,000 PBKDF2
iterations for a value already in hand. The salt is pinned against the
JavaScript engine, which is normative -- a Kotlin test asserting what
Kotlin computes would prove only that Kotlin is self-consistent, and
the failure to guard against is three engines disagreeing, which shows
up as one person having two addresses depending on which phone they
used.

The address is stored in the vault because it can only be derived at
sign-in: the passphrase exists for that one moment and is never kept,
so a restored session had nothing to recompute from. Caught by the
empty state saying so on the emulator rather than showing a blank.

A QR encoder, written rather than depended on. Android has no encoder
in the platform, and this is one fully specified algorithm for one
fixed job -- turning a short ASCII address into a bitmap. It is
verified by DECODING: thirteen codes, every version from 1 to 10 at
exact capacity, read back by CoreImage's detector, which shares no line
with it. That found the bug that mattered. The symbol was structurally
perfect -- finders, timing, alignment, data placement and mask all
confirmed correct against a reference symbol -- and nothing could read
it, because the fifteen format bits were written least-significant
first. It fails identically at every version with no clue where.

The Account screen was a flat stack of fields, buttons and paragraphs
with dividers between them, so a security setting carried the same
weight as a text box and "Delete account" the same as "Save display
name". Cards now, matching the Contacts screen that already had them,
with the destructive pair on their own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-27 13:36:58 -04:00
co-authored by Claude Opus 5
parent 28d7485c26
commit e46d51430a
10 changed files with 1007 additions and 115 deletions
@@ -0,0 +1,133 @@
package io.flamenet.messenger.app
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Color as AndroidColor
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.FilterQuality
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import io.flamenet.e2e.QrCode
/**
* Your address, as a code somebody can scan and a string they can copy.
*
* 🔴 **Android had no way to show this at all**, which made the cross-relay
* work iOS-only in practice: an address you cannot display is an address
* nobody can send to. The engine could encode an fnid and had no way to derive
* one, so there was nothing to put on a screen even if a screen had existed.
*
* ⚠️ **The string stays visible under the code.** An fnid is a derived key, not
* a name — an address that is nearly right addresses somebody else — and a
* scanner is not always to hand. Copy is there because retyping 52 characters
* off a screen is how the wrong person gets messaged.
*/
@Composable
fun AddressCard(fnid: String?, relay: String) {
val context = LocalContext.current
Column(Modifier.fillMaxWidth()) {
Text("Your address", style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(Space.sm))
if (fnid == null) {
// ⚠️ Said plainly rather than hidden. Without an address this phone
// cannot be reached from another relay, and a blank space would
// read as the feature not existing rather than as it not working.
Text(
"This device could not derive your address. You can still message "
+ "people on ${relay}, but nobody on another relay can reach you.",
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.testTag("address-missing"),
)
return@Column
}
val address = "$fnid@$relay"
val encoded = "flamenet:$address"
val bitmap = remember(encoded) { qrBitmap(encoded) }
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (bitmap != null) {
Image(
bitmap = bitmap.asImageBitmap(),
contentDescription = "Your address as a QR code",
// ⚠️ None, or the modules blur when scaled up and the code
// stops scanning — the default filtering is smooth.
filterQuality = FilterQuality.None,
contentScale = ContentScale.FillBounds,
modifier = Modifier.size(180.dp).testTag("address-qr"),
)
Spacer(Modifier.height(Space.sm))
}
Text(
address,
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center,
modifier = Modifier.testTag("address-text"),
)
Spacer(Modifier.height(Space.sm))
Row(horizontalArrangement = Arrangement.spacedBy(Space.sm)) {
OutlinedButton(
onClick = { copy(context, "Flamenet address", address) },
modifier = Modifier.testTag("copy-address"),
) { Text("Copy address") }
}
}
Spacer(Modifier.height(Space.sm))
Text(
"Derived from your passphrase, not issued by a server. Sign in anywhere "
+ "with the same passphrase and this stays the same — which is what "
+ "lets it outlive any one relay.",
style = MaterialTheme.typography.bodySmall,
)
}
}
private fun copy(context: Context, label: String, text: String) {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText(label, text))
}
/**
* One module per pixel plus a quiet zone; Compose scales it up.
*
* ⚠️ The four-module quiet zone is not decoration. A QR printed hard against
* other content does not scan, and the margin has to be part of the bitmap
* because the surrounding card has a background of its own.
*/
private fun qrBitmap(text: String, quiet: Int = 4): Bitmap? {
val matrix = runCatching { QrCode.encode(text) }.getOrNull() ?: return null
val side = matrix.size + quiet * 2
val pixels = IntArray(side * side) { AndroidColor.WHITE }
for (r in 0 until matrix.size) {
for (c in 0 until matrix.size) {
if (matrix[r, c]) pixels[(r + quiet) * side + (c + quiet)] = AndroidColor.BLACK
}
}
return Bitmap.createBitmap(pixels, side, side, Bitmap.Config.ARGB_8888)
}
@@ -3,6 +3,10 @@ package io.flamenet.messenger.app
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.material3.Surface
import androidx.compose.material3.ButtonDefaults
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
@@ -498,125 +502,178 @@ fun ColumnScope.AccountScreen(
Text("Device ${phase.deviceId}", style = MaterialTheme.typography.bodySmall,
modifier = Modifier.testTag("device-id"))
Divider()
Spacer(Modifier.height(Space.lg))
OutlinedTextField(
value = displayName,
onValueChange = { displayName = it },
label = { Text("Display name") },
singleLine = true,
modifier = Modifier.fillMaxWidth().testTag("display-name"),
)
Button(
onClick = { model.updateDisplayName(displayName.trim()); displayName = "" },
enabled = displayName.isNotBlank() && !phase.busy,
modifier = Modifier.fillMaxWidth().testTag("save-display-name"),
) { Text("Save display name") }
// 🔴 **Cards, like every other screen in this app.** This one was a flat
// stack of full-width fields, buttons and paragraph-length explanations
// with `Divider()` between them — no grouping, so "change your password"
// and "delete your account" carried exactly the same visual weight, and a
// security setting sat in the same undifferentiated column as a text box.
// The Contacts screen was given this treatment and Account was missed.
SettingsCard {
AddressCard(fnid = phase.fnid, relay = phase.relay)
}
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("New password") },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth().testTag("new-password"),
)
Button(
onClick = { model.updatePassword(password); password = "" },
enabled = password.length >= 8 && !phase.busy,
modifier = Modifier.fillMaxWidth().testTag("save-password"),
) { Text("Change password") }
Text(
"Changing it signs this device out — that is what changing it means.",
style = MaterialTheme.typography.bodySmall,
)
Divider()
// -- Security (§19) ------------------------------------------------------
Text("Security", style = MaterialTheme.typography.titleMedium)
if (phase.forkDetected) {
// Above the toggle, not below: this is a statement about the server the
// toggle configures, and it changes what the toggle means.
Text(
"⚠️ This relay showed two different key logs.",
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.testTag("fork-detected"),
Spacer(Modifier.height(Space.md))
SettingsCard {
Text("Profile", style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(Space.sm))
OutlinedTextField(
value = displayName,
onValueChange = { displayName = it },
label = { Text("Display name") },
singleLine = true,
modifier = Modifier.fillMaxWidth().testTag("display-name"),
)
Spacer(Modifier.height(Space.sm))
Button(
onClick = { model.updateDisplayName(displayName.trim()); displayName = "" },
enabled = displayName.isNotBlank() && !phase.busy,
modifier = Modifier.fillMaxWidth().testTag("save-display-name"),
) { Text("Save display name") }
Spacer(Modifier.height(Space.lg))
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("New password") },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth().testTag("new-password"),
)
Spacer(Modifier.height(Space.sm))
Button(
onClick = { model.updatePassword(password); password = "" },
enabled = password.length >= 8 && !phase.busy,
modifier = Modifier.fillMaxWidth().testTag("save-password"),
) { Text("Change password") }
Spacer(Modifier.height(Space.sm))
Text(
"Two devices compared what this server told each of them about its key log, and the "
+ "answers cannot both be true. A server doing that can hand different people "
+ "different keys for the same person. Treat conversations here as compromised "
+ "until you have verified safety numbers in person.",
"Changing it signs this device out — that is what changing it means.",
style = MaterialTheme.typography.bodySmall,
)
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text("Require published keys", modifier = Modifier.weight(1f))
Switch(
checked = phase.requireKeyLog,
onCheckedChange = model::setRequireKeyLog,
modifier = Modifier.testTag("require-key-log"),
// -- Security (§19) ------------------------------------------------------
Spacer(Modifier.height(Space.md))
SettingsCard {
Text("Security", style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(Space.sm))
if (phase.forkDetected) {
// Above the toggle, not below: this is a statement about the server
// the toggle configures, and it changes what the toggle means.
Text(
"⚠️ This relay showed two different key logs.",
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.testTag("fork-detected"),
)
Text(
"Two devices compared what this server told each of them about its key log, and the "
+ "answers cannot both be true. A server doing that can hand different people "
+ "different keys for the same person. Treat conversations here as compromised "
+ "until you have verified safety numbers in person.",
style = MaterialTheme.typography.bodySmall,
)
Spacer(Modifier.height(Space.sm))
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text("Require published keys", modifier = Modifier.weight(1f))
Switch(
checked = phase.requireKeyLog,
onCheckedChange = model::setRequireKeyLog,
modifier = Modifier.testTag("require-key-log"),
)
}
Text(
"Refuse to send unless this server can prove the recipient's keys appear in the public "
+ "log it publishes. Stronger, but older servers keep no log and every message to "
+ "them will fail. Your keys are always compared against what you saw before, "
+ "whether this is on or off.",
style = MaterialTheme.typography.bodySmall,
)
Spacer(Modifier.height(Space.lg))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text("Sealed sender", modifier = Modifier.weight(1f))
TextButton(
onClick = model::rotateDeliveryKey,
modifier = Modifier.testTag("rotate-delivery-key"),
) { Text("New delivery key") }
}
Text(
"Messages you send are addressed to the recipient without naming you, once they have "
+ "your delivery key. Replacing it is the only way to stop someone who is abusing "
+ "it — and it cuts off every contact until you next message them.",
style = MaterialTheme.typography.bodySmall,
)
}
Text(
"Refuse to send unless this server can prove the recipient's keys appear in the public "
+ "log it publishes. Stronger, but older servers keep no log and every message to "
+ "them will fail. Your keys are always compared against what you saw before, "
+ "whether this is on or off.",
style = MaterialTheme.typography.bodySmall,
)
Row(
// ⚠️ **The destructive pair, in their own card.** Sign-out lives here
// rather than under the contact list because this is where people look for
// it, and because it is destructive enough to sit beside the other
// destructive thing rather than under a list of friends. Separating the
// card is what stops "Sign out" reading like one more setting.
Spacer(Modifier.height(Space.md))
SettingsCard {
Button(
onClick = model::signOut,
modifier = Modifier.fillMaxWidth().testTag("sign-out"),
) { Text("Sign out") }
Spacer(Modifier.height(Space.sm))
Text(
"Signing out erases this phone's keys and stored messages. They cannot be recovered.",
style = MaterialTheme.typography.bodySmall,
)
Spacer(Modifier.height(Space.lg))
Button(
onClick = { if (confirmingDelete) model.deleteAccount() else confirmingDelete = true },
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError,
),
modifier = Modifier.fillMaxWidth().testTag("delete-account"),
) {
Text(if (confirmingDelete) "Really delete — this cannot be undone" else "Delete account")
}
Spacer(Modifier.height(Space.sm))
Text(
"Deletes the account on ${phase.relay} and erases this phone's keys and messages.",
style = MaterialTheme.typography.bodySmall,
)
}
Spacer(Modifier.height(Space.lg))
}
/**
* One group of settings on its own surface.
*
* ⚠️ Matches the cards the Contacts screen already uses rather than inventing a
* second card style — same shape, same elevation, same padding. Two kinds of
* card in one app reads as an unfinished redesign, which is what this was.
*/
@Composable
private fun SettingsCard(content: @Composable ColumnScope.() -> Unit) {
Surface(
shape = RoundedCornerShape(Radius.medium),
color = MaterialTheme.colorScheme.surface,
shadowElevation = 2.dp,
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text("Sealed sender", modifier = Modifier.weight(1f))
TextButton(
onClick = model::rotateDeliveryKey,
modifier = Modifier.testTag("rotate-delivery-key"),
) { Text("New delivery key") }
Column(Modifier.padding(Space.lg), content = content)
}
Text(
"Messages you send are addressed to the recipient without naming you, once they have "
+ "your delivery key. Replacing it is the only way to stop someone who is abusing "
+ "it — and it cuts off every contact until you next message them.",
style = MaterialTheme.typography.bodySmall,
)
Divider()
// Sign-out lives here rather than under the contact list, because this is
// where people look for it — and because it is destructive enough to sit
// beside the other destructive things rather than under a list of friends.
Button(
onClick = model::signOut,
modifier = Modifier.fillMaxWidth().testTag("sign-out"),
) { Text("Sign out") }
Text(
"Signing out erases this phone's keys and stored messages. They cannot be recovered.",
style = MaterialTheme.typography.bodySmall,
)
Button(
onClick = { if (confirmingDelete) model.deleteAccount() else confirmingDelete = true },
modifier = Modifier.fillMaxWidth().testTag("delete-account"),
) {
Text(if (confirmingDelete) "Really delete — this cannot be undone" else "Delete account")
}
Text(
"Deletes the account on ${phase.relay} and erases this phone's keys and messages.",
style = MaterialTheme.typography.bodySmall,
)
}
@Composable
@@ -8,7 +8,10 @@ import io.flamenet.client.RelayClient
import io.flamenet.client.RelayAccounts
import io.flamenet.client.MessengerApi
import io.flamenet.client.RelayException
import io.flamenet.e2e.AccountKdf
import io.flamenet.e2e.DeviceIdentity
import io.flamenet.e2e.Ed25519
import io.flamenet.e2e.FNID
import io.flamenet.e2e.Vault
import kotlinx.coroutines.Dispatchers
import io.flamenet.client.IdentityTrust
@@ -50,6 +53,19 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
val deviceId: String,
val relay: String,
val status: String,
/**
* This account's global address, `fnid:...`, or null if it could
* not be derived.
*
* 🔴 **Derived on this device, never issued.** It is the public
* half of a key stretched from the passphrase, which is what lets
* it survive a relay disappearing — and what makes it the only
* thing somebody on another server can address. Null is a real
* state worth rendering honestly rather than hiding: without it
* this phone cannot be reached across a relay boundary at all.
*/
val fnid: String? = null,
val buddies: List<MessengerApi.Buddy> = emptyList(),
/**
@@ -245,6 +261,11 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
userId = credential.userId,
deviceId = stored.deviceId,
relay = SiteConfig(credential.relayBaseUrl).host,
// From the vault, because a restore has no passphrase
// to derive it from. Without this the Account screen
// said "this device could not derive your address"
// after every relaunch, which was true and useless.
fnid = credential.fnid,
status = "Signed in on this device.",
buddies = fetchedBuddies.getOrDefault(emptyList()),
buddiesLoaded = fetchedBuddies.isSuccess,
@@ -302,6 +323,10 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
email: String,
): Phase {
val relayAccounts = RelayAccounts(config.baseUrl)
// Set by the shared branch below, which gets it free from the stretch
// it already runs. Left null for a relay-local account, which derives
// its own further down.
var sharedSeed: ByteArray? = null
val account = when (config.accountProvider) {
SiteConfig.AccountProvider.SHARED -> {
// The passphrase is stretched on this device and only the
@@ -310,7 +335,9 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
// learns a product-scoped pseudonym and not the handle.
val accounts = AccountsClient(config.accountServiceUrl)
if (register) accounts.register(handle, passphrase)
relayAccounts.sharedSession(accounts.signIn(handle, passphrase).token, handle)
val session = accounts.signIn(handle, passphrase)
sharedSeed = session.accountKeySeed
relayAccounts.sharedSession(session.token, handle)
}
// A relay-local account authenticates against the relay itself,
// which is what "this server keeps its own accounts" means. The
@@ -324,6 +351,28 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
}
val credential = account.appPassword
// 🔴 **The address, derived here because this is the only moment the
// passphrase exists.** Android could encode an address and had no way
// to produce its own, so the Account screen could not show one and
// nobody on another relay could be given one — the federation work was
// iOS-only in practice. A shared account gets the seed back from the
// one stretch sign-in already paid for; a relay-local account has no
// stored salt by design, so the salt is a pure function of the handle.
//
// ⚠️ `runCatching`: an address is worth having and nothing else on this
// screen depends on it. A device too old for Ed25519 from a raw seed
// should still sign in.
val accountKeySeed: ByteArray? = when (config.accountProvider) {
SiteConfig.AccountProvider.SHARED -> sharedSeed
SiteConfig.AccountProvider.LOCAL -> runCatching {
AccountKdf.accountKeySeed(
passphrase, AccountKdf.localAccountSalt(account.username))
}.getOrNull()
}
val fnid = accountKeySeed?.let {
runCatching { FNID.address(Ed25519.fromSeed(it).publicKey) }.getOrNull()
}
val vault = vault ?: error("the vault is not open")
val device = identity ?: DeviceIdentity.create().also {
vault.saveIdentity(it)
@@ -344,6 +393,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
username = account.username,
appPassword = credential,
userId = account.userId,
fnid = fnid,
))
session = Session(
@@ -362,6 +412,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
userId = account.userId,
deviceId = device.deviceId,
relay = config.host,
fnid = fnid,
status = "Registered with ${preKeys.size} one-time prekeys published.",
buddies = fetchedBuddies.getOrDefault(emptyList()),
buddiesLoaded = fetchedBuddies.isSuccess,
@@ -51,7 +51,18 @@ class AccountsClient(
*/
data class Params(val salt: ByteArray, val kdfId: Int, val iterations: Int)
data class Session(val token: String, val pseudonym: String, val expiresAt: String)
/**
* @property accountKeySeed the 32-byte Ed25519 seed whose public half is
* this account's address. Derived from the same stretch as the auth
* secret and never sent anywhere — see [AccountKdf.signInSecrets] for
* why it comes back here rather than being asked for separately.
*/
data class Session(
val token: String,
val pseudonym: String,
val expiresAt: String,
val accountKeySeed: ByteArray? = null,
)
sealed class Failure(message: String) : Exception(message) {
/** Wrong handle and wrong passphrase are the same answer, on purpose. */
@@ -97,7 +108,10 @@ class AccountsClient(
iterations = params.iterations,
saltBytes = params.salt.size,
)
val secret = AccountKdf.authSecret(passphrase, params.salt, effective)
// Both secrets from ONE stretch. Asking for the account key separately
// would re-run 650,000 iterations for a value already in hand — several
// seconds on a phone, spent twice, to sign in once.
val (secret, accountKeySeed) = AccountKdf.signInSecrets(passphrase, params.salt, effective)
val json = post("sessions", JSONObject()
.put("handle", handle)
@@ -107,6 +121,7 @@ class AccountsClient(
token = json.getString("token"),
pseudonym = json.optString("pseudonym", ""),
expiresAt = json.optString("expiresAt", ""),
accountKeySeed = accountKeySeed,
)
}
@@ -164,4 +164,50 @@ object AccountKdf {
*/
fun accountKeySeed(passphrase: String, salt: ByteArray, params: Params = Params.PBKDF2_V1): ByteArray =
derive(passphrase, salt, Context.ACCOUNT_IDENTITY, 32, params)
/**
* Both secrets a sign-in needs, from a single stretch.
*
* 🔴 **Ask for them together or pay twice.** `derive` re-runs PBKDF2 on
* every call, so asking for the auth secret and then the account key is
* 1.3 million iterations — measured at several seconds on a mid-range
* phone, for a value that was already in hand. The stretch is the whole
* cost; the two HKDF expansions are free.
*/
fun signInSecrets(
passphrase: String,
salt: ByteArray,
params: Params = Params.PBKDF2_V1,
): Pair<ByteArray, ByteArray> {
val ikm = master(passphrase, salt, params)
fun expand(context: String) = Hkdf.derive(
ikm = ikm,
salt = ByteArray(0),
info = context.toByteArray(Charsets.UTF_8),
length = 32,
)
return expand(Context.ACCOUNT_AUTH) to expand(Context.ACCOUNT_IDENTITY)
}
/**
* The salt for an account that lives on the relay itself, where no service
* stores one.
*
* 🔴 **Relay-independent on purpose.** The same handle and passphrase
* produce the same address at every self-hosted relay, which is exactly
* what lets one person keep mailboxes on two of them. It also means two
* different people who pick the same handle AND the same passphrase
* collide into one identity — which is the same thing as sharing a
* password, and no worse for being said out loud.
*
* ⚠️ Normalised first, or `Alice` and `alice` are two people to the KDF
* and one person to every relay that stores them.
*/
fun localAccountSalt(handle: String, params: Params = Params.PBKDF2_V1): ByteArray =
Hkdf.derive(
ikm = handle.trim().lowercase().toByteArray(Charsets.UTF_8),
salt = ByteArray(0),
info = "flamenet/local-account-salt/v1".toByteArray(Charsets.UTF_8),
length = params.saltBytes,
)
}
@@ -10,12 +10,11 @@ package io.flamenet.e2e
*
* Implements `engine/src/fnid.js`, which is normative.
*
* ⚠️ This engine can *encode* an address but cannot yet *derive* one: the
* account key comes from PBKDF2 over the passphrase under
* `flamenet/account/identity/v1`, and this module has no KDF at all — the JS
* and Swift engines each have one and Kotlin does not. Until that exists, an
* Android client can verify and display an address it is given and cannot
* produce its own.
* Deriving one takes two steps that live elsewhere, because neither belongs
* to an encoder: `AccountKdf.accountKeySeed` stretches the passphrase under
* `flamenet/account/identity/v1`, and `Ed25519.fromSeed` turns those 32 bytes
* into the public key this encodes. `AccountKdf.localAccountSalt` supplies the
* salt for a relay that keeps its own accounts and therefore stores none.
*/
object FNID {
@@ -0,0 +1,450 @@
package io.flamenet.e2e
/**
* A QR code, so an address can be shown to another phone.
*
* 🔴 **Written rather than pulled in.** Android has no QR *encoder* in the
* platform — `CIFilter.qrCodeGenerator` is an Apple API with no counterpart —
* and the usual answer is a third-party library. This is ~250 lines of a fully
* specified algorithm (ISO/IEC 18004) for one fixed job: turn one short ASCII
* address into a bitmap. A dependency for that is a supply chain, an update
* treadmill and a licence, in exchange for code nobody would otherwise read.
*
* ⚠️ **Byte mode, level M, versions 1-10, and no more.** The input is always an
* address — `flamenet:fnid:<52 chars>@<hostname>` — so alphanumeric and kanji
* modes would never fire, and level M is what the iOS side already uses: at H
* the module count grows enough that the code stops scanning from a phone held
* at arm's length, which is the only way anyone will use this. Version 10 at M
* holds 216 bytes, roughly three times the longest address anyone can have.
* Anything larger throws rather than silently producing a code that will not
* scan.
*
* ⚠️ **Verified by decoding, not by inspection.** A QR encoder that is subtly
* wrong still produces a plausible-looking square of dots; nothing about
* looking at it tells you whether a scanner will read it. `QrCodeTest` renders
* these and decodes them with an implementation that shares no code with this
* one.
*/
object QrCode {
/** Level M. The other levels are not implemented; see the class note. */
private const val EC_LEVEL_BITS = 0b00
/**
* Per version (index 1..10): error-correction codewords per block, then the
* block layout as (count, data codewords) pairs for group one and group two.
*
* Straight from the standard's table. A single wrong number here produces a
* code that is structurally valid and unreadable, which is why the test
* decodes every version rather than checking sizes.
*/
private data class Spec(
val ecPerBlock: Int,
val group1Blocks: Int,
val group1Data: Int,
val group2Blocks: Int = 0,
val group2Data: Int = 0,
) {
val dataCodewords: Int get() = group1Blocks * group1Data + group2Blocks * group2Data
val totalBlocks: Int get() = group1Blocks + group2Blocks
}
private val SPECS = mapOf(
1 to Spec(10, 1, 16),
2 to Spec(16, 1, 28),
3 to Spec(26, 1, 44),
4 to Spec(18, 2, 32),
5 to Spec(24, 2, 43),
6 to Spec(16, 4, 27),
7 to Spec(18, 4, 31),
8 to Spec(22, 2, 38, 2, 39),
9 to Spec(22, 3, 36, 2, 37),
10 to Spec(26, 4, 43, 1, 44),
)
/** Alignment-pattern centre coordinates per version. Version 1 has none. */
private val ALIGNMENT = mapOf(
1 to intArrayOf(),
2 to intArrayOf(6, 18),
3 to intArrayOf(6, 22),
4 to intArrayOf(6, 26),
5 to intArrayOf(6, 30),
6 to intArrayOf(6, 34),
7 to intArrayOf(6, 22, 38),
8 to intArrayOf(6, 24, 42),
9 to intArrayOf(6, 26, 46),
10 to intArrayOf(6, 28, 50),
)
/**
* The finished code: `size` x `size` modules, true meaning dark.
*
* No quiet zone. The caller adds it when rendering — a QR without four
* modules of margin does not scan, and where that margin lives is a
* rendering decision, not an encoding one.
*/
class Matrix(val size: Int) {
private val dark = BooleanArray(size * size)
private val reserved = BooleanArray(size * size)
operator fun get(row: Int, col: Int): Boolean = dark[row * size + col]
internal fun set(row: Int, col: Int, value: Boolean, reserve: Boolean = false) {
dark[row * size + col] = value
if (reserve) reserved[row * size + col] = true
}
internal fun isReserved(row: Int, col: Int): Boolean = reserved[row * size + col]
internal fun inBounds(row: Int, col: Int) = row in 0 until size && col in 0 until size
}
/** Encode [text] as UTF-8 in byte mode. */
fun encode(text: String): Matrix {
val data = text.toByteArray(Charsets.UTF_8)
val version = SPECS.entries
.sortedBy { it.key }
.firstOrNull { data.size + headerBytes(it.key) <= it.value.dataCodewords }
?.key
?: throw IllegalArgumentException(
"${data.size} bytes will not fit in a version-10 QR code at level M")
val spec = SPECS.getValue(version)
val codewords = interleave(bitStream(data, version, spec), spec)
return draw(version, codewords)
}
/** Mode indicator plus character count, rounded up to whole codewords. */
private fun headerBytes(version: Int): Int = if (version < 10) 2 else 3
/** Mode nibble, length, payload, terminator, pad — as a codeword array. */
private fun bitStream(data: ByteArray, version: Int, spec: Spec): IntArray {
val bits = BitBuffer()
bits.append(0b0100, 4) // byte mode
bits.append(data.size, if (version < 10) 8 else 16)
for (b in data) bits.append(b.toInt() and 0xFF, 8)
val capacityBits = spec.dataCodewords * 8
// Terminator: up to four zero bits, fewer if there is no room.
bits.append(0, minOf(4, capacityBits - bits.size))
while (bits.size % 8 != 0) bits.append(0, 1)
// Pad alternately with the two bytes the standard names, until full.
var pad = 0xEC
while (bits.size < capacityBits) {
bits.append(pad, 8)
pad = if (pad == 0xEC) 0x11 else 0xEC
}
return bits.toCodewords()
}
/**
* Split into blocks, compute each block's error correction, and interleave.
*
* ⚠️ The interleave is what makes a QR survive a smudge: consecutive
* codewords of one block end up far apart on the symbol, so damage that
* would destroy one block is spread across all of them.
*/
private fun interleave(dataCodewords: IntArray, spec: Spec): IntArray {
val blocks = mutableListOf<IntArray>()
val ecBlocks = mutableListOf<IntArray>()
var offset = 0
fun take(count: Int, size: Int) {
repeat(count) {
val block = dataCodewords.copyOfRange(offset, offset + size)
offset += size
blocks += block
ecBlocks += ReedSolomon.remainder(block, spec.ecPerBlock)
}
}
take(spec.group1Blocks, spec.group1Data)
take(spec.group2Blocks, spec.group2Data)
val out = mutableListOf<Int>()
val longest = blocks.maxOf { it.size }
for (i in 0 until longest) {
for (block in blocks) if (i < block.size) out += block[i]
}
for (i in 0 until spec.ecPerBlock) {
for (block in ecBlocks) out += block[i]
}
return out.toIntArray()
}
private fun draw(version: Int, codewords: IntArray): Matrix {
val size = 17 + version * 4
val m = Matrix(size)
fun finder(row: Int, col: Int) {
for (r in -1..7) for (c in -1..7) {
if (!m.inBounds(row + r, col + c)) continue
val onRing = r in 0..6 && c in 0..6 &&
(r == 0 || r == 6 || c == 0 || c == 6 || (r in 2..4 && c in 2..4))
m.set(row + r, col + c, onRing, reserve = true)
}
}
finder(0, 0); finder(0, size - 7); finder(size - 7, 0)
// Timing patterns, which give a scanner the module pitch.
for (i in 8 until size - 8) {
m.set(6, i, i % 2 == 0, reserve = true)
m.set(i, 6, i % 2 == 0, reserve = true)
}
val centres = ALIGNMENT.getValue(version)
for (r in centres) for (c in centres) {
// ⚠️ Not over a finder. The three corners already carry one.
if ((r <= 8 && c <= 8) || (r <= 8 && c >= size - 9) || (r >= size - 9 && c <= 8)) continue
for (dr in -2..2) for (dc in -2..2) {
val onRing = dr == -2 || dr == 2 || dc == -2 || dc == 2 || (dr == 0 && dc == 0)
m.set(r + dr, c + dc, onRing, reserve = true)
}
}
// The dark module. Always set, always here.
m.set(size - 8, 8, true, reserve = true)
// Reserve the format areas before data placement, or data lands in them.
for (i in 0..8) {
if (!m.isReserved(8, i)) m.set(8, i, false, reserve = true)
if (!m.isReserved(i, 8)) m.set(i, 8, false, reserve = true)
}
for (i in 0..7) {
if (!m.isReserved(8, size - 1 - i)) m.set(8, size - 1 - i, false, reserve = true)
if (!m.isReserved(size - 1 - i, 8)) m.set(size - 1 - i, 8, false, reserve = true)
}
if (version >= 7) {
for (i in 0..5) for (j in 0..2) {
m.set(size - 11 + j, i, false, reserve = true)
m.set(i, size - 11 + j, false, reserve = true)
}
}
placeData(m, codewords)
val mask = chooseMask(m, size)
applyMask(m, size, mask)
writeFormat(m, size, mask)
if (version >= 7) writeVersion(m, size, version)
return m
}
/**
* The zigzag: two columns at a time, right to left, alternating direction,
* skipping the vertical timing column.
*/
private fun placeData(m: Matrix, codewords: IntArray) {
var bit = 0
val total = codewords.size * 8
var col = m.size - 1
var upward = true
while (col > 0) {
if (col == 6) col-- // the timing column is not data
val rows = if (upward) (m.size - 1) downTo 0 else 0 until m.size
for (row in rows) {
for (c in intArrayOf(col, col - 1)) {
if (m.isReserved(row, c)) continue
val value = if (bit < total) {
(codewords[bit / 8] shr (7 - bit % 8)) and 1 == 1
} else false
m.set(row, c, value)
bit++
}
}
upward = !upward
col -= 2
}
}
private fun maskAt(mask: Int, row: Int, col: Int): Boolean = when (mask) {
0 -> (row + col) % 2 == 0
1 -> row % 2 == 0
2 -> col % 3 == 0
3 -> (row + col) % 3 == 0
4 -> (row / 2 + col / 3) % 2 == 0
5 -> (row * col) % 2 + (row * col) % 3 == 0
6 -> ((row * col) % 2 + (row * col) % 3) % 2 == 0
else -> ((row + col) % 2 + (row * col) % 3) % 2 == 0
}
private fun applyMask(m: Matrix, size: Int, mask: Int) {
for (row in 0 until size) for (col in 0 until size) {
if (m.isReserved(row, col)) continue
if (maskAt(mask, row, col)) m.set(row, col, !m[row, col])
}
}
/**
* Try all eight masks and keep the least penalised, as the standard
* requires. A mask is not cosmetic: the wrong one leaves runs that a
* scanner mistakes for finder patterns.
*/
private fun chooseMask(m: Matrix, size: Int): Int {
var best = 0
var bestPenalty = Int.MAX_VALUE
for (mask in 0..7) {
applyMask(m, size, mask)
val penalty = penalty(m, size)
applyMask(m, size, mask) // its own inverse
if (penalty < bestPenalty) { bestPenalty = penalty; best = mask }
}
return best
}
private fun penalty(m: Matrix, size: Int): Int {
var score = 0
// Rule 1: runs of five or more of one colour.
for (i in 0 until size) {
for (transposed in listOf(false, true)) {
var run = 1
for (j in 1 until size) {
val a = if (transposed) m[j, i] else m[i, j]
val b = if (transposed) m[j - 1, i] else m[i, j - 1]
if (a == b) run++ else { if (run >= 5) score += run - 2; run = 1 }
}
if (run >= 5) score += run - 2
}
}
// Rule 2: solid 2x2 blocks.
for (r in 0 until size - 1) for (c in 0 until size - 1) {
val v = m[r, c]
if (v == m[r, c + 1] && v == m[r + 1, c] && v == m[r + 1, c + 1]) score += 3
}
// Rule 3: the finder-like 1:1:3:1:1 sequence with four light modules.
val a = booleanArrayOf(true, false, true, true, true, false, true, false, false, false, false)
val b = a.reversedArray()
for (r in 0 until size) for (c in 0 until size) {
for (pattern in listOf(a, b)) {
if (c + pattern.size <= size &&
(pattern.indices).all { m[r, c + it] == pattern[it] }) score += 40
if (r + pattern.size <= size &&
(pattern.indices).all { m[r + it, c] == pattern[it] }) score += 40
}
}
// Rule 4: imbalance between dark and light.
var dark = 0
for (r in 0 until size) for (c in 0 until size) if (m[r, c]) dark++
val percent = dark * 100 / (size * size)
score += 10 * (kotlin.math.abs(percent - 50) / 5)
return score
}
/** BCH(15,5) with the standard generator, then the fixed XOR mask. */
private fun writeFormat(m: Matrix, size: Int, mask: Int) {
val data = (EC_LEVEL_BITS shl 3) or mask
var rem = data shl 10
repeat(10) {
if (rem shr (14 - it) and 1 == 1) rem = rem xor (0b10100110111 shl (4 - it))
}
val bits = ((data shl 10) or (rem and 0x3FF)) xor 0b101010000010010
// 🔴 **Most significant bit FIRST, and this is the whole ballgame.**
// Written the other way round the symbol is structurally perfect — the
// finders, the timing, the alignment, the data and the mask were all
// verified correct against a reference symbol — and NOTHING can read
// it, because the fifteen bits that tell a scanner the level and the
// mask are backwards. It fails silently and identically at every
// version, which reads as "our encoder is broken" with no clue where.
// Established by reading Apple's generator at all four levels and
// matching the standard's L=01 M=00 Q=11 H=10 (only this order does).
fun place(index: Int, row: Int, col: Int) =
m.set(row, col, (bits shr (14 - index)) and 1 == 1, reserve = true)
// First copy: around the top-left finder.
for (i in 0..5) place(i, 8, i)
place(6, 8, 7)
place(7, 8, 8)
place(8, 7, 8)
for (i in 9..14) place(i, 14 - i, 8)
// Second copy, so damage to one corner does not cost the format.
for (i in 0..7) place(i, size - 1 - i, 8)
for (i in 8..14) place(i, 8, size - 15 + i)
}
/** BCH(18,6), for versions 7 and up. */
private fun writeVersion(m: Matrix, size: Int, version: Int) {
var rem = version shl 12
repeat(12) {
if (rem shr (17 - it) and 1 == 1) rem = rem xor (0b1111100100101 shl (5 - it))
}
val bits = (version shl 12) or (rem and 0xFFF)
for (i in 0..17) {
val on = bits shr i and 1 == 1
m.set(i / 3, size - 11 + i % 3, on, reserve = true)
m.set(size - 11 + i % 3, i / 3, on, reserve = true)
}
}
/** Accumulates bits most-significant first. */
private class BitBuffer {
private val bits = mutableListOf<Int>()
val size: Int get() = bits.size
fun append(value: Int, count: Int) {
for (i in count - 1 downTo 0) bits += (value shr i) and 1
}
fun toCodewords(): IntArray {
val out = IntArray(bits.size / 8)
for (i in bits.indices) {
if (bits[i] == 1) out[i / 8] = out[i / 8] or (1 shl (7 - i % 8))
}
return out
}
}
}
/**
* Reed-Solomon over GF(256) with the QR primitive polynomial, 0x11D.
*
* Only the encoder half: the remainder of the message polynomial divided by the
* generator, which is what the error-correction codewords are. Decoding is the
* scanner's problem.
*/
internal object ReedSolomon {
private val EXP = IntArray(512)
private val LOG = IntArray(256)
init {
var x = 1
for (i in 0 until 255) {
EXP[i] = x
LOG[x] = i
x = x shl 1
if (x >= 256) x = x xor 0x11D
}
for (i in 255 until 512) EXP[i] = EXP[i - 255]
}
private fun mul(a: Int, b: Int): Int =
if (a == 0 || b == 0) 0 else EXP[LOG[a] + LOG[b]]
/** The generator polynomial for `degree` error-correction codewords. */
private fun generator(degree: Int): IntArray {
var poly = intArrayOf(1)
for (i in 0 until degree) {
val next = IntArray(poly.size + 1)
for (j in poly.indices) {
next[j] = next[j] xor poly[j]
next[j + 1] = next[j + 1] xor mul(poly[j], EXP[i])
}
poly = next
}
return poly
}
fun remainder(data: IntArray, ecCount: Int): IntArray {
val gen = generator(ecCount)
val result = IntArray(data.size + ecCount)
data.copyInto(result)
for (i in data.indices) {
val factor = result[i]
if (factor == 0) continue
for (j in gen.indices) {
result[i + j] = result[i + j] xor mul(gen[j], factor)
}
}
return result.copyOfRange(data.size, result.size)
}
}
@@ -133,6 +133,21 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
val username: String,
val appPassword: String,
val userId: Long,
/**
* This account's global address.
*
* 🔴 **Stored because it can only be DERIVED at sign-in.** It comes
* from the passphrase, which exists for exactly the length of one
* sign-in and is never kept — so a restored session had no way to
* recompute it, and the Account screen correctly reported that this
* device had no address every time the app was relaunched. Re-deriving
* is not an option: it is 650,000 PBKDF2 iterations and the input is
* gone.
*
* ⚠️ Public by nature — it is what people are given — so this adds
* nothing to what the vault already protects.
*/
val fnid: String? = null,
)
fun loadCredential(): Credential? = readJson(CREDENTIAL)?.let {
@@ -141,6 +156,9 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
username = it.getString("username"),
appPassword = it.getString("appPassword"),
userId = it.getLong("userId"),
// ⚠️ Optional read: a credential written before addresses existed
// is still a valid credential, and must not fail to load.
fnid = it.optString("fnid").ifEmpty { null },
)
}
@@ -148,7 +166,8 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
.put("relay", credential.relayBaseUrl)
.put("username", credential.username)
.put("appPassword", credential.appPassword)
.put("userId", credential.userId))
.put("userId", credential.userId)
.put("fnid", credential.fnid ?: ""))
// -- Sessions ------------------------------------------------------------
@@ -0,0 +1,47 @@
package io.flamenet.e2e
import kotlin.test.Test
import kotlin.test.assertEquals
/**
* The salt for an account on a relay that keeps its own accounts.
*
* 🔴 **Pinned against the JavaScript engine, which is normative** — the value
* below was produced by `engine/src/fnid.js`'s `localAccountSalt`, not by this
* implementation. A Kotlin test that asserts what Kotlin computes proves only
* that Kotlin is self-consistent; the failure this guards against is the three
* engines disagreeing, which surfaces as one person having two addresses
* depending on which phone they signed in from.
*
* ⚠️ Changing the expected value here is never the fix. It is a wire constant:
* a different salt is a different account key, and a different account key is
* a different person to everyone who pinned the old one.
*/
class LocalAccountSaltTest {
@Test
fun `matches the javascript engine`() {
assertEquals(
"331bfb32ce724a978dda86f671ac324a",
AccountKdf.localAccountSalt("fnreviewalpha").toHex(),
)
}
/** Normalisation is part of the contract, not a convenience. */
@Test
fun `case and surrounding space do not change the salt`() {
val plain = AccountKdf.localAccountSalt("fnreviewalpha").toHex()
assertEquals(plain, AccountKdf.localAccountSalt("FNReviewAlpha").toHex())
assertEquals(plain, AccountKdf.localAccountSalt(" fnreviewalpha ").toHex())
}
@Test
fun `it is the length the KDF params ask for`() {
assertEquals(
AccountKdf.Params.PBKDF2_V1.saltBytes,
AccountKdf.localAccountSalt("someone").size,
)
}
private fun ByteArray.toHex() = joinToString("") { "%02x".format(it) }
}
@@ -0,0 +1,75 @@
package io.flamenet.e2e
import java.awt.image.BufferedImage
import java.io.File
import javax.imageio.ImageIO
import kotlin.test.Test
/**
* Renders QR codes to PNG so an INDEPENDENT decoder can read them back.
*
* 🔴 **The only honest way to test an encoder we wrote ourselves.** A wrong QR
* still looks like a QR; asserting the matrix against values this same code
* produced proves nothing except that it is deterministic. So this writes real
* images and `Scripts/verify-qr.swift` decodes them with CoreImage's detector,
* which shares no line of code with `QrCode`.
*
* ⚠️ Not an assertion by itself — it is the fixture half. It passes as long as
* the encoder does not throw; the verdict comes from the decoder.
*/
class QrCodeRenderForDecodingTest {
@Test
fun `render the corpus`() {
val out = File(System.getProperty("fnmsg.qr.out") ?: "build/qr-corpus")
out.mkdirs()
CORPUS.forEachIndexed { index, text ->
ImageIO.write(render(text, scale = 8, quiet = 4), "png", File(out, "qr-$index.png"))
File(out, "qr-$index.txt").writeText(text)
}
println("wrote ${CORPUS.size} QR images to ${out.absolutePath}")
}
private fun render(text: String, scale: Int, quiet: Int): BufferedImage {
val m = QrCode.encode(text)
val side = (m.size + quiet * 2) * scale
val image = BufferedImage(side, side, BufferedImage.TYPE_INT_RGB)
val g = image.createGraphics()
g.color = java.awt.Color.WHITE
g.fillRect(0, 0, side, side)
g.color = java.awt.Color.BLACK
for (r in 0 until m.size) for (c in 0 until m.size) {
if (m[r, c]) g.fillRect((c + quiet) * scale, (r + quiet) * scale, scale, scale)
}
g.dispose()
return image
}
companion object {
/**
* Chosen to cross every version boundary the encoder supports, because
* the block layout, the alignment patterns and the version-information
* block all change with version and each is its own chance to be wrong.
*/
val CORPUS: List<String> = listOf(
"flamenet:fnid:kx7q2m", // v1
"flamenet:fnid:" + "a".repeat(52) + "@relay1.flamenet.io", // a real address
"flamenet:fnid:" + "b".repeat(52) + "@" + "long".repeat(8) + ".example.com",
// ⚠️ Exactly full, version by version: data codewords minus the
// mode nibble and character count. The boundary is where an
// off-by-one in the terminator or the padding shows up, and it is
// the one place a wrong encoder still produces a scannable-looking
// square.
"x".repeat(14), // v1
"x".repeat(26), // v2
"x".repeat(42), // v3
"x".repeat(62), // v4
"x".repeat(84), // v5
"x".repeat(106), // v6
"x".repeat(122), // v7 — first version with a version-information block
"x".repeat(152), // v8 — two block groups of different sizes
"x".repeat(180), // v9
"x".repeat(213), // v10 — the largest this encoder makes
)
}
}