diff --git a/PARITY.md b/PARITY.md index 970fdfa..684db2d 100644 --- a/PARITY.md +++ b/PARITY.md @@ -73,8 +73,8 @@ From `Sources/Auth/*` and `Sources/Views/Sign*`. | Change password | ✅ signs the device out, because that is what it does | | Delete account | ✅ confirms in place, erases the phone too | | Relay picker / `SiteConfig` equivalent | ✅ `SiteConfig.kt`, a `relay` field, and an `identity-mode` label | -| Deterministic local-relay KDF salt | ⚪ | -| Multi-homing (publish + read home records) | ⚪ | +| Deterministic local-relay KDF salt | ✅ `AccountKdf.localAccountSalt`, pinned to the published vector, and wired into sign-in for a LOCAL relay | +| Multi-homing (publish + read home records) | ✅ publish, read, verify and the serial floor — see below | ⚠️ Sign-up is where the three deployment modes become visible to a user, and each derives its salt differently. Getting this wrong does not error — it @@ -94,15 +94,15 @@ From `E2E/E2EInbox.swift`, which is the real feature list. | History at rest | ✅ encrypted, keyed by person not device | | Session restore across restarts | ✅ credential and device both survive | | Reactions | ✅ transcript carries a `mid`; long-press gives a five-emoji row, one emoji per person | -| Edit a sent message (§6.15) | 🟡 inbound edits now APPLY to the message they name; offering the control is UI work | +| Edit a sent message (§6.15) | ✅ inbound edits apply to the message they name; **Edit** in the long-press menu, own messages only, bubble marked `edited` | | Drafts that survive a relaunch | ✅ sealed in the vault, not SharedPreferences | -| Forwarding (§6.16) | 🟡 `fwd` now reaches the transcript so second-hand words are not drawn as the sender's own; originating needs the UI | +| Forwarding (§6.16) | ✅ `fwd` reaches the transcript and the bubble; **Forward** offers people and groups, and carries no trail back to the source | | Group message ids and quotes (§6.9) | ✅ transcript stores both; sent and queued copies share one id | | Mentions (§6.18) | ✅ codec, an `@` picker in the group composer, and ids recorded at pick time rather than re-derived from the text | | Per-message timers (§6.17) | ✅ combining rule and per-message pruning; composer control in progress | | Search inside messages | ✅ scanned, never indexed | | Search inside messages | ✅ scanned, never indexed — no plaintext index at rest | -| Pinned message (§6.19) | 🟡 inbound pins now stored per thread, sealed in the vault; the banner and the control are UI work | +| Pinned message (§6.19) | ✅ inbound pins stored per thread sealed in the vault; **Pin/Unpin** in the menu and a banner above the thread | | Photos | ✅ send via the system picker, render inline | | Voice notes (record + play) | ✅ AAC/MP4, tap to record, tap to send | | Stories, and seen-state | ✅ sent per-contact, expired bytes deleted | @@ -222,6 +222,36 @@ somebody is grinding attempts. The steps are clamped before the shift, and --- +## Multi-homing (§20.4) + +Both rows above were ⚪ and both were **stale**: `AccountKdf.localAccountSalt` is +pinned to the published vector and wired into sign-in for a local relay, and +`MailboxHoming` / `AccountIdentity.publishHomes` have shipped the publish and +read halves for a while. The document was describing a gap that had closed. + +🔴 **What was genuinely missing was on the READING side, on BOTH clients.** An +older home record is *genuinely signed* — the account holder published it, +before they added a second relay — so a relay that wants to be somebody's only +mailbox does not have to forge anything: it serves the record it saw first, that +record verifies perfectly, and the sender delivers to one relay believing it is +delivering to all of them. + +The relay refuses a lower serial on **write**. Nothing refused one on **read**: +`verifiedRelays` here and `verifyHomes` on iOS both checked the signature and +stopped. Fixed on both (Android this MR, iOS `ios!116`) by remembering the +highest serial seen per address and refusing anything below it. + +⚠️ The floor rises only for a record that verified — otherwise a hostile relay +could raise it with a forged `Int.MAX_VALUE` and lock out every later genuine +record, turning the rollback defence into a denial of service against the +account holder. + +⚠️ Neither client claims an attack when it refuses. A relay that has genuinely +fallen behind serves an old record for honest reasons, and nothing in a client +can tell that from the other case. + +--- + ## Inbound control envelopes 🔴 **The app never asked the engine whether an envelope belonged in a thread.** @@ -309,14 +339,38 @@ the messenger rather than staying separate apps. | Local vault: add, organise, delete | ⚪ | | Bell–LaPadula labels and clearance | ⚪ | | Share a file into a chat or group | ⚪ | +| Wallet engine: BIP-39, Base58, SLIP-0010 | 🟡 primitives built and pinned to the published vectors; no screen yet | | Wallet: BIP-39 seed, create and restore | ⚪ | | Wallet: fresh receive address per receive | ⚪ | | Wallet: balance and transactions from a node | ⚪ | -⚠️ Nothing here exists on Android yet. The iOS side is built and tested; the -engine work it rests on (BIP-39, SLIP-0010, Base58, the label encoding) is -Swift-only and has no Kotlin counterpart, so this is the largest remaining gap -and it is a build, not a port of a screen. +⚠️ Nothing above the engine exists on Android yet. This is the largest +remaining gap and it is a build, not a port of a screen. + +🔴 **The wallet primitives are now in Kotlin and pinned to the STANDARDS, not to +the Swift engine.** `Bip39`, `Base58` and `HdKey` are held to BIP-39's own +vectors, SLIP-0010's own ed25519 vectors and the published word-list SHA-256 — +the same things the Swift side is held to. Checking one implementation against +the other would only prove they agree, and two implementations agreeing on the +wrong answer is exactly how a wallet ends up empty: a phrase written down on an +iPhone has to restore on an Android handset, and on somebody else's software +years from now. + +Every failure in that file is silent in production. A wrong checksum bit, a lost +leading zero, a missed NFKD — none of them error. They derive a different, +perfectly valid, empty wallet. + +Two Kotlin-specific decisions, both money-relevant: + +- **PBKDF2 is hand-written over `Mac`**, not `SecretKeyFactory`. The JCA path + takes a `CharArray` and the provider decides the encoding — `AccountKdf` + already carries that caveat for the account KDF. For a wallet the consequence + is worse than a failed sign-in: a phrase containing anything outside ASCII + would stretch differently under a different provider and derive a different + empty wallet with no error anywhere. +- **Base58 is a digit loop, not `BigInteger`.** The obvious Kotlin version is + `BigInteger(1, bytes).toString(58)` remapped, and it silently drops leading + zero bytes — encoding two different keys to one address. --- 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 543469c..81cd221 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt @@ -1622,6 +1622,8 @@ fun ColumnScope.ChatScreen( avatars: Map = emptyMap(), /** Ours, so the mention list never offers you yourself. */ myUserId: Long = 0L, + /** Somewhere to forward to (§6.16); groups as well as people. */ + groups: List = emptyList(), ) { // ⚠️ Keyed on the thread, so switching conversations re-reads rather than // carrying one person's half-written message into somebody else's composer. @@ -1741,6 +1743,62 @@ fun ColumnScope.ChatScreen( // because a timer nobody can see is a timer nobody trusts. if (chat.peerId != null) ConversationStatusLine(model, chat) + // §6.15/§6.19 — which message a sheet is currently about. Hoisted above the + // list because a row's own `remember` dies when it scrolls out, and an edit + // box that vanished mid-typing because the message scrolled away would be + // the worst possible way to lose a correction. + var editing by remember(chat.threadId) { mutableStateOf(null) } + var forwarding by remember(chat.threadId) { mutableStateOf(null) } + + // 🔴 The pinned banner, above the messages: a pin nobody can see is a pin + // nobody uses. Resolved from the id against the lines on screen, so a + // pinned message that was since deleted shows nothing rather than a stale + // copy of words that are gone. + chat.pinnedMessageId?.let { pinnedId -> + chat.lines.firstOrNull { it.id == pinnedId }?.let { pinned -> + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(Radius.small)) + .background(Palette.Accent.copy(alpha = 0.10f)) + .padding(horizontal = 12.dp, vertical = 8.dp) + .testTag("pinned-banner"), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Space.xs), + ) { + Text("Pinned", fontSize = 11.sp, color = Palette.SecondaryText) + Text( + pinned.text.take(80), + fontSize = 13.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + } + } + } + + editing?.let { line -> + EditMessageSheet( + line = line, + onDismiss = { editing = null }, + onSave = { text -> model.editMessage(line, chat, text); editing = null }, + ) + } + + forwarding?.let { line -> + ForwardSheet( + line = line, + buddies = buddies, + groups = groups, + onDismiss = { forwarding = null }, + onPick = { peerId, group -> + model.forwardMessage(line.text, peerId, group) + forwarding = null + }, + ) + } + LazyColumn( state = listState, modifier = Modifier.fillMaxWidth().weight(1f).testTag("messages"), @@ -1822,6 +1880,32 @@ fun ColumnScope.ChatScreen( onClick = { clipboard.setText(AnnotatedString(line.text)); menuOpen = false }, ) } + // ⚠️ Every control below needs an id to point at, so all + // of them are absent — not disabled — on a message written + // before ids existed. A greyed-out row invites a tap and + // teaches nothing. + if (line.id != null && line.mine && line.text.isNotBlank()) { + DropdownMenuItem( + text = { Text("Edit") }, + onClick = { editing = line; menuOpen = false }, + modifier = Modifier.testTag("edit-message"), + ) + } + if (line.id != null) { + val isPinned = chat.pinnedMessageId == line.id + DropdownMenuItem( + text = { Text(if (isPinned) "Unpin" else "Pin") }, + onClick = { model.pinMessage(line, chat, !isPinned); menuOpen = false }, + modifier = Modifier.testTag("pin-message"), + ) + } + if (line.text.isNotBlank()) { + DropdownMenuItem( + text = { Text("Forward") }, + onClick = { forwarding = line; menuOpen = false }, + modifier = Modifier.testTag("forward-message"), + ) + } DropdownMenuItem( text = { Text(if (line.mine) "Unsend" else "Delete for me") }, onClick = { model.deleteMessage(line); menuOpen = false }, @@ -2165,6 +2249,23 @@ private fun Bubble( modifier = Modifier.padding(horizontal = Kit.bubblePadding, vertical = 2.dp), ) { Text(time, fontSize = 10.sp, color = Palette.SecondaryText) + // 🔴 **An edit says so (§6.15).** Silently replacing the words is + // worse than the typo: the reader has no way to tell a correction + // from their own misremembering, and no way to know the message + // they replied to is not the message that is there now. + if (line.editedAt != null) { + Text("edited", fontSize = 10.sp, color = Palette.SecondaryText, + modifier = Modifier.testTag("edited-marker")) + } + // ⚠️ **THAT it was forwarded, never from whom (§6.16).** Second-hand + // words drawn as the sender's own is the thing this prevents; + // naming the original author would be a different feature and a + // worse one, because it republishes somebody who was never in this + // conversation. + if (line.forwarded) { + Text("Forwarded", fontSize = 10.sp, color = Palette.SecondaryText, + modifier = Modifier.testTag("forwarded-marker")) + } // 🔴 **A message nobody received must not look like one that was // received.** That sameness is the whole of what makes a ghost // message possible; every specific cause behind one is downstream @@ -3733,3 +3834,119 @@ internal fun memberLabel( buddies: List, ): String = buddies.firstOrNull { it.userId == id } ?.let { it.displayName.ifBlank { it.handle } } ?: "user $id" + +/** + * Change the wording of a message already sent (§6.15). + * + * ⚠️ **Opens with the current text already in it.** An edit box that starts + * empty is a retype, and somebody correcting one word in a long message would + * rather not. The cursor sits at the end for the same reason. + * + * ⚠️ Saving nothing is refused rather than treated as a delete. Emptying the + * box is the shape of a slip, and "Unsend" is right there in the same menu for + * anybody who meant it. + */ +@Composable +private fun EditMessageSheet( + line: Session.Line, + onDismiss: () -> Unit, + onSave: (String) -> Unit, +) { + var text by remember(line.id) { mutableStateOf(line.text) } + androidx.compose.material3.AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Edit message") }, + text = { + Column { + androidx.compose.material3.OutlinedTextField( + value = text, + onValueChange = { text = it }, + modifier = Modifier.fillMaxWidth().testTag("edit-field"), + singleLine = false, + ) + Spacer(Modifier.height(Space.xs)) + // 🔴 Says what the other side will see. An edit is not a quiet + // repair: the recipient's bubble changes and is marked, and + // somebody about to correct a typo should know that before they + // do it rather than discover it afterwards. + Text( + "Everyone in this conversation sees the new wording, and their " + + "copy is marked as edited.", + fontSize = 12.sp, + color = Palette.SecondaryText, + ) + } + }, + confirmButton = { + androidx.compose.material3.TextButton( + onClick = { onSave(text) }, + enabled = text.isNotBlank() && text != line.text, + modifier = Modifier.testTag("edit-save"), + ) { Text("Save") } + }, + dismissButton = { + androidx.compose.material3.TextButton(onClick = onDismiss) { Text("Cancel") } + }, + ) +} + +/** + * Pass a message's words on to another conversation (§6.16). + * + * 🔴 **The list is people and groups, and the message travels without its + * author.** What is sent is the text with `fwd` set — no original thread, no + * name, no quote. A forward that named its source would republish a private + * message's author to somebody who was never in that conversation, which is + * the one thing §6.16 exists to prevent. + */ +@Composable +private fun ForwardSheet( + line: Session.Line, + buddies: List, + groups: List, + onDismiss: () -> Unit, + onPick: (Long?, io.flamenet.client.RelayClient.Group?) -> Unit, +) { + androidx.compose.material3.AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Forward to") }, + text = { + Column(modifier = Modifier.testTag("forward-list")) { + Text( + // ⚠️ States the cost plainly. "Forwarded" is all the + // recipient is told, and somebody passing on a message + // should know their own name is what travels with it. + "This sends the words on their own. Whoever receives it sees " + + "that it was forwarded, and sees it from you.", + fontSize = 12.sp, + color = Palette.SecondaryText, + ) + Spacer(Modifier.height(Space.sm)) + groups.forEach { group -> + Text( + group.name.ifBlank { "Group" }, + modifier = Modifier + .fillMaxWidth() + .clickable { onPick(null, group) } + .padding(vertical = 10.dp) + .testTag("forward-group-${group.id}"), + ) + } + buddies.forEach { buddy -> + Text( + buddy.displayName.ifBlank { buddy.handle }, + modifier = Modifier + .fillMaxWidth() + .clickable { onPick(buddy.userId, null) } + .padding(vertical = 10.dp) + .testTag("forward-to-${buddy.userId}"), + ) + } + } + }, + confirmButton = {}, + dismissButton = { + androidx.compose.material3.TextButton(onClick = onDismiss) { Text("Cancel") } + }, + ) +} diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt index 07188dc..b0e1488 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt @@ -233,7 +233,7 @@ private fun Root(model: MessengerViewModel = viewModel()) { val chat = current.openChat if (chat != null) { ChatScreen(model, chat, current.error, current.buddies, current.avatars, - myUserId = current.userId) + myUserId = current.userId, groups = current.groups) } else { // 🔴 **The content area is weighted, and the tab bar is // not.** These used to be plain siblings, so the tab bar 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 21c4535..85c5b4e 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt @@ -247,6 +247,15 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { * number that points at somebody else. */ val address: String? = null, + /** + * The message pinned in this conversation (§6.19), or null. + * + * ⚠️ An id, resolved against [lines] at draw time. Holding a copy + * of the message here would go stale the moment it was edited and + * would outlive it being deleted — a banner quoting words that are + * no longer in the thread. + */ + val pinnedMessageId: String? = null, /** * What we believe about the other side's identity keys (§19). * @@ -712,6 +721,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { disappearSeconds = current.conversations.disappearSeconds(peerId), replyReminderAt = current.replyReminder(peerId), scheduled = current.scheduledFor(peerId), + pinnedMessageId = current.pinnedMessage(thread), ), // Opening the thread is what marks it read. Nothing is sent to // the relay for this — read state is local, because a read @@ -750,7 +760,8 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { update { it.copy( openChat = Phase.Chat( - thread, card.address, current.history(thread), address = card.address), + thread, card.address, current.history(thread), address = card.address, + pinnedMessageId = current.pinnedMessage(thread)), unread = it.unread - thread, error = null, ) @@ -783,7 +794,23 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { // survive, and showing it unchecked would make this screen a place // that quietly confirms the attack. runCatching { - current.relay.homes(fnid)?.let(io.flamenet.client.AccountIdentity::verifiedRelays) + val record = current.relay.homes(fnid) ?: return@runCatching null + // 🔴 **And not older than one already seen.** An old record is + // genuinely signed — published before a second home was added — + // so a relay wanting to be somebody's only mailbox does not + // have to forge anything; it just serves the record it saw + // first. The signature check alone accepts that, which is what + // the serial exists to stop. + val floor = current.vault.homeSerial(fnid) + val relays = io.flamenet.client.AccountIdentity + .verifiedRelays(record, floor = floor) + // ⚠️ Advanced only after the record was ACCEPTED. Recording the + // serial of a record that failed verification would let a + // hostile relay raise the floor with a forgery and lock out + // every later genuine record. + io.flamenet.client.AccountIdentity.verifiedSerial(record) + ?.let { if (relays != null) current.vault.saveHomeSerial(fnid, it) } + relays }.getOrNull() } val readable = withContext(Dispatchers.IO) { @@ -857,7 +884,8 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { val thread = Vault.threadForGroup(group.id) update { it.copy( - openChat = Phase.Chat(thread, group.name, current.history(thread), group = group), + openChat = Phase.Chat(thread, group.name, current.history(thread), group = group, + pinnedMessageId = current.pinnedMessage(thread)), unread = it.unread - thread, ) } @@ -2035,6 +2063,74 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { * the tap registers immediately and a failed send puts back what was * there. All this has to do is re-read the thread afterwards. */ + /** + * Change the wording of a message already sent (§6.15). + * + * ⚠️ Refreshes the open chat from the transcript afterwards, like + * `reactToMessage` — the edit is applied to storage first and the screen + * reads from storage, so without this the correction lands and is invisible + * until the next poll. + */ + fun editMessage(line: Session.Line, chat: Phase.Chat, newText: String) = + viewModelScope.launch { + val current = session ?: return@launch + val mid = line.id ?: return@launch + withContext(Dispatchers.IO) { + runCatching { + current.editMessage(chat.threadId, mid, newText, + peerUserId = chat.peerId, group = chat.group) + } + } + update { + it.copy(openChat = it.openChat?.copy( + lines = current.history(chat.threadId), + pinnedMessageId = current.pinnedMessage(chat.threadId))) + } + } + + /** Pin a message, or take the pin down by pinning the empty id (§6.19). */ + fun pinMessage(line: Session.Line, chat: Phase.Chat, pin: Boolean) = + viewModelScope.launch { + val current = session ?: return@launch + val mid = line.id ?: return@launch + withContext(Dispatchers.IO) { + runCatching { + current.pinMessage(chat.threadId, if (pin) mid else "", + peerUserId = chat.peerId, group = chat.group) + } + } + update { + it.copy(openChat = it.openChat?.copy( + lines = current.history(chat.threadId), + pinnedMessageId = current.pinnedMessage(chat.threadId))) + } + } + + /** + * Pass a message's words on to another conversation (§6.16). + * + * 🔴 Takes the TEXT, not the line. A forward carries the words and nothing + * about where they came from, and a signature that accepted the whole line + * would make it easy to start carrying the author "just for context" — + * which is the one thing §6.16 exists to prevent. + */ + fun forwardMessage(text: String, toPeerUserId: Long?, toGroup: RelayClient.Group?) = + viewModelScope.launch { + val current = session ?: return@launch + withContext(Dispatchers.IO) { + runCatching { current.forwardMessage(text, toPeerUserId, toGroup) } + } + // ⚠️ The forward lands in ANOTHER thread, so the chat on screen is + // not the one that changed. Only the open chat is re-read, which is + // correct: reloading the destination would be reloading a screen + // nobody is looking at. + update { + it.copy(openChat = it.openChat?.let { chat -> + chat.copy(lines = current.history(chat.threadId)) + }) + } + } + fun reactToMessage(emoji: String, line: Session.Line, chat: Phase.Chat) = viewModelScope.launch { val current = session ?: return@launch diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt index 3309158..04b964f 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt @@ -738,18 +738,7 @@ class Session( if (!applyReaction(threadId, messageId, userId, emoji)) return false val reference = Content.Reference(messageId, author, "") val body = Content.reaction(emoji, reference, groupId = group?.id) - val sent = runCatching { - when { - // ⚠️ Pairwise, like every group message: the relay does no - // fan-out, so a group reaction is one send per member. - group != null -> group.memberIds - .filter { it != userId } - .map { conversations.send(it, body).reachedSomeone } - .any { it } - peerUserId != null -> conversations.send(peerUserId, body).reachedSomeone - else -> false - } - }.getOrDefault(false) + val sent = sendToThread(body, peerUserId, group) if (!sent) { applyReaction(threadId, messageId, userId, previous) return false @@ -757,6 +746,131 @@ class Session( return true } + /** + * Fan one control envelope out to a thread, exactly as `react` does. + * + * ⚠️ Pairwise for a group, because the relay does no fan-out — one send per + * member. Factored out of `react` rather than copied into three new + * callers: three copies of "which sends does this thread need" is three + * places for a group to be handled as a 1:1. + */ + private fun sendToThread( + body: String, + peerUserId: Long?, + group: RelayClient.Group?, + ): Boolean = runCatching { + when { + group != null -> group.memberIds + .filter { it != userId } + .map { conversations.send(it, body).reachedSomeone } + .any { it } + peerUserId != null -> conversations.send(peerUserId, body).reachedSomeone + else -> false + } + }.getOrDefault(false) + + /** + * Change the wording of a message already sent (§6.15). + * + * 🔴 **Only your own, and the check is here rather than only in the UI.** A + * menu that hides the control is a convenience; this is the rule. The + * receiving side enforces the same thing (`applyEdit` refuses an edit from + * anybody but the author), so neither end is trusting the other. + * + * ⚠️ **Applied locally first, then sent, and rolled back if the send + * fails** — the same shape as `react`. Waiting on a round trip to see your + * own correction appear makes it feel broken on a slow connection, and + * leaving a correction that exists only on this phone is worse: you would + * believe you had fixed it. + */ + fun editMessage( + threadId: String, + messageId: String, + newText: String, + peerUserId: Long?, + group: RelayClient.Group?, + ): Boolean { + val line = history(threadId).firstOrNull { it.id == messageId } ?: return false + if (!line.mine) return false + if (newText.isBlank()) return false + val previous = line.text + if (!applyEdit(threadId, messageId, userId, newText)) return false + val body = Content.edit(newText, of = messageId, groupId = group?.id) + if (!sendToThread(body, peerUserId, group)) { + // Put the old wording back. `applyEdit` stamps `edited`, so this + // leaves the flag behind — deliberately: the message DID change on + // this device and briefly showed the new words, and pretending + // otherwise would be a second lie on top of the failed send. + applyEdit(threadId, messageId, userId, previous) + return false + } + return true + } + + /** + * Pin a message in a thread, or take the pin down with an empty id (§6.19). + * + * ⚠️ **Anyone in the conversation may pin.** A pin is a shared banner, not + * a permission — restricting it to the author would mean the useful message + * in a group is exactly the one nobody but its writer can pin. + */ + fun pinMessage( + threadId: String, + messageId: String, + peerUserId: Long?, + group: RelayClient.Group?, + ): Boolean { + val previous = pinnedMessage(threadId) + applyPin(threadId, messageId) + val body = Content.pin(messageId, groupId = group?.id) + if (!sendToThread(body, peerUserId, group)) { + applyPin(threadId, previous.orEmpty()) + return false + } + return true + } + + /** + * Pass somebody's words on to another conversation (§6.16). + * + * 🔴 **The forward carries the words and NOT where they came from.** No + * author, no original thread, no quote — that is the whole point of §6.16, + * and it is why this sends a plain text envelope with `fwd` set rather than + * a reply that references the original. A forward that named its source + * would republish a private message's author to somebody who was never in + * that conversation. + * + * ⚠️ An attachment is deliberately not carried yet. Re-sending a blob means + * re-uploading it under a new token, and a forward that silently shared the + * ORIGINAL token would hand the new recipient a capability to a file in a + * conversation they are not part of. + */ + fun forwardMessage( + text: String, + toPeerUserId: Long?, + toGroup: RelayClient.Group?, + ): Boolean { + if (text.isBlank()) return false + val mid = java.util.UUID.randomUUID().toString() + // ⚠️ Two builders, because a group message carries its gid inside the + // envelope and a 1:1 one has no field for it. `Content.text` takes no + // groupId at all — passing one is a compile error rather than a silent + // drop, which is how this was caught. + val body = if (toGroup != null) { + Content.groupText(text, groupId = toGroup.id, messageId = mid, + forwarded = true) + } else { + Content.text(text, messageId = mid, forwarded = true) + } + if (!sendToThread(body, toPeerUserId, toGroup)) return false + val thread = toGroup?.let { Vault.threadForGroup(it.id) } + ?: toPeerUserId?.let { Vault.threadForUser(it) } + ?: return true + record(thread, text, mine = true, messageId = mid, forwarded = true, + delivery = Delivery.DELIVERED) + return true + } + private fun enqueue(peerUserId: Long, text: String, at: Long, thread: String? = null) { val pending = vault.outbox() diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/EditForwardPinWireTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/EditForwardPinWireTest.kt new file mode 100644 index 0000000..62ec02e --- /dev/null +++ b/app/src/test/kotlin/io/flamenet/messenger/app/EditForwardPinWireTest.kt @@ -0,0 +1,139 @@ +package io.flamenet.messenger.app + +import io.flamenet.e2e.Content +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * What the three new controls actually put on the wire (§6.15/§6.16/§6.19). + * + * 🔴 **The privacy rule for a forward is the one worth testing, because it is + * the one a "helpful" change would break.** §6.16 says a forward carries the + * words and nothing about where they came from. The obvious improvement — + * carrying a reference so the reader can see the original — would republish a + * private message's author to somebody who was never in that conversation. So + * the absence of that field is asserted, not just the presence of the flag. + * + * ⚠️ These check the ENVELOPE. The `Session` methods that build and apply them + * need a vault and a keystore, so their behaviour is covered on device; what is + * checkable here is the shape that crosses to the other app, which is where an + * interop bug would live. + */ +class EditForwardPinWireTest { + + private fun parse(json: String): Content = Content.parse(json)!! + + // ---- Forward (§6.16) ------------------------------------------------- + + /** + * 🔴 The whole point of the feature: forwarded, and no trail back. + */ + @Test + fun `a forward carries the words and nothing about their origin`() { + val body = Content.text("passed along", messageId = "mid-new", forwarded = true) + val sent = parse(body) + + assertEquals("passed along", sent.text) + assertTrue(sent.isForwarded, "the fwd flag was lost") + // 🔴 No reference to the original, in any form. + assertNull(sent.about, "a forward is carrying a reference to its source") + assertNull(sent.editOf) + assertNull(sent.pins) + assertTrue(sent.isRenderable, "a forward is a message and must be drawn") + } + + /** + * ⚠️ A forward gets a NEW id, not the original's. Reusing it would make the + * copy a target for edits and reactions aimed at the original — one of + * which would silently rewrite the other. + */ + @Test + fun `a forward is a new message, not the old one re-sent`() { + val first = parse(Content.text("passed along", messageId = "mid-a", forwarded = true)) + val second = parse(Content.text("passed along", messageId = "mid-b", forwarded = true)) + assertTrue(first.messageId != second.messageId) + } + + /** A forward into a group carries the gid, and still no origin. */ + @Test + fun `a group forward carries the group and still no origin`() { + val sent = parse( + Content.groupText("passed along", groupId = "gid-1", + messageId = "mid-new", forwarded = true), + ) + assertEquals("gid-1", sent.groupId) + assertTrue(sent.isForwarded) + assertNull(sent.about, "a group forward is carrying a reference to its source") + } + + // ---- Edit (§6.15) ---------------------------------------------------- + + @Test + fun `an edit names the message it replaces and carries the new wording`() { + val sent = parse(Content.edit("the corrected wording", of = "mid-1")) + assertEquals("mid-1", sent.editOf) + assertEquals("the corrected wording", sent.text) + // 🔴 Never drawn as a bubble of its own — the failure that shipped a + // duplicate beside the typo. + assertFalse(sent.isRenderable) + } + + @Test + fun `an edit in a group names the group`() { + val sent = parse(Content.edit("fixed", of = "mid-1", groupId = "gid-1")) + assertEquals("gid-1", sent.groupId) + assertEquals("mid-1", sent.editOf) + } + + // ---- Pin (§6.19) ----------------------------------------------------- + + /** + * 🔴 An id and never a copy. A copy would go stale the moment the message + * was edited and would outlive it being deleted — a banner quoting words + * that are no longer in the conversation. + */ + @Test + fun `a pin names an id and carries no copy of the message`() { + val sent = parse(Content.pin("mid-1")) + assertEquals("mid-1", sent.pins) + assertTrue(sent.text.isNullOrEmpty(), "a pin is carrying a copy of the message") + assertFalse(sent.isRenderable) + } + + /** Unpinning is the same envelope with an empty id, not a second kind. */ + @Test + fun `taking a pin down is the same envelope with an empty id`() { + val sent = parse(Content.pin("")) + assertEquals("", sent.pins) + assertFalse(sent.isRenderable) + } + + @Test + fun `a pin in a group names the group`() { + val sent = parse(Content.pin("mid-1", groupId = "gid-1")) + assertEquals("gid-1", sent.groupId) + assertEquals("mid-1", sent.pins) + } + + // ---- The three do not collide ---------------------------------------- + + /** + * ⚠️ Each envelope sets exactly one of the three fields. A message that was + * both an edit and a pin would be applied twice by a receiver that checks + * them in sequence, and the order of those checks is not part of the spec. + */ + @Test + fun `each control sets exactly one of the three fields`() { + val edit = parse(Content.edit("x", of = "mid-1")) + assertTrue(edit.editOf != null && edit.pins == null && !edit.isForwarded) + + val pin = parse(Content.pin("mid-1")) + assertTrue(pin.pins != null && pin.editOf == null && !pin.isForwarded) + + val forward = parse(Content.text("x", messageId = "mid-2", forwarded = true)) + assertTrue(forward.isForwarded && forward.editOf == null && forward.pins == null) + } +} diff --git a/client/src/main/kotlin/io/flamenet/client/AccountIdentity.kt b/client/src/main/kotlin/io/flamenet/client/AccountIdentity.kt index e87aef4..dc3c310 100644 --- a/client/src/main/kotlin/io/flamenet/client/AccountIdentity.kt +++ b/client/src/main/kotlin/io/flamenet/client/AccountIdentity.kt @@ -120,15 +120,54 @@ object AccountIdentity { return Ed25519.verify(pub, sig, FNID.homesCanonical(fnid, serial, relays)) } - /** Relays named by a record, but only once it verifies. */ - fun verifiedRelays(record: org.json.JSONObject): List? { + /** + * Relays named by a record, but only once it verifies AND is not older than + * one already seen. + * + * 🔴 **A signature alone does not stop a rollback, and that is the whole + * reason the serial exists.** An older record is *genuinely signed*: it was + * published by the account holder, before they added a second home. A relay + * that wants to be somebody's only mailbox does not have to forge anything + * — it serves the record it saw first, that record verifies perfectly, and + * the sender delivers to one relay believing it is delivering to all of + * them. + * + * That is the attack multi-homing exists to survive, and until this both + * clients checked only the signature. The relay refuses a lower serial on + * WRITE; nothing refused one on READ. + * + * ⚠️ [floor] is the highest serial this device has already accepted for + * this address, or null on first sight. Equal is fine — the same record + * re-read is the ordinary case. Only strictly lower is a rollback. + * + * ⚠️ **A refusal here is not proof of an attack**, and the caller must not + * present it as one. A relay that has genuinely fallen behind serves an old + * record for honest reasons; what the client can say is that it has seen a + * newer one, and that it will keep using it. + */ + fun verifiedRelays( + record: org.json.JSONObject, + floor: Int? = null, + ): List? { + val fnid = record.optString("fnid").ifEmpty { return null } + val serial = record.optInt("serial", -1) + if (serial < 0) return null + if (floor != null && serial < floor) return null + val array = record.optJSONArray("relays") ?: return null + val relays = (0 until array.length()).map { array.getString(it) } + val sig = record.optString("sig").ifEmpty { return null } + return if (verifyHomes(fnid, serial, relays, sig)) relays else null + } + + /** The serial a record carries, once it verifies. Null if it does not. */ + fun verifiedSerial(record: org.json.JSONObject): Int? { val fnid = record.optString("fnid").ifEmpty { return null } val serial = record.optInt("serial", -1) if (serial < 0) return null val array = record.optJSONArray("relays") ?: return null val relays = (0 until array.length()).map { array.getString(it) } val sig = record.optString("sig").ifEmpty { return null } - return if (verifyHomes(fnid, serial, relays, sig)) relays else null + return if (verifyHomes(fnid, serial, relays, sig)) serial else null } // The module's own helper, so this encodes identically to every other blob diff --git a/client/src/test/kotlin/io/flamenet/client/HomeRecordRollbackTest.kt b/client/src/test/kotlin/io/flamenet/client/HomeRecordRollbackTest.kt new file mode 100644 index 0000000..e3cbacf --- /dev/null +++ b/client/src/test/kotlin/io/flamenet/client/HomeRecordRollbackTest.kt @@ -0,0 +1,142 @@ +package io.flamenet.client + +import io.flamenet.e2e.Ed25519 +import io.flamenet.e2e.FNID +import java.security.SecureRandom +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import org.json.JSONArray +import org.json.JSONObject + +/** + * A home record that is older than one already seen must be refused (§20.4). + * + * 🔴 **A signature alone does not stop a rollback, and both clients checked + * only the signature.** An older home record is *genuinely signed* — the + * account holder published it, before they added a second relay. So a relay + * that wants to be somebody's only mailbox does not have to forge anything: it + * serves the record it saw first, that record verifies perfectly, and the + * sender delivers to one relay believing it is delivering to all of them. + * + * That is precisely the attack multi-homing exists to survive, and the serial + * is what was added to stop it. The relay refuses a lower serial on WRITE; + * until this, nothing refused one on READ — on either platform. + */ +class HomeRecordRollbackTest { + + private val seed = ByteArray(32).also { SecureRandom().nextBytes(it) } + private val fnid = FNID.address(Ed25519.fromSeed(seed).publicKey) + + /** A record the account holder really did sign. */ + private fun record(serial: Int, relays: List): JSONObject { + val sig = Wire.b64(Ed25519.sign(seed, FNID.homesCanonical(fnid, serial, relays))) + return JSONObject() + .put("fnid", fnid) + .put("serial", serial) + .put("relays", JSONArray(relays)) + .put("sig", sig) + } + + private val old = record(1, listOf("one.example")) + private val current = record(4, listOf("one.example", "two.example")) + + // ---- The attack ------------------------------------------------------ + + /** + * 🔴 The whole finding, in one assertion. Both records verify; only the + * serial separates them. + */ + @Test + fun `an older record is refused once a newer one has been seen`() { + assertNotNull(AccountIdentity.verifiedRelays(old), + "the old record does not verify, so this test proves nothing") + assertNotNull(AccountIdentity.verifiedRelays(current)) + + assertNull( + AccountIdentity.verifiedRelays(old, floor = 4), + "a relay serving a genuinely-signed OLDER record was believed — " + + "which is exactly how one relay makes itself somebody's only mailbox", + ) + } + + /** The newer one is still accepted against the same floor. */ + @Test + fun `the record at the floor is accepted`() { + assertEquals( + listOf("one.example", "two.example"), + AccountIdentity.verifiedRelays(current, floor = 4), + ) + } + + /** + * ⚠️ Equal is fine. Re-reading the same record is the ordinary case, and + * refusing it would break every second lookup. + */ + @Test + fun `the same serial twice is not a rollback`() { + assertNotNull(AccountIdentity.verifiedRelays(record(4, listOf("a.example")), floor = 4)) + } + + /** First sight has no floor and accepts whatever verifies. */ + @Test + fun `with no floor any verifying record is accepted`() { + assertNotNull(AccountIdentity.verifiedRelays(old, floor = null)) + assertNotNull(AccountIdentity.verifiedRelays(current, floor = null)) + } + + // ---- The floor cannot be poisoned ------------------------------------ + + /** + * 🔴 **A serial is only reported for a record that VERIFIES.** Otherwise a + * hostile relay could hand over an unsigned record carrying serial + * 2,000,000,000, have the client raise its floor to it, and lock out every + * genuine record for ever — turning the rollback defence into a denial of + * service against the account holder. + */ + @Test + fun `an unsigned record reports no serial to raise the floor with`() { + val forged = JSONObject() + .put("fnid", fnid) + .put("serial", Int.MAX_VALUE) + .put("relays", JSONArray(listOf("attacker.example"))) + .put("sig", Wire.b64(ByteArray(64))) + assertNull(AccountIdentity.verifiedSerial(forged), + "a forged record could raise the floor and lock out every real one") + assertNull(AccountIdentity.verifiedRelays(forged, floor = 1)) + } + + /** A record signed for a DIFFERENT address does not verify either. */ + @Test + fun `a record signed by somebody else is refused`() { + val otherSeed = ByteArray(32).also { SecureRandom().nextBytes(it) } + val relays = listOf("attacker.example") + val sig = Wire.b64(Ed25519.sign(otherSeed, FNID.homesCanonical(fnid, 9, relays))) + val impostor = JSONObject() + .put("fnid", fnid).put("serial", 9) + .put("relays", JSONArray(relays)).put("sig", sig) + assertNull(AccountIdentity.verifiedSerial(impostor)) + assertNull(AccountIdentity.verifiedRelays(impostor, floor = null)) + } + + /** + * ⚠️ The relay list is part of what is signed, so swapping it invalidates + * the record — a relay cannot keep a valid signature and quietly drop the + * other homes out of the list. + */ + @Test + fun `the relay list cannot be edited without breaking the signature`() { + val tampered = record(4, listOf("one.example", "two.example")) + .put("relays", JSONArray(listOf("one.example"))) + assertNull(AccountIdentity.verifiedRelays(tampered), + "a relay dropped the other homes and kept a valid signature") + } + + /** A negative or absent serial is malformed, not a floor of zero. */ + @Test + fun `a malformed serial is refused rather than treated as zero`() { + assertNull(AccountIdentity.verifiedRelays( + JSONObject().put("fnid", fnid).put("relays", JSONArray()).put("sig", "x"))) + } +} diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Base58.kt b/engine/src/main/kotlin/io/flamenet/e2e/Base58.kt new file mode 100644 index 0000000..7365e8f --- /dev/null +++ b/engine/src/main/kotlin/io/flamenet/e2e/Base58.kt @@ -0,0 +1,82 @@ +package io.flamenet.e2e + +/** + * Base58, the alphabet every ed25519 chain prints addresses in. + * + * ⚠️ **The leading-zero rule is the whole difficulty.** Base58 is + * base-conversion of one big integer, and an integer has no way to represent a + * leading zero byte — so the standard prepends one `1` per leading zero byte + * separately. Implementations that forget this encode two different keys to the + * same string, and the failure surfaces as money sent to the wrong address. + * Hence the tests, which are mostly about zeros. + * + * 🔴 **Hand-written rather than `java.math.BigInteger`.** The obvious Kotlin + * implementation is `BigInteger(1, bytes).toString(58)` with a remapped + * alphabet, and it is wrong in exactly the way above: `BigInteger` discards + * leading zeros, so a key beginning `0x00` loses a character and encodes to + * another key's address. The digit loop below is the same one the Swift engine + * runs, so the two cannot disagree. + */ +object Base58 { + + /** + * 🔴 Not the Bitcoin alphabet's cousins: `0`, `O`, `I` and `l` are absent + * deliberately, so an address read aloud or copied by hand cannot become a + * different valid one. + */ + const val ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" + + private val VALUES: Map = + ALPHABET.withIndex().associate { (index, character) -> character to index } + + fun encode(data: ByteArray): String { + if (data.isEmpty()) return "" + // 🔴 Starts EMPTY, not with a zero digit. Seeded with one, an all-zero + // input produces one digit AND one leading '1', so a single 0x00 byte + // encodes as "11" — every address with a leading zero comes out one + // character too long, and is either rejected by the other end or, + // worse, accepted as a different one. + val digits = ArrayList() + for (byte in data) { + var carry = byte.toInt() and 0xff + for (position in digits.indices) { + carry += digits[position] shl 8 + digits[position] = carry % 58 + carry /= 58 + } + while (carry > 0) { + digits.add(carry % 58) + carry /= 58 + } + } + // One '1' per leading zero byte — see the note above. + val leadingZeros = data.takeWhile { it.toInt() == 0 }.count() + return "1".repeat(leadingZeros) + + digits.reversed().map { ALPHABET[it] }.joinToString("") + } + + /** + * Null for any character outside the alphabet, rather than a best guess: a + * mistyped address must fail here, not resolve to somebody else's. + */ + fun decode(text: String): ByteArray? { + if (text.isEmpty()) return ByteArray(0) + val bytes = ArrayList() + for (character in text) { + var carry = VALUES[character] ?: return null + for (position in bytes.indices) { + carry += bytes[position] * 58 + bytes[position] = carry and 0xff + carry = carry shr 8 + } + while (carry > 0) { + bytes.add(carry and 0xff) + carry = carry shr 8 + } + } + val leadingOnes = text.takeWhile { it == '1' }.count() + val out = ByteArray(leadingOnes + bytes.size) + for (i in bytes.indices) out[leadingOnes + i] = bytes[bytes.size - 1 - i].toByte() + return out + } +} diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Bip39.kt b/engine/src/main/kotlin/io/flamenet/e2e/Bip39.kt new file mode 100644 index 0000000..417e51e --- /dev/null +++ b/engine/src/main/kotlin/io/flamenet/e2e/Bip39.kt @@ -0,0 +1,216 @@ +package io.flamenet.e2e + +import java.security.MessageDigest +import java.text.Normalizer +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec + +/** + * BIP-39 mnemonics, for the wallet only. + * + * 🔴 **Not for anything else here.** Files recovers through its own Crockford + * codes, and the reasoning for rejecting a word list there stands: 2,048 + * entries that must be byte-exact is a footgun. The wallet accepts that footgun + * for one reason — a phrase has to be understood by software nobody here + * controls — and pays for it with [verify]. + * + * A port of `Packages/FlamenetFiles/Sources/FilesEngine/BIP39.swift`, asserted + * against the same published vectors so the two cannot drift into deriving + * different wallets from one phrase. The vendored word list is byte-identical + * to the iOS one and has the same SHA-256. + */ +object Bip39 { + + sealed class Failure(message: String) : Exception(message) { + class BadEntropySize(val size: Int) : + Failure("Entropy must be 16, 20, 24, 28 or 32 bytes, not $size.") + class BadPhraseLength(val words: Int) : + Failure("A recovery phrase is 12, 15, 18, 21 or 24 words, not $words.") + class UnknownWord(val word: String) : + Failure("“$word” is not a recovery phrase word.") + object ChecksumFailed : + Failure("That phrase does not check out — look for a mistyped word.") + class WordlistDigestMismatch(val found: String) : + Failure("The word list does not match its published fingerprint.") + class WordlistWrongLength(val count: Int) : + Failure("A word list has exactly 2048 words, not $count.") + } + + /** + * The published SHA-256 of the BIP-39 English word list. + * + * ⚠️ Wrong here fails CLOSED — a correct list would be rejected and nothing + * would derive, which is the safe direction to be wrong in. + */ + const val ENGLISH_DIGEST = "2f5eed53a4727b4bf8880d8f3f199efc90e58503646d9ff8eff3a2ed3b24dbda" + + /** + * 🔴 The whole mitigation. A silently corrupted list does not error — it + * produces a DIFFERENT wallet from the same phrase, and the way somebody + * finds out is restoring and seeing an empty account. + */ + fun verify(words: List, digest: String = ENGLISH_DIGEST) { + if (words.size != 2048) throw Failure.WordlistWrongLength(words.size) + val canonical = words.joinToString("\n") + "\n" + val hex = MessageDigest.getInstance("SHA-256") + .digest(canonical.toByteArray(Charsets.UTF_8)) + .joinToString("") { "%02x".format(it) } + if (hex != digest) throw Failure.WordlistDigestMismatch(hex) + } + + private val PERMITTED = mapOf(16 to 12, 20 to 15, 24 to 18, 28 to 21, 32 to 24) + + /** + * Entropy to phrase. The checksum is the first `entropy.size / 4` bits of + * SHA-256(entropy), which is what makes a mistyped word fail rather than + * silently name another wallet. + */ + fun phrase(entropy: ByteArray, words: List): String { + if (!PERMITTED.containsKey(entropy.size)) throw Failure.BadEntropySize(entropy.size) + val checksum = MessageDigest.getInstance("SHA-256").digest(entropy) + val bits = StringBuilder() + for (b in entropy) bits.append(bitsOf(b)) + for (b in checksum) bits.append(bitsOf(b)) + val used = bits.substring(0, entropy.size * 8 + entropy.size / 4) + + val out = ArrayList() + var index = 0 + while (index < used.length) { + out.add(words[used.substring(index, index + 11).toInt(2)]) + index += 11 + } + return out.joinToString(" ") + } + + /** + * Phrase back to entropy, refusing anything that does not check out. + * + * ⚠️ Every failure is loud. A phrase of the wrong length, containing a word + * not on the list, or failing its checksum is rejected — never coerced into + * the nearest valid thing, which would hand somebody a wallet not theirs. + */ + fun entropy(phrase: String, words: List): ByteArray { + val given = normalise(phrase).trim().split(Regex("\\s+")).filter { it.isNotEmpty() } + if (given.size !in listOf(12, 15, 18, 21, 24)) throw Failure.BadPhraseLength(given.size) + + val lookup = HashMap(words.size * 2) + words.forEachIndexed { at, word -> lookup[word] = at } + + val bits = StringBuilder() + for (word in given) { + val at = lookup[word] ?: throw Failure.UnknownWord(word) + bits.append(at.toString(2).padStart(11, '0')) + } + + val entropyBits = given.size * 11 * 32 / 33 + val entropy = ByteArray(entropyBits / 8) + for (i in entropy.indices) { + entropy[i] = bits.substring(i * 8, i * 8 + 8).toInt(2).toByte() + } + val expected = MessageDigest.getInstance("SHA-256").digest(entropy) + .joinToString("") { bitsOf(it) } + val carried = bits.substring(entropyBits) + if (carried != expected.substring(0, entropyBits / 32)) throw Failure.ChecksumFailed + return entropy + } + + /** + * Phrase to the 64-byte seed everything derives from. + * + * ⚠️ The optional passphrase is BIP-39's, not the account's. A different one + * silently yields a DIFFERENT valid wallet — the standard's design, and a + * sharp edge worth naming wherever it is offered to somebody. + */ + fun seed(phrase: String, passphrase: String = ""): ByteArray = + pbkdf2HmacSha512( + password = normalise(phrase).toByteArray(Charsets.UTF_8), + salt = normalise("mnemonic$passphrase").toByteArray(Charsets.UTF_8), + iterations = 2048, + length = 64, + ) + + /** + * The vendored list, read from the module's resources. + * + * 🔴 Verified on every load rather than once at build. The file ships inside + * the app, and a check that only ran in CI would not notice one that + * arrived corrupted. + */ + fun englishWordlist(): List { + val stream = Bip39::class.java.getResourceAsStream("english.txt") + ?: throw Failure.WordlistWrongLength(0) + val words = stream.bufferedReader(Charsets.UTF_8).use { it.readText() } + .trim().split("\n").map { it.trim() }.filter { it.isNotEmpty() } + verify(words) + return words + } + + // ---- Internals ------------------------------------------------------- + + private fun bitsOf(b: Byte): String = + (b.toInt() and 0xff).toString(2).padStart(8, '0') + + /** + * 🔴 **NFKD, and it decides which wallet you get.** BIP-39 normalises the + * phrase and the salt before stretching. A phrase typed with composed + * accents and one typed with combining marks are the same words to a + * reader and different bytes to PBKDF2 — so skipping this derives a + * different, perfectly valid, empty wallet. + */ + private fun normalise(text: String): String = + Normalizer.normalize(text, Normalizer.Form.NFKD) + + /** + * PBKDF2-HMAC-SHA512. + * + * 🔴 **Hand-written on top of [Mac] rather than through + * `SecretKeyFactory("PBKDF2WithHmacSHA512")`, and the reason is money.** + * The JCA path takes a `CharArray` and the provider decides how to encode + * it — `AccountKdf.master` already carries a note about that for the + * account KDF, where the same ambiguity exists. For a wallet the + * consequence is worse than a failed sign-in: a phrase containing anything + * outside ASCII would stretch to different bytes under a different + * provider, and derive a different wallet that is empty and perfectly + * valid, with no error anywhere. Taking the bytes ourselves removes the + * provider from the decision entirely. + * + * ⚠️ Also: `PBKDF2WithHmacSHA512` is absent on Android below API 26, and + * this engine's floor is lower than the rest of the app's for a reason — + * the JVM tests run it with no emulator. + */ + internal fun pbkdf2HmacSha512( + password: ByteArray, + salt: ByteArray, + iterations: Int, + length: Int, + ): ByteArray { + val mac = Mac.getInstance("HmacSHA512") + mac.init(SecretKeySpec(password, "HmacSHA512")) + val blockSize = mac.macLength + val out = ByteArray(length) + var written = 0 + var block = 1 + while (written < length) { + mac.reset() + mac.update(salt) + mac.update(byteArrayOf( + (block ushr 24).toByte(), (block ushr 16).toByte(), + (block ushr 8).toByte(), block.toByte(), + )) + var u = mac.doFinal() + val accumulated = u.copyOf() + for (round in 2..iterations) { + mac.reset() + u = mac.doFinal(u) + for (i in accumulated.indices) { + accumulated[i] = (accumulated[i].toInt() xor u[i].toInt()).toByte() + } + } + val take = minOf(blockSize, length - written) + System.arraycopy(accumulated, 0, out, written, take) + written += take + block++ + } + return out + } +} diff --git a/engine/src/main/kotlin/io/flamenet/e2e/HdKey.kt b/engine/src/main/kotlin/io/flamenet/e2e/HdKey.kt new file mode 100644 index 0000000..69ac7f1 --- /dev/null +++ b/engine/src/main/kotlin/io/flamenet/e2e/HdKey.kt @@ -0,0 +1,124 @@ +package io.flamenet.e2e + +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec + +/** + * Hierarchical key derivation from a wallet seed. SLIP-0010, ed25519 curve. + * + * 🔴 **Addresses are independent of who you are, by construction.** The seed is + * fresh entropy, never derived from the passphrase, the account name, or the + * Flamenet identity — so no address can be computed from a Flamenet identity + * and no identity from an address. This type only walks a tree downward from + * that seed; nothing identifying is ever an input, and nothing here should ever + * be given one. + * + * ⚠️ **Fresh key per use, not one address reused.** The tree exists so an + * ordinary path — `m/44H/501H/accountH/0H` — yields a new keypair per receive + * without new backup material: the phrase still restores all of them. Reusing a + * single address would tie every payment together on a public ledger for + * anybody who cared to look, which is a privacy property the wallet can control + * and the chain cannot. + * + * 🔴 **ed25519 only, and hardened only.** SLIP-0010 defines no non-hardened + * derivation for this curve — the public key is a compressed Edwards point with + * no usable additive homomorphism — so [derive] refuses an unhardened index + * rather than inventing a scheme. secp256k1 chains need curve arithmetic this + * engine does not carry; supporting them is a dependency decision, not + * something to approximate here. + */ +data class HdKey( + /** 32 bytes. The ed25519 scalar seed, not an expanded secret. */ + val privateKey: ByteArray, + /** 32 bytes. Feeds the next derivation, never a key on its own. */ + val chainCode: ByteArray, +) { + // ⚠️ ByteArray has reference equality, and a data class inherits it. Two + // keys derived from one path down two objects must compare equal or every + // test here asserts nothing. + override fun equals(other: Any?) = other is HdKey && + privateKey.contentEquals(other.privateKey) && chainCode.contentEquals(other.chainCode) + + override fun hashCode() = 31 * privateKey.contentHashCode() + chainCode.contentHashCode() + + sealed class Failure(message: String) : Exception(message) { + class SeedOutOfRange(val size: Int) : Failure("That wallet seed is the wrong size.") + class Unhardened(val index: Long) : Failure("This wallet only derives hardened keys.") + class MalformedPath(val path: String) : Failure("$path isn't a derivation path.") + } + + /** One hardened step. [index] may be given with or without the flag set. */ + fun derive(index: Long): HdKey { + val hardenedIndex = if (index >= HARDENED) index else index or HARDENED + if (hardenedIndex < HARDENED) throw Failure.Unhardened(index) + + // 0x00 || private key || index, big-endian. The leading zero is what + // distinguishes this from the secp256k1 layout and is part of the + // standard, not padding. + val data = ByteArray(1 + 32 + 4) + data[0] = 0 + System.arraycopy(privateKey, 0, data, 1, 32) + data[33] = (hardenedIndex ushr 24).toByte() + data[34] = (hardenedIndex ushr 16).toByte() + data[35] = (hardenedIndex ushr 8).toByte() + data[36] = hardenedIndex.toByte() + + val bytes = hmacSha512(key = chainCode, data = data) + return HdKey(bytes.copyOfRange(0, 32), bytes.copyOfRange(32, 64)) + } + + /** + * Walk a path such as `m/44H/501H/0H/0H`. + * + * ⚠️ Accepts `H`, `h` and `'` for hardened, because all three are written + * in the wild and a path copied from documentation should not fail on + * notation. An index with none of them is REFUSED rather than silently + * hardened: somebody writing `m/44` means non-hardened, which this curve + * cannot do, and quietly giving them a different key than every other + * wallet would is the worst possible answer. + */ + fun derive(path: String): HdKey { + val components = path.split("/").filter { it.isNotEmpty() }.toMutableList() + if (components.firstOrNull()?.lowercase() == "m") components.removeAt(0) + var key = this + for (component in components) { + if (!(component.endsWith("H") || component.endsWith("h") || component.endsWith("'"))) { + throw Failure.Unhardened(0) + } + val index = component.dropLast(1).toLongOrNull() + ?: throw Failure.MalformedPath(path) + if (index < 0 || index >= HARDENED) throw Failure.MalformedPath(path) + key = key.derive(index) + } + return key + } + + companion object { + /** SLIP-0010 fixes this string; changing it changes every key. */ + private val CURVE = "ed25519 seed".toByteArray(Charsets.UTF_8) + + /** Anything at or above this is hardened. */ + const val HARDENED: Long = 0x8000_0000L + + /** The master key. SLIP-0010 §Master key generation. */ + fun master(seed: ByteArray): HdKey { + // The standard's own bound. BIP-39 gives 64, so this only fires on + // a caller that built a seed some other way. + if (seed.size !in 16..64) throw Failure.SeedOutOfRange(seed.size) + val bytes = hmacSha512(key = CURVE, data = seed) + // ⚠️ ed25519 needs no retry loop. On secp256k1 an IL of zero or ≥ n + // is re-hashed; every 32-byte string is a valid ed25519 scalar seed, + // so there is no such case and adding one would be dead code that + // looks load-bearing. + return HdKey(bytes.copyOfRange(0, 32), bytes.copyOfRange(32, 64)) + } + + fun master(seed: ByteArray, path: String): HdKey = master(seed).derive(path) + + private fun hmacSha512(key: ByteArray, data: ByteArray): ByteArray { + val mac = Mac.getInstance("HmacSHA512") + mac.init(SecretKeySpec(key, "HmacSHA512")) + return mac.doFinal(data) + } + } +} diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt index e61e5cc..2afd894 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt @@ -1667,6 +1667,33 @@ class Vault(private val storage: Storage, private val key: SecretKey) { storage.delete(pinnedName(threadId)) } + // -- Home-record rollback floor (§20.4) ---------------------------------- + + private fun homeSerialName(fnid: String) = "home-serial-${Bytes.b64(Bytes.sha256(fnid.toByteArray(Charsets.UTF_8))).take(22).replace('/', '_')}" + + /** + * The highest home-record serial seen for an address, or null. + * + * 🔴 **This is what makes the serial mean anything on the reading side.** An + * older home record is genuinely signed — published by the account holder + * before they added a second relay — so a relay serving it is handing out + * something that verifies. Remembering the highest seen is the only thing + * that turns "this signature is valid" into "this is not a rollback". + * + * ⚠️ Sealed like everything else here: which addresses this device has + * looked up is exactly the metadata the vault exists to keep off a stolen + * phone, and the name is a hash of the address for the same reason. + */ + fun homeSerial(fnid: String): Int? = + readJson(homeSerialName(fnid))?.optInt("serial", -1)?.takeIf { it >= 0 } + + /** Remember a serial, never going backwards. */ + fun saveHomeSerial(fnid: String, serial: Int) { + val seen = homeSerial(fnid) ?: -1 + if (serial <= seen) return + writeJson(homeSerialName(fnid), JSONObject().put("serial", serial)) + } + // -- Pinned message (§6.19) ---------------------------------------------- private fun pinnedName(threadId: String) = "pinned-$threadId" diff --git a/engine/src/main/resources/io/flamenet/e2e/english.txt b/engine/src/main/resources/io/flamenet/e2e/english.txt new file mode 100644 index 0000000..942040e --- /dev/null +++ b/engine/src/main/resources/io/flamenet/e2e/english.txt @@ -0,0 +1,2048 @@ +abandon +ability +able +about +above +absent +absorb +abstract +absurd +abuse +access +accident +account +accuse +achieve +acid +acoustic +acquire +across +act +action +actor +actress +actual +adapt +add +addict +address +adjust +admit +adult +advance +advice +aerobic +affair +afford +afraid +again +age +agent +agree +ahead +aim +air +airport +aisle +alarm +album +alcohol +alert +alien +all +alley +allow +almost +alone +alpha +already +also +alter +always +amateur +amazing +among +amount +amused +analyst +anchor +ancient +anger +angle +angry +animal +ankle +announce +annual +another +answer +antenna +antique +anxiety +any +apart +apology +appear +apple +approve +april +arch +arctic +area +arena +argue +arm +armed +armor +army +around +arrange +arrest +arrive +arrow +art +artefact +artist +artwork +ask +aspect +assault +asset +assist +assume +asthma +athlete +atom +attack +attend +attitude +attract +auction +audit +august +aunt +author +auto +autumn +average +avocado +avoid +awake +aware +away +awesome +awful +awkward +axis +baby +bachelor +bacon +badge +bag +balance +balcony +ball +bamboo +banana +banner +bar +barely +bargain +barrel +base +basic +basket +battle +beach +bean +beauty +because +become +beef +before +begin +behave +behind +believe +below +belt +bench +benefit +best +betray +better +between +beyond +bicycle +bid +bike +bind +biology +bird +birth +bitter +black +blade +blame +blanket +blast +bleak +bless +blind +blood +blossom +blouse +blue +blur +blush +board +boat +body +boil +bomb +bone +bonus +book +boost +border +boring +borrow +boss +bottom +bounce +box +boy +bracket +brain +brand +brass +brave +bread +breeze +brick +bridge +brief +bright +bring +brisk +broccoli +broken +bronze +broom +brother +brown +brush +bubble +buddy +budget +buffalo +build +bulb +bulk +bullet +bundle +bunker +burden +burger +burst +bus +business +busy +butter +buyer +buzz +cabbage +cabin +cable +cactus +cage +cake +call +calm +camera +camp +can +canal +cancel +candy +cannon +canoe +canvas +canyon +capable +capital +captain +car +carbon +card +cargo +carpet +carry +cart +case +cash +casino +castle +casual +cat +catalog +catch +category +cattle +caught +cause +caution +cave +ceiling +celery +cement +census +century +cereal +certain +chair +chalk +champion +change +chaos +chapter +charge +chase +chat +cheap +check +cheese +chef +cherry +chest +chicken +chief +child +chimney +choice +choose +chronic +chuckle +chunk +churn +cigar +cinnamon +circle +citizen +city +civil +claim +clap +clarify +claw +clay +clean +clerk +clever +click +client +cliff +climb +clinic +clip +clock +clog +close +cloth +cloud +clown +club +clump +cluster +clutch +coach +coast +coconut +code +coffee +coil +coin +collect +color +column +combine +come +comfort +comic +common +company +concert +conduct +confirm +congress +connect +consider +control +convince +cook +cool +copper +copy +coral +core +corn +correct +cost +cotton +couch +country +couple +course +cousin +cover +coyote +crack +cradle +craft +cram +crane +crash +crater +crawl +crazy +cream +credit +creek +crew +cricket +crime +crisp +critic +crop +cross +crouch +crowd +crucial +cruel +cruise +crumble +crunch +crush +cry +crystal +cube +culture +cup +cupboard +curious +current +curtain +curve +cushion +custom +cute +cycle +dad +damage +damp +dance +danger +daring +dash +daughter +dawn +day +deal +debate +debris +decade +december +decide +decline +decorate +decrease +deer +defense +define +defy +degree +delay +deliver +demand +demise +denial +dentist +deny +depart +depend +deposit +depth +deputy +derive +describe +desert +design +desk +despair +destroy +detail +detect +develop +device +devote +diagram +dial +diamond +diary +dice +diesel +diet +differ +digital +dignity +dilemma +dinner +dinosaur +direct +dirt +disagree +discover +disease +dish +dismiss +disorder +display +distance +divert +divide +divorce +dizzy +doctor +document +dog +doll +dolphin +domain +donate +donkey +donor +door +dose +double +dove +draft +dragon +drama +drastic +draw +dream +dress +drift +drill +drink +drip +drive +drop +drum +dry +duck +dumb +dune +during +dust +dutch +duty +dwarf +dynamic +eager +eagle +early +earn +earth +easily +east +easy +echo +ecology +economy +edge +edit +educate +effort +egg +eight +either +elbow +elder +electric +elegant +element +elephant +elevator +elite +else +embark +embody +embrace +emerge +emotion +employ +empower +empty +enable +enact +end +endless +endorse +enemy +energy +enforce +engage +engine +enhance +enjoy +enlist +enough +enrich +enroll +ensure +enter +entire +entry +envelope +episode +equal +equip +era +erase +erode +erosion +error +erupt +escape +essay +essence +estate +eternal +ethics +evidence +evil +evoke +evolve +exact +example +excess +exchange +excite +exclude +excuse +execute +exercise +exhaust +exhibit +exile +exist +exit +exotic +expand +expect +expire +explain +expose +express +extend +extra +eye +eyebrow +fabric +face +faculty +fade +faint +faith +fall +false +fame +family +famous +fan +fancy +fantasy +farm +fashion +fat +fatal +father +fatigue +fault +favorite +feature +february +federal +fee +feed +feel +female +fence +festival +fetch +fever +few +fiber +fiction +field +figure +file +film +filter +final +find +fine +finger +finish +fire +firm +first +fiscal +fish +fit +fitness +fix +flag +flame +flash +flat +flavor +flee +flight +flip +float +flock +floor +flower +fluid +flush +fly +foam +focus +fog +foil +fold +follow +food +foot +force +forest +forget +fork +fortune +forum +forward +fossil +foster +found +fox +fragile +frame +frequent +fresh +friend +fringe +frog +front +frost +frown +frozen +fruit +fuel +fun +funny +furnace +fury +future +gadget +gain +galaxy +gallery +game +gap +garage +garbage +garden +garlic +garment +gas +gasp +gate +gather +gauge +gaze +general +genius +genre +gentle +genuine +gesture +ghost +giant +gift +giggle +ginger +giraffe +girl +give +glad +glance +glare +glass +glide +glimpse +globe +gloom +glory +glove +glow +glue +goat +goddess +gold +good +goose +gorilla +gospel +gossip +govern +gown +grab +grace +grain +grant +grape +grass +gravity +great +green +grid +grief +grit +grocery +group +grow +grunt +guard +guess +guide +guilt +guitar +gun +gym +habit +hair +half +hammer +hamster +hand +happy +harbor +hard +harsh +harvest +hat +have +hawk +hazard +head +health +heart +heavy +hedgehog +height +hello +helmet +help +hen +hero +hidden +high +hill +hint +hip +hire +history +hobby +hockey +hold +hole +holiday +hollow +home +honey +hood +hope +horn +horror +horse +hospital +host +hotel +hour +hover +hub +huge +human +humble +humor +hundred +hungry +hunt +hurdle +hurry +hurt +husband +hybrid +ice +icon +idea +identify +idle +ignore +ill +illegal +illness +image +imitate +immense +immune +impact +impose +improve +impulse +inch +include +income +increase +index +indicate +indoor +industry +infant +inflict +inform +inhale +inherit +initial +inject +injury +inmate +inner +innocent +input +inquiry +insane +insect +inside +inspire +install +intact +interest +into +invest +invite +involve +iron +island +isolate +issue +item +ivory +jacket +jaguar +jar +jazz +jealous +jeans +jelly +jewel +job +join +joke +journey +joy +judge +juice +jump +jungle +junior +junk +just +kangaroo +keen +keep +ketchup +key +kick +kid +kidney +kind +kingdom +kiss +kit +kitchen +kite +kitten +kiwi +knee +knife +knock +know +lab +label +labor +ladder +lady +lake +lamp +language +laptop +large +later +latin +laugh +laundry +lava +law +lawn +lawsuit +layer +lazy +leader +leaf +learn +leave +lecture +left +leg +legal +legend +leisure +lemon +lend +length +lens +leopard +lesson +letter +level +liar +liberty +library +license +life +lift +light +like +limb +limit +link +lion +liquid +list +little +live +lizard +load +loan +lobster +local +lock +logic +lonely +long +loop +lottery +loud +lounge +love +loyal +lucky +luggage +lumber +lunar +lunch +luxury +lyrics +machine +mad +magic +magnet +maid +mail +main +major +make +mammal +man +manage +mandate +mango +mansion +manual +maple +marble +march +margin +marine +market +marriage +mask +mass +master +match +material +math +matrix +matter +maximum +maze +meadow +mean +measure +meat +mechanic +medal +media +melody +melt +member +memory +mention +menu +mercy +merge +merit +merry +mesh +message +metal +method +middle +midnight +milk +million +mimic +mind +minimum +minor +minute +miracle +mirror +misery +miss +mistake +mix +mixed +mixture +mobile +model +modify +mom +moment +monitor +monkey +monster +month +moon +moral +more +morning +mosquito +mother +motion +motor +mountain +mouse +move +movie +much +muffin +mule +multiply +muscle +museum +mushroom +music +must +mutual +myself +mystery +myth +naive +name +napkin +narrow +nasty +nation +nature +near +neck +need +negative +neglect +neither +nephew +nerve +nest +net +network +neutral +never +news +next +nice +night +noble +noise +nominee +noodle +normal +north +nose +notable +note +nothing +notice +novel +now +nuclear +number +nurse +nut +oak +obey +object +oblige +obscure +observe +obtain +obvious +occur +ocean +october +odor +off +offer +office +often +oil +okay +old +olive +olympic +omit +once +one +onion +online +only +open +opera +opinion +oppose +option +orange +orbit +orchard +order +ordinary +organ +orient +original +orphan +ostrich +other +outdoor +outer +output +outside +oval +oven +over +own +owner +oxygen +oyster +ozone +pact +paddle +page +pair +palace +palm +panda +panel +panic +panther +paper +parade +parent +park +parrot +party +pass +patch +path +patient +patrol +pattern +pause +pave +payment +peace +peanut +pear +peasant +pelican +pen +penalty +pencil +people +pepper +perfect +permit +person +pet +phone +photo +phrase +physical +piano +picnic +picture +piece +pig +pigeon +pill +pilot +pink +pioneer +pipe +pistol +pitch +pizza +place +planet +plastic +plate +play +please +pledge +pluck +plug +plunge +poem +poet +point +polar +pole +police +pond +pony +pool +popular +portion +position +possible +post +potato +pottery +poverty +powder +power +practice +praise +predict +prefer +prepare +present +pretty +prevent +price +pride +primary +print +priority +prison +private +prize +problem +process +produce +profit +program +project +promote +proof +property +prosper +protect +proud +provide +public +pudding +pull +pulp +pulse +pumpkin +punch +pupil +puppy +purchase +purity +purpose +purse +push +put +puzzle +pyramid +quality +quantum +quarter +question +quick +quit +quiz +quote +rabbit +raccoon +race +rack +radar +radio +rail +rain +raise +rally +ramp +ranch +random +range +rapid +rare +rate +rather +raven +raw +razor +ready +real +reason +rebel +rebuild +recall +receive +recipe +record +recycle +reduce +reflect +reform +refuse +region +regret +regular +reject +relax +release +relief +rely +remain +remember +remind +remove +render +renew +rent +reopen +repair +repeat +replace +report +require +rescue +resemble +resist +resource +response +result +retire +retreat +return +reunion +reveal +review +reward +rhythm +rib +ribbon +rice +rich +ride +ridge +rifle +right +rigid +ring +riot +ripple +risk +ritual +rival +river +road +roast +robot +robust +rocket +romance +roof +rookie +room +rose +rotate +rough +round +route +royal +rubber +rude +rug +rule +run +runway +rural +sad +saddle +sadness +safe +sail +salad +salmon +salon +salt +salute +same +sample +sand +satisfy +satoshi +sauce +sausage +save +say +scale +scan +scare +scatter +scene +scheme +school +science +scissors +scorpion +scout +scrap +screen +script +scrub +sea +search +season +seat +second +secret +section +security +seed +seek +segment +select +sell +seminar +senior +sense +sentence +series +service +session +settle +setup +seven +shadow +shaft +shallow +share +shed +shell +sheriff +shield +shift +shine +ship +shiver +shock +shoe +shoot +shop +short +shoulder +shove +shrimp +shrug +shuffle +shy +sibling +sick +side +siege +sight +sign +silent +silk +silly +silver +similar +simple +since +sing +siren +sister +situate +six +size +skate +sketch +ski +skill +skin +skirt +skull +slab +slam +sleep +slender +slice +slide +slight +slim +slogan +slot +slow +slush +small +smart +smile +smoke +smooth +snack +snake +snap +sniff +snow +soap +soccer +social +sock +soda +soft +solar +soldier +solid +solution +solve +someone +song +soon +sorry +sort +soul +sound +soup +source +south +space +spare +spatial +spawn +speak +special +speed +spell +spend +sphere +spice +spider +spike +spin +spirit +split +spoil +sponsor +spoon +sport +spot +spray +spread +spring +spy +square +squeeze +squirrel +stable +stadium +staff +stage +stairs +stamp +stand +start +state +stay +steak +steel +stem +step +stereo +stick +still +sting +stock +stomach +stone +stool +story +stove +strategy +street +strike +strong +struggle +student +stuff +stumble +style +subject +submit +subway +success +such +sudden +suffer +sugar +suggest +suit +summer +sun +sunny +sunset +super +supply +supreme +sure +surface +surge +surprise +surround +survey +suspect +sustain +swallow +swamp +swap +swarm +swear +sweet +swift +swim +swing +switch +sword +symbol +symptom +syrup +system +table +tackle +tag +tail +talent +talk +tank +tape +target +task +taste +tattoo +taxi +teach +team +tell +ten +tenant +tennis +tent +term +test +text +thank +that +theme +then +theory +there +they +thing +this +thought +three +thrive +throw +thumb +thunder +ticket +tide +tiger +tilt +timber +time +tiny +tip +tired +tissue +title +toast +tobacco +today +toddler +toe +together +toilet +token +tomato +tomorrow +tone +tongue +tonight +tool +tooth +top +topic +topple +torch +tornado +tortoise +toss +total +tourist +toward +tower +town +toy +track +trade +traffic +tragic +train +transfer +trap +trash +travel +tray +treat +tree +trend +trial +tribe +trick +trigger +trim +trip +trophy +trouble +truck +true +truly +trumpet +trust +truth +try +tube +tuition +tumble +tuna +tunnel +turkey +turn +turtle +twelve +twenty +twice +twin +twist +two +type +typical +ugly +umbrella +unable +unaware +uncle +uncover +under +undo +unfair +unfold +unhappy +uniform +unique +unit +universe +unknown +unlock +until +unusual +unveil +update +upgrade +uphold +upon +upper +upset +urban +urge +usage +use +used +useful +useless +usual +utility +vacant +vacuum +vague +valid +valley +valve +van +vanish +vapor +various +vast +vault +vehicle +velvet +vendor +venture +venue +verb +verify +version +very +vessel +veteran +viable +vibrant +vicious +victory +video +view +village +vintage +violin +virtual +virus +visa +visit +visual +vital +vivid +vocal +voice +void +volcano +volume +vote +voyage +wage +wagon +wait +walk +wall +walnut +want +warfare +warm +warrior +wash +wasp +waste +water +wave +way +wealth +weapon +wear +weasel +weather +web +wedding +weekend +weird +welcome +west +wet +whale +what +wheat +wheel +when +where +whip +whisper +wide +width +wife +wild +will +win +window +wine +wing +wink +winner +winter +wire +wisdom +wise +wish +witness +wolf +woman +wonder +wood +wool +word +work +world +worry +worth +wrap +wreck +wrestle +wrist +write +wrong +yard +year +yellow +you +young +youth +zebra +zero +zone +zoo diff --git a/engine/src/test/kotlin/io/flamenet/e2e/WalletPrimitivesTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/WalletPrimitivesTest.kt new file mode 100644 index 0000000..60e6a04 --- /dev/null +++ b/engine/src/test/kotlin/io/flamenet/e2e/WalletPrimitivesTest.kt @@ -0,0 +1,347 @@ +package io.flamenet.e2e + +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * BIP-39, Base58 and SLIP-0010 against the published vectors. + * + * 🔴 **These are pinned to the STANDARDS, not to the Swift engine.** Testing + * one implementation against the other proves only that they agree, and two + * implementations agreeing on the wrong answer is exactly how a wallet ends up + * empty: a phrase written down on an iPhone has to restore on an Android + * handset, and on somebody else's software years from now. So the vectors here + * are BIP-39's own, SLIP-0010's own, and the published word-list digest — which + * the Swift engine is also held to. + * + * ⚠️ **Every failure in this file is silent in production.** A wrong checksum + * bit, a lost leading zero, a missed NFKD: none of them error. They derive a + * different, perfectly valid, empty wallet, and the way somebody finds out is + * restoring their phrase and seeing nothing there. + */ +class WalletPrimitivesTest { + + private fun hex(bytes: ByteArray) = bytes.joinToString("") { "%02x".format(it) } + private fun unhex(text: String) = ByteArray(text.length / 2) { + text.substring(it * 2, it * 2 + 2).toInt(16).toByte() + } + + private val words by lazy { Bip39.englishWordlist() } + + // ---- The word list --------------------------------------------------- + + /** + * 🔴 The whole mitigation for vendoring 2,048 strings. A silently corrupted + * list does not error — it produces a DIFFERENT wallet from the same + * phrase. + */ + @Test + fun `the vendored word list matches its published fingerprint`() { + assertEquals(2048, words.size) + Bip39.verify(words) + assertEquals("abandon", words.first()) + assertEquals("zoo", words.last()) + } + + @Test + fun `a corrupted word list is refused`() { + val tampered = words.toMutableList().also { it[7] = "banana" } + assertFailsWith { Bip39.verify(tampered) } + assertFailsWith { Bip39.verify(words.drop(1)) } + } + + // ---- BIP-39 vectors -------------------------------------------------- + + /** + * The canonical first vector from BIP-39's own test file: all-zero entropy. + */ + @Test + fun `all-zero entropy produces the canonical phrase and seed`() { + val entropy = ByteArray(16) + val phrase = Bip39.phrase(entropy, words) + assertEquals( + "abandon abandon abandon abandon abandon abandon abandon abandon " + + "abandon abandon abandon about", + phrase, + ) + assertContentEquals(entropy, Bip39.entropy(phrase, words)) + assertEquals( + "c55257c360c07c72029aebc1b53c05ed0362ada38ead3e3e9efa3708e5349553" + + "1f09a6987599d18264c1e1c92f2cf141630c7a3c4ab7c81b2f001698e7463b04", + hex(Bip39.seed(phrase, passphrase = "TREZOR")), + ) + } + + /** A 24-word vector, to exercise the longer checksum. */ + @Test + fun `the all-ones 32-byte vector round-trips`() { + val entropy = ByteArray(32) { 0xff.toByte() } + val phrase = Bip39.phrase(entropy, words) + assertEquals(24, phrase.split(" ").size) + assertEquals("zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo " + + "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo vote", phrase) + assertContentEquals(entropy, Bip39.entropy(phrase, words)) + // ⚠️ This expectation was WRONG in the first version of this file — it + // carried the seed from a different BIP-39 vector, and the Kotlin + // answer below is the correct one. It was resolved by computing the + // value independently (PBKDF2-HMAC-SHA512, NFKD, 2048 rounds) rather + // than by adopting whatever the implementation produced, which is the + // only way a vector test is worth anything: a pinned value taken from + // the code under test pins the bug. + assertEquals( + "dd48c104698c30cfe2b6142103248622fb7bb0ff692eebb00089b32d22484e16" + + "13912f0a5b694407be899ffd31ed3992c456cdf60f5d4564b8ba3f05a69890ad", + hex(Bip39.seed(phrase, passphrase = "TREZOR")), + ) + } + + /** A mid-length vector, so 18 words is not left to inference. */ + @Test + fun `an eighteen-word vector round-trips`() { + val entropy = unhex("808080808080808080808080808080808080808080808080") + val phrase = Bip39.phrase(entropy, words) + assertEquals( + "letter advice cage absurd amount doctor acoustic avoid letter " + + "advice cage absurd amount doctor acoustic avoid letter always", + phrase, + ) + assertContentEquals(entropy, Bip39.entropy(phrase, words)) + } + + /** + * 🔴 **The passphrase silently names a different wallet.** That is BIP-39's + * design, and it is the sharpest edge in the whole standard — somebody who + * mistypes it does not get an error, they get an empty account. + */ + @Test + fun `a different BIP-39 passphrase derives a different seed`() { + val phrase = Bip39.phrase(ByteArray(16), words) + assertNotEquals(hex(Bip39.seed(phrase)), hex(Bip39.seed(phrase, "TREZOR"))) + assertNotEquals(hex(Bip39.seed(phrase, "a")), hex(Bip39.seed(phrase, "A"))) + } + + // ---- BIP-39 refusals ------------------------------------------------- + + @Test + fun `a mistyped word is refused rather than resolved to a neighbour`() { + val phrase = Bip39.phrase(ByteArray(16), words).replace("about", "abut") + assertFailsWith { Bip39.entropy(phrase, words) } + } + + /** + * 🔴 The checksum's whole job: a phrase of real words in a plausible order + * that is not a phrase anybody generated. + */ + @Test + fun `a phrase that fails its checksum is refused`() { + val phrase = Bip39.phrase(ByteArray(16), words).replace("about", "abandon") + assertFailsWith { Bip39.entropy(phrase, words) } + } + + @Test + fun `a phrase of the wrong length is refused`() { + val short = Bip39.phrase(ByteArray(16), words).split(" ").drop(1).joinToString(" ") + assertFailsWith { Bip39.entropy(short, words) } + } + + @Test + fun `entropy of a size the standard does not define is refused`() { + assertFailsWith { Bip39.phrase(ByteArray(15), words) } + assertFailsWith { Bip39.phrase(ByteArray(64), words) } + } + + /** ⚠️ Extra spacing is a typing artefact, not a different phrase. */ + @Test + fun `surrounding and repeated whitespace does not change the phrase`() { + val phrase = Bip39.phrase(ByteArray(16), words) + assertContentEquals( + Bip39.entropy(phrase, words), + Bip39.entropy(" " + phrase.replace(" ", " ") + "\n", words), + ) + } + + // ---- Base58 ---------------------------------------------------------- + + /** + * 🔴 **The leading-zero rule, which is where money goes missing.** Base58 is + * base conversion of one integer, and an integer cannot hold a leading zero + * byte — the standard prepends one `1` per zero byte instead. An + * implementation using `BigInteger` loses them and encodes two different + * keys to one address. + */ + @Test + fun `leading zero bytes each become a one`() { + assertEquals("1", Base58.encode(byteArrayOf(0))) + assertEquals("11", Base58.encode(byteArrayOf(0, 0))) + assertEquals("1111", Base58.encode(byteArrayOf(0, 0, 0, 0))) + // And a zero byte in front of real data is not the same as without it. + assertNotEquals( + Base58.encode(byteArrayOf(1, 2, 3)), + Base58.encode(byteArrayOf(0, 1, 2, 3)), + ) + } + + @Test + fun `base58 round-trips, zeros included`() { + for (case in listOf( + byteArrayOf(), + byteArrayOf(0), + byteArrayOf(0, 0, 0), + byteArrayOf(1), + byteArrayOf(0, 1, 2, 3, 4, 5), + ByteArray(32) { it.toByte() }, + ByteArray(32) { 0xff.toByte() }, + ByteArray(32), + )) { + assertContentEquals(case, Base58.decode(Base58.encode(case)), + "round trip lost ${hex(case)}") + } + } + + @Test + fun `the published Bitcoin vectors encode as stated`() { + assertEquals("2NEpo7TZRRrLZSi2U", Base58.encode("Hello World!".toByteArray())) + assertEquals( + "USm3fpXnKG5EUBx2ndxBDMPVciP5hGey2Jh4NDv6gmeo1LkMeiKrLJUUBk6Z", + Base58.encode("The quick brown fox jumps over the lazy dog.".toByteArray()), + ) + } + + /** + * ⚠️ A mistyped address must FAIL, not resolve to somebody else's. `0`, + * `O`, `I` and `l` are absent from the alphabet for exactly this reason. + */ + @Test + fun `a character outside the alphabet decodes to nothing`() { + assertNull(Base58.decode("0OIl")) + assertNull(Base58.decode("abc def")) + assertNull(Base58.decode("abc+def")) + } + + // ---- SLIP-0010 vectors ----------------------------------------------- + + /** + * SLIP-0010's own ed25519 test vector one. The chain code is checked as + * well as the key: a derivation that gets the key right and the chain code + * wrong produces correct CHILDREN of nothing, and diverges one level down. + */ + @Test + fun `slip10 ed25519 vector one`() { + val seed = unhex("000102030405060708090a0b0c0d0e0f") + val master = HdKey.master(seed) + assertEquals( + "2b4be7f19ee27bbf30c667b642d5f4aa69fd169872f8fc3059c08ebae2eb19e7", + hex(master.privateKey), + ) + assertEquals( + "90046a93de5380a72b5e45010748567d5ea02bbf6522f979e05c0d8d8ca9fffb", + hex(master.chainCode), + ) + + val child = master.derive("m/0H") + assertEquals( + "68e0fe46dfb67e368c75379acec591dad19df3cde26e63b93a8e704f1dade7a3", + hex(child.privateKey), + ) + assertEquals( + "8b59aa11380b624e81507a27fedda59fea6d0b779a778918a2fd3590e16e9c69", + hex(child.chainCode), + ) + + val deeper = master.derive("m/0H/1H/2H/2H/1000000000H") + assertEquals( + "8f94d394a8e8fd6b1bc2f3f49f5c47e385281d5c17e65324b0f62483e37e8793", + hex(deeper.privateKey), + ) + } + + /** SLIP-0010's ed25519 vector two, with a longer seed. */ + @Test + fun `slip10 ed25519 vector two`() { + val seed = unhex( + "fffcf9f6f3f0edeae7e4e1dedbd8d5d2cfccc9c6c3c0bdbab7b4b1aeaba8a5a2" + + "9f9c999693908d8a8784817e7b7875726f6c696663605d5a5754514e4b484542" + ) + val master = HdKey.master(seed) + assertEquals( + "171cb88b1b3c1db25add599712e36245d75bc65a1a5c9e18d76f9f2b1eab4012", + hex(master.privateKey), + ) + val child = master.derive("m/0H/2147483647H/1H/2147483646H/2H") + assertEquals( + "551d333177df541ad876a60ea71f00447931c0a9da16f227c11ea080d7391b8d", + hex(child.privateKey), + ) + } + + /** + * 🔴 **A path without hardening is REFUSED, not silently hardened.** + * SLIP-0010 defines no non-hardened derivation for ed25519. Somebody + * writing `m/44` means non-hardened; quietly handing them a different key + * than every other wallet would give them an address nobody else can + * reproduce from the same phrase. + */ + @Test + fun `an unhardened path component is refused`() { + val master = HdKey.master(ByteArray(32) { 1 }) + assertFailsWith { master.derive("m/44") } + assertFailsWith { master.derive("m/44H/501") } + } + + /** ⚠️ All three hardening notations are accepted, since all three are written. */ + @Test + fun `H and h and apostrophe all mean hardened`() { + val master = HdKey.master(ByteArray(32) { 1 }) + assertEquals(master.derive("m/44H"), master.derive("m/44h")) + assertEquals(master.derive("m/44H"), master.derive("m/44'")) + } + + @Test + fun `a malformed path is refused`() { + val master = HdKey.master(ByteArray(32) { 1 }) + assertFailsWith { master.derive("m/notanumberH") } + assertFailsWith { master.derive("m/99999999999H") } + } + + @Test + fun `a seed outside the standard's range is refused`() { + assertFailsWith { HdKey.master(ByteArray(15)) } + assertFailsWith { HdKey.master(ByteArray(65)) } + } + + /** + * ⚠️ Derivation is a pure function of seed and path. Sounds obvious; it is + * the property a restored wallet depends on entirely. + */ + @Test + fun `the same phrase and path always give the same key`() { + val seed = Bip39.seed(Bip39.phrase(ByteArray(16), words)) + assertEquals( + HdKey.master(seed, "m/44H/501H/0H/0H"), + HdKey.master(seed, "m/44H/501H/0H/0H"), + ) + assertNotEquals( + HdKey.master(seed, "m/44H/501H/0H/0H"), + HdKey.master(seed, "m/44H/501H/1H/0H"), + ) + } + + /** + * The BIP-44 shape the wallet actually uses, end to end from a phrase. + * Not a published vector — the point is that the whole chain runs and that + * successive accounts differ, which is what "a fresh address per receive" + * rests on. + */ + @Test + fun `a phrase walks the wallet path and gives distinct keys per account`() { + val seed = Bip39.seed(Bip39.phrase(ByteArray(32), words)) + val keys = (0..4).map { HdKey.master(seed, "m/44H/501H/${it}H/0H").privateKey } + assertEquals(5, keys.map { hex(it) }.toSet().size, "two accounts derived the same key") + assertTrue(keys.all { it.size == 32 }) + } +}