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:
co-authored by
Claude Opus 5
parent
28d7485c26
commit
e46d51430a
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user