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:
@@ -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,10 +738,28 @@ 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 {
|
||||
val sent = sendToThread(body, peerUserId, group)
|
||||
if (!sent) {
|
||||
applyReaction(threadId, messageId, userId, previous)
|
||||
return false
|
||||
}
|
||||
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 {
|
||||
// ⚠️ 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 }
|
||||
@@ -750,13 +768,109 @@ class Session(
|
||||
else -> false
|
||||
}
|
||||
}.getOrDefault(false)
|
||||
if (!sent) {
|
||||
applyReaction(threadId, messageId, userId, previous)
|
||||
|
||||
/**
|
||||
* 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 })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user