diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/AddressCard.kt b/app/src/main/kotlin/io/flamenet/messenger/app/AddressCard.kt new file mode 100644 index 0000000..4f19010 --- /dev/null +++ b/app/src/main/kotlin/io/flamenet/messenger/app/AddressCard.kt @@ -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) +} diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt index a882bb8..b86f84d 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt @@ -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 diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt index f45b1b1..b2b84b1 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt @@ -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 = 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, diff --git a/client/src/main/kotlin/io/flamenet/client/AccountsClient.kt b/client/src/main/kotlin/io/flamenet/client/AccountsClient.kt index 7af3234..8253949 100644 --- a/client/src/main/kotlin/io/flamenet/client/AccountsClient.kt +++ b/client/src/main/kotlin/io/flamenet/client/AccountsClient.kt @@ -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, ) } diff --git a/engine/src/main/kotlin/io/flamenet/e2e/AccountKdf.kt b/engine/src/main/kotlin/io/flamenet/e2e/AccountKdf.kt index c294c8b..dc5b87b 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/AccountKdf.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/AccountKdf.kt @@ -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 { + 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, + ) } diff --git a/engine/src/main/kotlin/io/flamenet/e2e/FNID.kt b/engine/src/main/kotlin/io/flamenet/e2e/FNID.kt index 7580a59..d6abbcd 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/FNID.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/FNID.kt @@ -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 { diff --git a/engine/src/main/kotlin/io/flamenet/e2e/QrCode.kt b/engine/src/main/kotlin/io/flamenet/e2e/QrCode.kt new file mode 100644 index 0000000..5c73f17 --- /dev/null +++ b/engine/src/main/kotlin/io/flamenet/e2e/QrCode.kt @@ -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>@` โ€” 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() + val ecBlocks = mutableListOf() + 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() + 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() + 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) + } +} diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt index 75525bb..2f7bab0 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt @@ -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 ------------------------------------------------------------ diff --git a/engine/src/test/kotlin/io/flamenet/e2e/LocalAccountSaltTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/LocalAccountSaltTest.kt new file mode 100644 index 0000000..19be48c --- /dev/null +++ b/engine/src/test/kotlin/io/flamenet/e2e/LocalAccountSaltTest.kt @@ -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) } +} diff --git a/engine/src/test/kotlin/io/flamenet/e2e/QrCodeRenderForDecodingTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/QrCodeRenderForDecodingTest.kt new file mode 100644 index 0000000..1abb2c6 --- /dev/null +++ b/engine/src/test/kotlin/io/flamenet/e2e/QrCodeRenderForDecodingTest.kt @@ -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 = 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 + ) + } +}