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.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
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.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.ColumnScope
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
@@ -498,125 +502,178 @@ fun ColumnScope.AccountScreen(
|
|||||||
Text("Device ${phase.deviceId}", style = MaterialTheme.typography.bodySmall,
|
Text("Device ${phase.deviceId}", style = MaterialTheme.typography.bodySmall,
|
||||||
modifier = Modifier.testTag("device-id"))
|
modifier = Modifier.testTag("device-id"))
|
||||||
|
|
||||||
Divider()
|
Spacer(Modifier.height(Space.lg))
|
||||||
|
|
||||||
OutlinedTextField(
|
// 🔴 **Cards, like every other screen in this app.** This one was a flat
|
||||||
value = displayName,
|
// stack of full-width fields, buttons and paragraph-length explanations
|
||||||
onValueChange = { displayName = it },
|
// with `Divider()` between them — no grouping, so "change your password"
|
||||||
label = { Text("Display name") },
|
// and "delete your account" carried exactly the same visual weight, and a
|
||||||
singleLine = true,
|
// security setting sat in the same undifferentiated column as a text box.
|
||||||
modifier = Modifier.fillMaxWidth().testTag("display-name"),
|
// The Contacts screen was given this treatment and Account was missed.
|
||||||
)
|
SettingsCard {
|
||||||
Button(
|
AddressCard(fnid = phase.fnid, relay = phase.relay)
|
||||||
onClick = { model.updateDisplayName(displayName.trim()); displayName = "" },
|
}
|
||||||
enabled = displayName.isNotBlank() && !phase.busy,
|
|
||||||
modifier = Modifier.fillMaxWidth().testTag("save-display-name"),
|
|
||||||
) { Text("Save display name") }
|
|
||||||
|
|
||||||
OutlinedTextField(
|
Spacer(Modifier.height(Space.md))
|
||||||
value = password,
|
SettingsCard {
|
||||||
onValueChange = { password = it },
|
Text("Profile", style = MaterialTheme.typography.titleMedium)
|
||||||
label = { Text("New password") },
|
Spacer(Modifier.height(Space.sm))
|
||||||
singleLine = true,
|
OutlinedTextField(
|
||||||
visualTransformation = PasswordVisualTransformation(),
|
value = displayName,
|
||||||
modifier = Modifier.fillMaxWidth().testTag("new-password"),
|
onValueChange = { displayName = it },
|
||||||
)
|
label = { Text("Display name") },
|
||||||
Button(
|
singleLine = true,
|
||||||
onClick = { model.updatePassword(password); password = "" },
|
modifier = Modifier.fillMaxWidth().testTag("display-name"),
|
||||||
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.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(
|
Text(
|
||||||
"Two devices compared what this server told each of them about its key log, and the "
|
"Changing it signs this device out — that is what changing it means.",
|
||||||
+ "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,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Row(
|
// -- Security (§19) ------------------------------------------------------
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
Spacer(Modifier.height(Space.md))
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
SettingsCard {
|
||||||
) {
|
Text("Security", style = MaterialTheme.typography.titleMedium)
|
||||||
Text("Require published keys", modifier = Modifier.weight(1f))
|
Spacer(Modifier.height(Space.sm))
|
||||||
Switch(
|
|
||||||
checked = phase.requireKeyLog,
|
if (phase.forkDetected) {
|
||||||
onCheckedChange = model::setRequireKeyLog,
|
// Above the toggle, not below: this is a statement about the server
|
||||||
modifier = Modifier.testTag("require-key-log"),
|
// 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(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
) {
|
) {
|
||||||
Text("Sealed sender", modifier = Modifier.weight(1f))
|
Column(Modifier.padding(Space.lg), content = content)
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
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
|
@Composable
|
||||||
|
|||||||
@@ -8,7 +8,10 @@ import io.flamenet.client.RelayClient
|
|||||||
import io.flamenet.client.RelayAccounts
|
import io.flamenet.client.RelayAccounts
|
||||||
import io.flamenet.client.MessengerApi
|
import io.flamenet.client.MessengerApi
|
||||||
import io.flamenet.client.RelayException
|
import io.flamenet.client.RelayException
|
||||||
|
import io.flamenet.e2e.AccountKdf
|
||||||
import io.flamenet.e2e.DeviceIdentity
|
import io.flamenet.e2e.DeviceIdentity
|
||||||
|
import io.flamenet.e2e.Ed25519
|
||||||
|
import io.flamenet.e2e.FNID
|
||||||
import io.flamenet.e2e.Vault
|
import io.flamenet.e2e.Vault
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import io.flamenet.client.IdentityTrust
|
import io.flamenet.client.IdentityTrust
|
||||||
@@ -50,6 +53,19 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
val deviceId: String,
|
val deviceId: String,
|
||||||
val relay: String,
|
val relay: String,
|
||||||
val status: 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(),
|
val buddies: List<MessengerApi.Buddy> = emptyList(),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -245,6 +261,11 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
userId = credential.userId,
|
userId = credential.userId,
|
||||||
deviceId = stored.deviceId,
|
deviceId = stored.deviceId,
|
||||||
relay = SiteConfig(credential.relayBaseUrl).host,
|
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.",
|
status = "Signed in on this device.",
|
||||||
buddies = fetchedBuddies.getOrDefault(emptyList()),
|
buddies = fetchedBuddies.getOrDefault(emptyList()),
|
||||||
buddiesLoaded = fetchedBuddies.isSuccess,
|
buddiesLoaded = fetchedBuddies.isSuccess,
|
||||||
@@ -302,6 +323,10 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
email: String,
|
email: String,
|
||||||
): Phase {
|
): Phase {
|
||||||
val relayAccounts = RelayAccounts(config.baseUrl)
|
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) {
|
val account = when (config.accountProvider) {
|
||||||
SiteConfig.AccountProvider.SHARED -> {
|
SiteConfig.AccountProvider.SHARED -> {
|
||||||
// The passphrase is stretched on this device and only the
|
// 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.
|
// learns a product-scoped pseudonym and not the handle.
|
||||||
val accounts = AccountsClient(config.accountServiceUrl)
|
val accounts = AccountsClient(config.accountServiceUrl)
|
||||||
if (register) accounts.register(handle, passphrase)
|
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,
|
// A relay-local account authenticates against the relay itself,
|
||||||
// which is what "this server keeps its own accounts" means. The
|
// 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
|
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 vault = vault ?: error("the vault is not open")
|
||||||
val device = identity ?: DeviceIdentity.create().also {
|
val device = identity ?: DeviceIdentity.create().also {
|
||||||
vault.saveIdentity(it)
|
vault.saveIdentity(it)
|
||||||
@@ -344,6 +393,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
username = account.username,
|
username = account.username,
|
||||||
appPassword = credential,
|
appPassword = credential,
|
||||||
userId = account.userId,
|
userId = account.userId,
|
||||||
|
fnid = fnid,
|
||||||
))
|
))
|
||||||
|
|
||||||
session = Session(
|
session = Session(
|
||||||
@@ -362,6 +412,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
userId = account.userId,
|
userId = account.userId,
|
||||||
deviceId = device.deviceId,
|
deviceId = device.deviceId,
|
||||||
relay = config.host,
|
relay = config.host,
|
||||||
|
fnid = fnid,
|
||||||
status = "Registered with ${preKeys.size} one-time prekeys published.",
|
status = "Registered with ${preKeys.size} one-time prekeys published.",
|
||||||
buddies = fetchedBuddies.getOrDefault(emptyList()),
|
buddies = fetchedBuddies.getOrDefault(emptyList()),
|
||||||
buddiesLoaded = fetchedBuddies.isSuccess,
|
buddiesLoaded = fetchedBuddies.isSuccess,
|
||||||
|
|||||||
@@ -51,7 +51,18 @@ class AccountsClient(
|
|||||||
*/
|
*/
|
||||||
data class Params(val salt: ByteArray, val kdfId: Int, val iterations: Int)
|
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) {
|
sealed class Failure(message: String) : Exception(message) {
|
||||||
/** Wrong handle and wrong passphrase are the same answer, on purpose. */
|
/** Wrong handle and wrong passphrase are the same answer, on purpose. */
|
||||||
@@ -97,7 +108,10 @@ class AccountsClient(
|
|||||||
iterations = params.iterations,
|
iterations = params.iterations,
|
||||||
saltBytes = params.salt.size,
|
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()
|
val json = post("sessions", JSONObject()
|
||||||
.put("handle", handle)
|
.put("handle", handle)
|
||||||
@@ -107,6 +121,7 @@ class AccountsClient(
|
|||||||
token = json.getString("token"),
|
token = json.getString("token"),
|
||||||
pseudonym = json.optString("pseudonym", ""),
|
pseudonym = json.optString("pseudonym", ""),
|
||||||
expiresAt = json.optString("expiresAt", ""),
|
expiresAt = json.optString("expiresAt", ""),
|
||||||
|
accountKeySeed = accountKeySeed,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -164,4 +164,50 @@ object AccountKdf {
|
|||||||
*/
|
*/
|
||||||
fun accountKeySeed(passphrase: String, salt: ByteArray, params: Params = Params.PBKDF2_V1): ByteArray =
|
fun accountKeySeed(passphrase: String, salt: ByteArray, params: Params = Params.PBKDF2_V1): ByteArray =
|
||||||
derive(passphrase, salt, Context.ACCOUNT_IDENTITY, 32, params)
|
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.
|
* Implements `engine/src/fnid.js`, which is normative.
|
||||||
*
|
*
|
||||||
* ⚠️ This engine can *encode* an address but cannot yet *derive* one: the
|
* Deriving one takes two steps that live elsewhere, because neither belongs
|
||||||
* account key comes from PBKDF2 over the passphrase under
|
* to an encoder: `AccountKdf.accountKeySeed` stretches the passphrase under
|
||||||
* `flamenet/account/identity/v1`, and this module has no KDF at all — the JS
|
* `flamenet/account/identity/v1`, and `Ed25519.fromSeed` turns those 32 bytes
|
||||||
* and Swift engines each have one and Kotlin does not. Until that exists, an
|
* into the public key this encodes. `AccountKdf.localAccountSalt` supplies the
|
||||||
* Android client can verify and display an address it is given and cannot
|
* salt for a relay that keeps its own accounts and therefore stores none.
|
||||||
* produce its own.
|
|
||||||
*/
|
*/
|
||||||
object FNID {
|
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 username: String,
|
||||||
val appPassword: String,
|
val appPassword: String,
|
||||||
val userId: Long,
|
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 {
|
fun loadCredential(): Credential? = readJson(CREDENTIAL)?.let {
|
||||||
@@ -141,6 +156,9 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
|
|||||||
username = it.getString("username"),
|
username = it.getString("username"),
|
||||||
appPassword = it.getString("appPassword"),
|
appPassword = it.getString("appPassword"),
|
||||||
userId = it.getLong("userId"),
|
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("relay", credential.relayBaseUrl)
|
||||||
.put("username", credential.username)
|
.put("username", credential.username)
|
||||||
.put("appPassword", credential.appPassword)
|
.put("appPassword", credential.appPassword)
|
||||||
.put("userId", credential.userId))
|
.put("userId", credential.userId)
|
||||||
|
.put("fnid", credential.fnid ?: ""))
|
||||||
|
|
||||||
// -- Sessions ------------------------------------------------------------
|
// -- 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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user