Merge branch 'feat/wallet-engine-primitives' into 'main'

Wallet engine primitives in Kotlin: BIP-39, Base58, SLIP-0010

See merge request paulhitt/flamenet-messenger-android!44
This commit was merged in pull request #71.
This commit is contained in:
Paul Hitt
2026-09-04 20:49:32 +00:00
14 changed files with 3673 additions and 28 deletions
+63 -9
View File
@@ -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.
---
@@ -1622,6 +1622,8 @@ fun ColumnScope.ChatScreen(
avatars: Map<Long, io.flamenet.e2e.Attachment.Ref> = 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<io.flamenet.client.RelayClient.Group> = 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<Session.Line?>(null) }
var forwarding by remember(chat.threadId) { mutableStateOf<Session.Line?>(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<io.flamenet.client.MessengerApi.Buddy>,
): 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<io.flamenet.client.MessengerApi.Buddy>,
groups: List<io.flamenet.client.RelayClient.Group>,
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") }
},
)
}
@@ -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
@@ -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
@@ -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()
@@ -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)
}
}
@@ -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<String>? {
/**
* 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<String>? {
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
@@ -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<String>): 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")))
}
}
@@ -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<Char, Int> =
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<Int>()
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<Int>()
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
}
}
@@ -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<String>, 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>): 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<String>()
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<String>): 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<String, Int>(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<String> {
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
}
}
@@ -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)
}
}
}
@@ -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"
File diff suppressed because it is too large Load Diff
@@ -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.Failure.WordlistDigestMismatch> { Bip39.verify(tampered) }
assertFailsWith<Bip39.Failure.WordlistWrongLength> { 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.Failure.UnknownWord> { 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.Failure.ChecksumFailed> { 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.Failure.BadPhraseLength> { Bip39.entropy(short, words) }
}
@Test
fun `entropy of a size the standard does not define is refused`() {
assertFailsWith<Bip39.Failure.BadEntropySize> { Bip39.phrase(ByteArray(15), words) }
assertFailsWith<Bip39.Failure.BadEntropySize> { 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<HdKey.Failure.Unhardened> { master.derive("m/44") }
assertFailsWith<HdKey.Failure.Unhardened> { 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<HdKey.Failure.MalformedPath> { master.derive("m/notanumberH") }
assertFailsWith<HdKey.Failure.MalformedPath> { master.derive("m/99999999999H") }
}
@Test
fun `a seed outside the standard's range is refused`() {
assertFailsWith<HdKey.Failure.SeedOutOfRange> { HdKey.master(ByteArray(15)) }
assertFailsWith<HdKey.Failure.SeedOutOfRange> { 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 })
}
}