The Files security lattice in Kotlin (labels, clearance) #76

Merged
paulhitt merged 2 commits from feat/files-label-lattice into main 2026-09-04 20:49:49 +00:00
9 changed files with 1039 additions and 5 deletions
+14 -4
View File
@@ -337,13 +337,13 @@ the messenger rather than staying separate apps.
| Feature | Android |
|---|:--:|
| Local vault: add, organise, delete | ⚪ |
| Bell–LaPadula labels and clearance | ⚪ |
| Bell–LaPadula labels and clearance | 🟡 lattice, encoding and clearance in Kotlin, pinned to independently-computed vectors; the ★-property is unenforced on **both** clients — android#15 |
| Share a file into a chat or group | ⚪ |
| Wallet engine: BIP-39, Base58, SLIP-0010 | ✅ pinned to the published vectors |
| Wallet engine: address derivation and signing | ✅ BIP-44 path, Base58 address, and the engine's own RFC 8032 signs — no API-33 floor |
| Wallet: BIP-39 seed, create and restore | 🟡 engine done — create, import, restore, sealed at rest; no screen yet |
| Wallet: fresh receive address per receive | ⚪ |
| Wallet: balance and transactions from a node | 🟡 `ChainRpc` reads both; no screen yet |
| Wallet: BIP-39 seed, create and restore | ✅ create, import, restore, sealed at rest, with the phrase shown once and explained |
| Wallet: fresh receive address per receive | ✅ counter in the vault; the screen says why the address changes |
| Wallet: balance and transactions from a node | ✅ both, with **unread drawn as an em dash and never as zero** |
⚠️ 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.
@@ -406,6 +406,16 @@ said". And which node somebody points at is a privacy decision the app has to
state: asking a public endpoint for an address's balance ties that address to
this device's IP in somebody's logs.
🔴 **An unread balance is drawn as an em dash, never as 0.00.** Null means this
app could not read the balance; zero means the account is empty. Drawing the
first as the second tells somebody their money is gone — on every flaky
connection, which is exactly when they are most likely to believe it. Same rule
for the activity list: "not read yet" and "nothing yet" are different sentences.
⚠️ Reached from Account, not the tab bar. The bar is four icons by design and
Alerts was taken OUT of it in August for being a fifth; a wallet people open
occasionally does not earn the slot that decision refused.
🔴 **A JSON-RPC refusal arrives with HTTP 200.** A client checking only the
status code carries on with a body that has no result, fails somewhere less
obvious, and throws the node's own explanation away on the route — the
@@ -741,6 +741,29 @@ fun ColumnScope.AccountScreen(
SectionBand()
SettingsSection { StoryAudienceSection(model, phase) }
// ⚠️ Reached from Account rather than the tab bar. The bar is four icons
// by design — the kit's artboard is literally "Bottom Bar/Without Label" —
// and Alerts was taken OUT of it in August for being a fifth. A wallet
// people open occasionally does not earn the slot that decision refused.
SectionBand()
SettingsSection {
Text("Wallet", fontWeight = FontWeight.Medium, fontSize = 16.sp)
Spacer(Modifier.height(Space.xs))
Text(
"A wallet on this phone, with keys only you hold. Not connected to "
+ "your Flamenet account.",
fontSize = 13.sp,
color = Palette.SecondaryText,
)
Spacer(Modifier.height(Space.sm))
KitSecondaryButton(
text = "Open wallet",
onClick = { model.openWallet() },
modifier = Modifier.fillMaxWidth(),
tag = "open-wallet",
)
}
SectionBand()
SettingsSection { AppLockSection() }
@@ -287,6 +287,8 @@ private fun Root(model: MessengerViewModel = viewModel()) {
CallsScreen(model, current)
MessengerViewModel.Phase.Screen.STORIES ->
StoriesFeed(model, current)
MessengerViewModel.Phase.Screen.WALLET ->
WalletScreen(model, current)
MessengerViewModel.Phase.Screen.NOTIFICATIONS ->
NotificationsScreen(model, current)
MessengerViewModel.Phase.Screen.ACCOUNT ->
@@ -3,6 +3,7 @@ package io.flamenet.messenger.app
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import io.flamenet.client.ChainRpc
import io.flamenet.client.DeviceClock
import io.flamenet.client.AccountsClient
import io.flamenet.client.RelayClient
@@ -59,6 +60,8 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
val userId: Long,
val deviceId: String,
val relay: String,
/** What the wallet screen draws. Null until it is opened. */
val wallet: WalletState? = null,
val status: String,
/**
@@ -198,7 +201,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
* entry point — that is where you see there IS something — but a
* card feed cannot live above a contact list without pushing it off.
*/
enum class Screen { CONTACTS, MESSAGES, CALLS, STORIES, NOTIFICATIONS, ACCOUNT }
enum class Screen { CONTACTS, MESSAGES, CALLS, STORIES, NOTIFICATIONS, ACCOUNT, WALLET }
/** What the call UI needs to draw itself. */
data class CallState(
@@ -1132,8 +1135,180 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
}.onFailure { failure -> update { it.copy(busy = false, error = describe(failure)) } }
}
/**
* What the wallet screen draws.
*
* 🔴 **Balance and history are nullable and that is the point.** Null means
* "not asked yet, or the node did not answer" — which the screen renders as
* an em dash, never as zero. A wallet that shows 0.00 when it could not
* reach a node has told somebody their money is gone.
*/
data class WalletState(
val exists: Boolean = false,
val address: String? = null,
/** Lamports, or null when unknown. Never zero as a stand-in. */
val lamports: Long? = null,
val history: List<ChainRpc.Entry>? = null,
/** Shown once, on creation or when deliberately revealed. */
val phrase: String? = null,
val busy: Boolean = false,
val problem: String? = null,
/** Which node this is reading from, so the screen can say. */
val endpoint: String = WalletChain.DEFAULT_ENDPOINT,
)
fun closeChat() = update { it.copy(openChat = null) }
// -- Wallet ---------------------------------------------------------------
private fun wordlist() = io.flamenet.e2e.Bip39.englishWordlist()
private fun walletUpdate(change: (WalletState) -> WalletState) = update {
it.copy(wallet = change(it.wallet ?: WalletState()))
}
/** Read what is stored, without touching the network. */
fun openWallet() = viewModelScope.launch {
showScreen(Phase.Screen.WALLET)
val store = vault ?: return@launch
val entropy = withContext(Dispatchers.IO) {
runCatching { store.walletEntropy() }.getOrNull()
}
if (entropy == null) {
walletUpdate { it.copy(exists = false, endpoint = chainEndpoint()) }
return@launch
}
val address = withContext(Dispatchers.IO) {
runCatching {
io.flamenet.e2e.Wallet.account(entropy, wordlist(), store.walletNextIndex().toLong())
.receive(store.walletNextIndex().toLong()).address
}.getOrNull()
}
walletUpdate {
it.copy(exists = true, address = address, endpoint = chainEndpoint(), problem = null)
}
refreshWallet()
}
private fun chainEndpoint() = WalletChain.endpoint(getApplication())
/**
* Make a wallet, and show the phrase once.
*
* 🔴 **The phrase is shown and not stored anywhere it can be read back
* casually.** It is in the returned state for as long as the screen holds
* it, and the record on disk is the ENTROPY — the words are regenerated
* from it when somebody deliberately asks. Somebody who never writes them
* down and later erases the device has nothing, which is what a recovery
* phrase means and has to be said at this moment rather than afterwards.
*/
fun createWallet() = viewModelScope.launch {
val store = vault ?: return@launch
walletUpdate { it.copy(busy = true, problem = null) }
val made = withContext(Dispatchers.IO) {
runCatching {
val created = io.flamenet.e2e.Wallet.create(wordlist())
store.saveWalletEntropy(created.entropy)
created
}
}
made.onSuccess { created ->
walletUpdate { it.copy(busy = false, exists = true, phrase = created.phrase) }
openWallet()
}.onFailure { failure ->
walletUpdate { it.copy(busy = false, problem = failure.message) }
}
}
/**
* Adopt a phrase typed in from somewhere else.
*
* ⚠️ Validated before anything is stored. A phrase that fails its checksum
* is refused here rather than saved and discovered later, when the wallet
* it names turns out to be empty and nobody can say why.
*/
fun importWallet(phrase: String) = viewModelScope.launch {
val store = vault ?: return@launch
walletUpdate { it.copy(busy = true, problem = null) }
val done = withContext(Dispatchers.IO) {
runCatching { store.saveWalletEntropy(io.flamenet.e2e.Wallet.import(phrase, wordlist())) }
}
done.onSuccess {
walletUpdate { it.copy(busy = false, exists = true) }
openWallet()
}.onFailure { failure ->
walletUpdate { it.copy(busy = false, problem = failure.message) }
}
}
/** Show the words again, for somebody writing them down properly. */
fun revealWalletPhrase() = viewModelScope.launch {
val store = vault ?: return@launch
val phrase = withContext(Dispatchers.IO) {
runCatching {
store.walletEntropy()?.let { io.flamenet.e2e.Wallet.phrase(it, wordlist()) }
}.getOrNull()
}
walletUpdate { it.copy(phrase = phrase) }
}
fun hideWalletPhrase() = walletUpdate { it.copy(phrase = null) }
/**
* Ask the node what the address holds and what has happened to it.
*
* 🔴 **A failure leaves the balance NULL, never zero.** The screen draws an
* em dash for null. Showing 0.00 because a node did not answer tells
* somebody their money is gone, which is the single worst thing this screen
* can say.
*/
fun refreshWallet() = viewModelScope.launch {
val address = _phase.value.let { it as? Phase.SignedIn }?.wallet?.address
?: return@launch
walletUpdate { it.copy(busy = true, problem = null) }
val endpoint = chainEndpoint()
val read = withContext(Dispatchers.IO) {
runCatching {
val rpc = ChainRpc(endpoint)
rpc.balance(address) to rpc.history(address)
}
}
read.onSuccess { (lamports, history) ->
walletUpdate {
it.copy(busy = false, lamports = lamports, history = history, problem = null)
}
}.onFailure { failure ->
walletUpdate {
it.copy(busy = false, lamports = null, history = null,
problem = failure.message ?: "That node did not answer.")
}
}
}
/**
* A fresh address to be paid at.
*
* 🔴 **The counter advances, which is the whole privacy argument.** Reusing
* one address lets anybody watching a public chain group every payment
* somebody ever received. The chain will not forget, so this is the only
* lever there is.
*/
fun nextReceiveAddress() = viewModelScope.launch {
val store = vault ?: return@launch
val next = withContext(Dispatchers.IO) {
runCatching {
val entropy = store.walletEntropy() ?: return@runCatching null
val index = store.walletNextIndex().toLong()
val address = io.flamenet.e2e.Wallet.account(entropy, wordlist(), index)
.receive(index).address
store.saveWalletNextIndex((index + 1).toInt())
address
}.getOrNull()
} ?: return@launch
walletUpdate { it.copy(address = next, lamports = null, history = null) }
refreshWallet()
}
fun showScreen(screen: Phase.Screen) {
// Opening the Calls tab is what clears its missed badge.
if (screen == Phase.Screen.CALLS) markCallsSeen()
@@ -0,0 +1,72 @@
package io.flamenet.messenger.app
import android.content.Context
/**
* Which chain node the wallet reads from.
*
* ⚠️ **Pointing at a node is a privacy decision, and the app says so rather
* than choosing quietly.** Asking a public RPC endpoint what an address holds
* ties that address to this device's IP in somebody else's logs — every time
* the screen refreshes. Running your own node is the only answer that removes
* it. The default exists so the wallet works at all, not because it is private.
*
* ⚠️ **https only.** A balance read over plain http is one anybody on the path
* can rewrite, and a wallet showing a number an attacker chose is worse than a
* wallet showing nothing. A configured http endpoint is ignored rather than
* honoured, and the screen falls back to the default.
*/
object WalletChain {
const val DEFAULT_ENDPOINT = "https://api.mainnet-beta.solana.com"
private const val PREFS = "wallet-chain"
private const val ENDPOINT = "endpoint"
private fun prefs(context: Context) =
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
/** The operator's own node, or null when the default is in use. */
fun custom(context: Context): String? =
prefs(context).getString(ENDPOINT, null)
?.trim()
?.takeIf { it.isNotEmpty() && it.startsWith("https://") }
fun endpoint(context: Context): String = custom(context) ?: DEFAULT_ENDPOINT
fun setEndpoint(context: Context, url: String?) {
val cleaned = url?.trim().orEmpty()
if (cleaned.isEmpty()) prefs(context).edit().remove(ENDPOINT).apply()
else prefs(context).edit().putString(ENDPOINT, cleaned).apply()
}
/** "Solana mainnet", or the host of whatever was configured. */
fun name(context: Context): String {
val custom = custom(context) ?: return "Solana mainnet"
return runCatching { java.net.URI(custom).host }.getOrNull() ?: custom
}
/**
* What a transfer costs, and what must stay behind.
*
* 🔴 **An account below the rent-exempt minimum is reclaimed by the
* network.** Emptying a wallet to the last lamport does not leave it empty;
* it deletes it. Anything offering to send "everything" has to stop here,
* and the two numbers are named rather than buried in an expression so that
* the reason survives.
*/
const val FEE_LAMPORTS = 5_000L
const val RENT_EXEMPT_LAMPORTS = 890_880L
/** What can actually be sent from a balance, or zero. */
fun spendable(lamports: Long): Long =
(lamports - FEE_LAMPORTS - RENT_EXEMPT_LAMPORTS).coerceAtLeast(0)
/** Lamports as SOL, for display only. */
fun sol(lamports: Long): String {
val whole = lamports / 1_000_000_000L
val fraction = lamports % 1_000_000_000L
return "%d.%09d".format(whole, fraction).trimEnd('0').trimEnd('.')
.ifEmpty { "0" }
}
}
@@ -0,0 +1,363 @@
package io.flamenet.messenger.app
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* The wallet.
*
* 🔴 **A balance this app could not read is drawn as an em dash, never as
* zero.** Showing 0.00 because a node did not answer tells somebody their money
* is gone — the single worst sentence this screen can say, and one it would say
* on every flaky connection. Null is a different fact from empty and is drawn
* as one.
*
* ⚠️ **Both cards are always here once a wallet exists**, empty or not. A
* screen that hides the balance until it has one, and hides the history until
* there is one, is a screen that looks broken exactly when somebody is looking
* for reassurance that nothing went wrong.
*/
@Composable
fun ColumnScope.WalletScreen(
model: MessengerViewModel,
phase: MessengerViewModel.Phase.SignedIn,
) {
val wallet = phase.wallet ?: MessengerViewModel.WalletState()
val context = LocalContext.current
val clipboard = LocalClipboardManager.current
Column(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.verticalScroll(rememberScrollState())
.padding(Kit.margin)
.testTag("wallet"),
verticalArrangement = Arrangement.spacedBy(Space.md),
) {
if (!wallet.exists) {
NoWallet(model)
return@Column
}
// 🔴 The phrase card sits ABOVE everything about the chain. Somebody
// who has just made a wallet has one job — write these words down — and
// putting a balance in front of it invites them to skip it and come
// back later, which for most people means never.
wallet.phrase?.let { phrase -> PhraseCard(phrase, model, clipboard) }
BalanceCard(wallet, model, context)
ReceiveCard(wallet, model, clipboard)
ActivityCard(wallet)
wallet.problem?.let {
Text(it, color = MaterialTheme.colorScheme.error, fontSize = 13.sp,
modifier = Modifier.testTag("wallet-problem"))
}
if (wallet.phrase == null) {
KitSecondaryButton(
text = "Show recovery phrase",
onClick = model::revealWalletPhrase,
modifier = Modifier.fillMaxWidth(),
tag = "reveal-phrase",
)
}
}
}
@Composable
private fun ColumnScope.NoWallet(model: MessengerViewModel) {
var phrase by remember { mutableStateOf("") }
Card {
Text("No wallet yet", fontWeight = FontWeight.Medium, fontSize = 16.sp)
Spacer(Modifier.height(Space.xs))
// ⚠️ Says what a wallet here IS before offering to make one. The two
// facts that surprise people later are that it has nothing to do with
// their Flamenet account, and that the phrase is the only backup.
Text(
"A wallet on this phone, with keys only you hold. It is not connected "
+ "to your Flamenet account — nobody can work out one from the other — "
+ "and the recovery phrase is the only way back if this phone is "
+ "erased or lost.",
fontSize = 13.sp,
color = Palette.SecondaryText,
)
Spacer(Modifier.height(Space.md))
KitPrimaryButton(
text = "Create a wallet",
onClick = model::createWallet,
modifier = Modifier.fillMaxWidth(),
tag = "create-wallet",
)
}
Spacer(Modifier.height(Space.md))
Card {
Text("Already have one?", fontWeight = FontWeight.Medium, fontSize = 16.sp)
Spacer(Modifier.height(Space.xs))
OutlinedTextField(
value = phrase,
onValueChange = { phrase = it },
placeholder = { Text("Your 12 or 24 word recovery phrase", fontSize = 13.sp) },
modifier = Modifier.fillMaxWidth().testTag("import-phrase"),
)
Spacer(Modifier.height(Space.sm))
KitSecondaryButton(
text = "Restore from a phrase",
onClick = { model.importWallet(phrase.trim()); phrase = "" },
modifier = Modifier.fillMaxWidth(),
tag = "import-wallet",
)
}
}
/**
* 🔴 **Shown once, and the screen says what that means.** The words are the
* whole wallet: anybody who reads them owns it, and anybody who loses them
* loses it. Both halves have to be said at the same moment, because saying only
* the first makes people hide it and saying only the second makes them
* screenshot it.
*/
@Composable
private fun ColumnScope.PhraseCard(
phrase: String,
model: MessengerViewModel,
clipboard: androidx.compose.ui.platform.ClipboardManager,
) {
Card {
Text("Your recovery phrase", fontWeight = FontWeight.Medium, fontSize = 16.sp)
Spacer(Modifier.height(Space.xs))
Text(
"Write these words down on paper, in order, and keep them somewhere "
+ "safe. Anybody who has them can spend from this wallet. If this "
+ "phone is erased or lost and you do not have them, the wallet is "
+ "gone — nobody can recover it for you, including us.",
fontSize = 13.sp,
color = Palette.SecondaryText,
)
Spacer(Modifier.height(Space.sm))
Text(
phrase,
fontSize = 15.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(Radius.small))
// ⚠️ `Mist`, not `SurfaceAlt`: the card is already SurfaceAlt,
// and two identical greys would leave the words looking like
// body text rather than the one thing on this screen worth
// copying onto paper.
.background(Palette.Mist)
.padding(Space.sm)
.testTag("wallet-phrase"),
)
Spacer(Modifier.height(Space.sm))
Row(horizontalArrangement = Arrangement.spacedBy(Space.sm)) {
KitSecondaryButton(
text = "Copy",
// ⚠️ Copying puts the phrase on a clipboard every other app can
// read. Offered because people will otherwise photograph the
// screen, which is worse and permanent — but it is the second
// option, not the first, and paper is what the text asks for.
onClick = { clipboard.setText(AnnotatedString(phrase)) },
modifier = Modifier.weight(1f),
tag = "copy-phrase",
)
KitSecondaryButton(
text = "I've written it down",
onClick = model::hideWalletPhrase,
modifier = Modifier.weight(1f),
tag = "hide-phrase",
)
}
}
}
@Composable
private fun ColumnScope.BalanceCard(
wallet: MessengerViewModel.WalletState,
model: MessengerViewModel,
context: android.content.Context,
) {
Card {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text("Balance", fontSize = 13.sp, color = Palette.SecondaryText)
Text(WalletChain.name(context), fontSize = 11.sp, color = Palette.SecondaryText)
}
Spacer(Modifier.height(Space.xs))
Text(
// 🔴 An em dash, never "0.00". Null means this app could not read
// the balance; zero means the account is empty. Drawing the first
// as the second tells somebody their money is gone.
wallet.lamports?.let { "${WalletChain.sol(it)} SOL" } ?: "—",
fontSize = 28.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.testTag("wallet-balance"),
)
if (wallet.lamports == null && !wallet.busy) {
Text(
"Not read yet. Nothing has been lost — this is what the app knows, "
+ "not what the wallet holds.",
fontSize = 12.sp,
color = Palette.SecondaryText,
modifier = Modifier.testTag("balance-unknown"),
)
}
Spacer(Modifier.height(Space.sm))
KitSecondaryButton(
text = if (wallet.busy) "Reading…" else "Refresh",
onClick = model::refreshWallet,
modifier = Modifier.fillMaxWidth(),
tag = "refresh-balance",
)
}
}
@Composable
private fun ColumnScope.ReceiveCard(
wallet: MessengerViewModel.WalletState,
model: MessengerViewModel,
clipboard: androidx.compose.ui.platform.ClipboardManager,
) {
Card {
Text("Receive at", fontSize = 13.sp, color = Palette.SecondaryText)
Spacer(Modifier.height(Space.xs))
Text(
wallet.address ?: "—",
fontSize = 13.sp,
modifier = Modifier.fillMaxWidth().testTag("wallet-address"),
)
Spacer(Modifier.height(Space.xs))
// ⚠️ Says the address changes BEFORE somebody saves the old one
// somewhere. A fresh address per payment is the one linkability a
// wallet can prevent, and an address that quietly changed would look
// like a bug to anybody who had written it down.
Text(
"A new address each time you ask, so payments to you are not all "
+ "tied together on a public ledger. Older addresses keep working.",
fontSize = 12.sp,
color = Palette.SecondaryText,
)
Spacer(Modifier.height(Space.sm))
Row(horizontalArrangement = Arrangement.spacedBy(Space.sm)) {
KitSecondaryButton(
text = "Copy",
onClick = { wallet.address?.let { clipboard.setText(AnnotatedString(it)) } },
modifier = Modifier.weight(1f),
tag = "copy-address",
)
KitSecondaryButton(
text = "New address",
onClick = model::nextReceiveAddress,
modifier = Modifier.weight(1f),
tag = "next-address",
)
}
}
}
@Composable
private fun ColumnScope.ActivityCard(wallet: MessengerViewModel.WalletState) {
Card {
Text("Activity", fontSize = 13.sp, color = Palette.SecondaryText)
Spacer(Modifier.height(Space.xs))
val history = wallet.history
when {
// Same rule as the balance: unknown is not the same as none.
history == null -> Text(
"Not read yet.",
fontSize = 13.sp, color = Palette.SecondaryText,
modifier = Modifier.testTag("activity-unknown"),
)
history.isEmpty() -> Text(
"Nothing yet.",
fontSize = 13.sp, color = Palette.SecondaryText,
modifier = Modifier.testTag("activity-empty"),
)
else -> history.forEach { entry -> ActivityRow(entry) }
}
}
}
@Composable
private fun ActivityRow(entry: io.flamenet.client.ChainRpc.Entry) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
entry.signature,
fontSize = 12.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
// ⚠️ "Just now" for a transaction the node has not dated yet.
// It has it and has not assigned a block time; showing 1970
// would be the epoch leaking onto somebody's screen.
entry.blockTimeSeconds?.let {
SimpleDateFormat("d MMM HH:mm", Locale.getDefault())
.format(Date(it * 1000))
} ?: "Just now",
fontSize = 11.sp,
color = Palette.SecondaryText,
)
}
// 🔴 A failed transaction says so. Not showing it would have somebody
// believe a payment went through.
if (entry.failed) {
Text("Failed", fontSize = 12.sp, color = Palette.Pink,
modifier = Modifier.testTag("tx-failed"))
}
}
}
@Composable
private fun ColumnScope.Card(content: @Composable ColumnScope.() -> Unit) {
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(Radius.medium))
.background(Palette.SurfaceAlt)
.padding(Space.md),
content = content,
)
}
@@ -0,0 +1,92 @@
package io.flamenet.messenger.app
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* How the wallet screen states a number.
*
* 🔴 **The rule this suite exists for: an unread balance is NOT zero.** A screen
* that draws 0.00 because a node did not answer has told somebody their money is
* gone — on every flaky connection. The screen renders null as an em dash, and
* these cover the arithmetic underneath it.
*/
class WalletDisplayTest {
// ---- Lamports as SOL -------------------------------------------------
@Test
fun `a whole number of SOL reads without a decimal tail`() {
assertEquals("1", WalletChain.sol(1_000_000_000L))
assertEquals("5", WalletChain.sol(5_000_000_000L))
}
@Test
fun `a fraction keeps its significant digits and drops the padding`() {
assertEquals("0.5", WalletChain.sol(500_000_000L))
assertEquals("1.000000001", WalletChain.sol(1_000_000_001L))
assertEquals("0.000000001", WalletChain.sol(1L))
}
/**
* ⚠️ Zero is a real balance and reads as "0", not as an empty string. The
* trimming that removes a decimal tail must not eat the whole number.
*/
@Test
fun `an empty account reads as zero, not as nothing`() {
assertEquals("0", WalletChain.sol(0L))
}
/**
* ⚠️ Five SOL already exceeds Int.MAX_VALUE in lamports. Anything holding a
* balance in an Int would overflow long before the number got interesting.
*/
@Test
fun `a large balance is not truncated`() {
assertEquals("9223372036.854775807", WalletChain.sol(Long.MAX_VALUE))
}
// ---- What can actually be sent ---------------------------------------
/**
* 🔴 **An account below the rent-exempt minimum is RECLAIMED by the
* network.** Emptying a wallet to the last lamport does not leave it empty,
* it deletes it — so "send everything" has to stop short of everything.
*/
@Test
fun `spendable leaves the fee and the rent-exempt minimum behind`() {
val balance = 2_000_000_000L
assertEquals(
balance - WalletChain.FEE_LAMPORTS - WalletChain.RENT_EXEMPT_LAMPORTS,
WalletChain.spendable(balance),
)
}
/** A balance too small to send anything reports zero, never a negative. */
@Test
fun `a balance below the floor is not negative spendable`() {
assertEquals(0L, WalletChain.spendable(0L))
assertEquals(0L, WalletChain.spendable(1_000L))
assertEquals(0L, WalletChain.spendable(WalletChain.RENT_EXEMPT_LAMPORTS))
}
@Test
fun `the floor is the fee plus the rent minimum, exactly`() {
val floor = WalletChain.FEE_LAMPORTS + WalletChain.RENT_EXEMPT_LAMPORTS
assertEquals(0L, WalletChain.spendable(floor))
assertEquals(1L, WalletChain.spendable(floor + 1))
}
// ---- Which node ------------------------------------------------------
/**
* 🔴 **http is ignored rather than honoured.** A balance read over plain
* http is one anybody on the path can rewrite, and a wallet showing a number
* an attacker chose is worse than a wallet showing nothing.
*/
@Test
fun `the default endpoint is https`() {
assertTrue(WalletChain.DEFAULT_ENDPOINT.startsWith("https://"))
}
}
@@ -0,0 +1,101 @@
package io.flamenet.e2e
/**
* The security lattice. Mirrors §1 of the published Files specification.
*
* 🔴 **The ten-byte encoding is authenticated data for every construction that
* uses it**, so a label that encodes differently on two platforms does not
* produce a warning — it produces a decryption failure, or worse, plaintext
* that quietly belongs to the wrong sensitivity. That is why [bytes] is pinned
* to a vector rather than left to look obviously right.
*
* ⚠️ **Two dimensions, and both are required.** A classification alone is a
* ladder: anything high enough reads anything lower. Compartments cut across
* it — "Secret, and only if you are in this area" — and the interesting failure
* is an implementation that checks the level and forgets the areas, which reads
* as working right up until somebody sees a compartment they are not in.
*/
data class Label(
val classification: Short,
/** Bit positions 0..63. */
val compartments: Set<Int> = emptySet(),
) {
init {
require(compartments.all { it in 0..63 }) { "compartment bits must be 0..63" }
}
val mask: Long
get() = compartments.fold(0L) { acc, bit -> acc or (1L shl bit) }
/**
* `this ⊒ other`: at least as high, AND a superset of areas.
*
* 🔴 Both, always. Dropping the compartment half turns the lattice back
* into a ladder and silently widens every read.
*/
fun dominates(other: Label): Boolean =
classification >= other.classification && compartments.containsAll(other.compartments)
/**
* `int16be classification || uint64be compartment mask`.
*
* ⚠️ Big-endian, and stated because the JVM's own `ByteBuffer` default
* happens to agree while Kotlin's shifts do not care — writing this with
* `toByte()` on an unshifted value would produce little-endian bytes that
* still look plausible in a debugger and decrypt nothing.
*/
fun bytes(): ByteArray {
val out = ByteArray(10)
out[0] = ((classification.toInt() shr 8) and 0xff).toByte()
out[1] = (classification.toInt() and 0xff).toByte()
val m = mask
for (i in 0 until 8) {
out[2 + i] = ((m ushr (56 - i * 8)) and 0xff).toByte()
}
return out
}
companion object {
/** The label everything dominates: unclassified, no areas. */
val bottom = Label(0)
}
}
/**
* What one person is allowed to reach.
*
* 🔴 **Both bounds, always.** A label must sit INSIDE the range, not merely
* below the top of it. The minimum is what stops somebody cleared for sensitive
* work from also holding the unclassified pile — which sounds like a
* convenience to remove until it is the reason an audit can say who could have
* seen what.
*/
data class Clearance(val maximum: Label, val minimum: Label = Label.bottom) {
fun permits(label: Label): Boolean =
maximum.dominates(label) && label.dominates(minimum)
/**
* 🔴 **No read up.** Bell–LaPadula's simple security property: you cannot
* read what is above you. This is the one people expect.
*/
fun mayRead(label: Label): Boolean = permits(label)
/**
* Whether a write to [label] is allowed.
*
* 🔴 **The SAME check as reading, and that is a deliberate match with iOS
* rather than an oversight.** `FilesSession.ensureLabelKey` and `use` both
* gate on `clearance.permits`, so the range is the whole rule on both
* platforms.
*
* ⚠️ **Bell–LaPadula's ★-property is therefore NOT enforced.** The model
* says somebody cleared to read sensitive material must not write *below*
* their clearance, because they would carry what they read down with them.
* Neither client implements that. Adding it on Android alone would mean a
* file that saves on one phone and is refused on the other, with no way for
* either person to tell which rule they had met — a worse outcome than the
* gap. Filed for both so it is decided once: android#15 / ios#65.
*/
fun mayWrite(label: Label): Boolean = permits(label)
}
@@ -0,0 +1,196 @@
package io.flamenet.e2e
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* The security lattice, and the two Bell–LaPadula rules it exists to enforce.
*
* 🔴 **The encoding vectors were computed independently, not read off this
* implementation.** The ten bytes are authenticated data for every construction
* that uses a label, so an encoding that differs between platforms does not warn
* — it produces a decryption failure, or plaintext that quietly belongs to the
* wrong sensitivity. A vector taken from the code under test would pin whatever
* the code happens to do.
*/
class LabelTest {
private fun hex(bytes: ByteArray) = bytes.joinToString("") { "%02x".format(it) }
// ---- The encoding ----------------------------------------------------
/** `int16be classification || uint64be compartment mask`. */
@Test
fun `the ten-byte encoding matches the specification`() {
assertEquals("00000000000000000000", hex(Label(0).bytes()))
assertEquals("00010000000000000000", hex(Label(1).bytes()))
assertEquals("00038000000000000001", hex(Label(3, setOf(0, 63)).bytes()))
assertEquals("01000000000000000100", hex(Label(256, setOf(8)).bytes()))
}
/**
* ⚠️ A negative classification and a full compartment mask together — the
* case where a sign-extension mistake or an `Int` in place of a `Long`
* shows up. Both halves are all-ones, so a wrong shift produces something
* that still looks plausible.
*/
@Test
fun `the extremes encode without sign-extension damage`() {
assertEquals("ffffffffffffffffffff", hex(Label(-1, (0..63).toSet()).bytes()))
}
/**
* 🔴 Bit 63 is the sign bit of a Long. Building the mask with an `Int`
* shift, or reading it back as one, loses it — and the compartment that
* vanishes is the one nobody notices until somebody sees a file they are
* not cleared for.
*/
@Test
fun `the top compartment bit survives`() {
assertEquals("00008000000000000000", hex(Label(0, setOf(63)).bytes()))
assertTrue(Label(0, setOf(63)).mask != 0L)
}
@Test
fun `the encoding is always ten bytes`() {
assertEquals(10, Label(0).bytes().size)
assertEquals(10, Label(-1, (0..63).toSet()).bytes().size)
}
@Test
fun `a compartment outside the range is refused`() {
assertFailsWith<IllegalArgumentException> { Label(0, setOf(64)) }
assertFailsWith<IllegalArgumentException> { Label(0, setOf(-1)) }
}
// ---- Domination ------------------------------------------------------
@Test
fun `a higher classification dominates a lower one`() {
assertTrue(Label(3).dominates(Label(1)))
assertFalse(Label(1).dominates(Label(3)))
assertTrue(Label(2).dominates(Label(2)))
}
/**
* 🔴 **The failure that reads as working.** An implementation that compares
* the classification and forgets the compartments behaves correctly on
* every test built from levels alone — right up until somebody sees a
* compartment they are not in.
*/
@Test
fun `a higher level does NOT dominate a compartment it is not in`() {
val topSecretNoAreas = Label(9)
val secretInArea = Label(5, setOf(2))
assertFalse(
topSecretNoAreas.dominates(secretInArea),
"a higher classification reached into a compartment it does not hold",
)
}
@Test
fun `domination needs a superset of areas, not an overlap`() {
val holder = Label(5, setOf(1, 2))
assertTrue(holder.dominates(Label(5, setOf(1))))
assertTrue(holder.dominates(Label(5, setOf(1, 2))))
assertFalse(holder.dominates(Label(5, setOf(1, 3))),
"an overlapping but incomplete set was treated as domination")
}
/** Two labels can be incomparable — neither dominates the other. */
@Test
fun `disjoint compartments are incomparable in both directions`() {
val a = Label(5, setOf(1))
val b = Label(5, setOf(2))
assertFalse(a.dominates(b))
assertFalse(b.dominates(a))
}
@Test
fun `bottom is dominated by everything`() {
assertTrue(Label(0).dominates(Label.bottom))
assertTrue(Label(9, setOf(3)).dominates(Label.bottom))
}
// ---- Clearance: no read up -------------------------------------------
@Test
fun `a clearance reads what it dominates`() {
val cleared = Clearance(maximum = Label(5, setOf(1, 2)))
assertTrue(cleared.mayRead(Label(3, setOf(1))))
assertTrue(cleared.mayRead(Label(5, setOf(1, 2))))
}
@Test
fun `a clearance cannot read above itself`() {
val cleared = Clearance(maximum = Label(5))
assertFalse(cleared.mayRead(Label(6)))
assertFalse(cleared.mayRead(Label(5, setOf(1))),
"a compartment outside the clearance was readable")
}
/**
* 🔴 **The minimum is a real bound, not decoration.** A label must sit
* INSIDE the range. Somebody cleared for sensitive work is deliberately not
* also holding the unclassified pile — which sounds like a convenience to
* remove until it is the reason an audit can say who could have seen what.
*/
@Test
fun `a clearance with a floor cannot read below it`() {
val cleared = Clearance(maximum = Label(5), minimum = Label(2))
assertTrue(cleared.mayRead(Label(3)))
assertFalse(cleared.mayRead(Label(1)), "a label below the floor was readable")
}
// ---- Writing ---------------------------------------------------------
/**
* 🔴 **Writing is gated by the same range check as reading, and the
* ★-property is NOT enforced — on either platform.**
*
* Bell–LaPadula says somebody cleared to read sensitive material must not
* write *below* their clearance, because they would carry what they read
* down with them. iOS gates `ensureLabelKey` and `use` on
* `clearance.permits` and nothing else, so this matches it deliberately.
*
* Implementing the rule on Android alone would produce a file that saves on
* one phone and is refused on the other, with neither person able to tell
* which rule they had met — worse than the gap itself. Filed as android#15 /
* ios#65 so it is decided once, for both.
*
* ⚠️ This asserts the CURRENT behaviour, including the part that is a gap.
* It should fail loudly on the day somebody implements the ★-property,
* which is the point of writing the absence down rather than leaving it
* implicit.
*/
@Test
fun `writing uses the same range check as reading, star-property not enforced`() {
val cleared = Clearance(maximum = Label(5))
assertTrue(cleared.mayWrite(Label(5)))
assertTrue(
cleared.mayWrite(Label(1)),
"the star-property is now enforced — see android#15, and update the " +
"iOS side and this test together",
)
assertFalse(cleared.mayWrite(Label(8)))
assertFalse(cleared.mayRead(Label(8)))
}
@Test
fun `a floor bounds writing as well as reading`() {
val cleared = Clearance(maximum = Label(5), minimum = Label(2))
assertTrue(cleared.mayWrite(Label(3)))
assertFalse(cleared.mayWrite(Label(1)))
}
@Test
fun `a compartment outside the clearance cannot be written either`() {
val cleared = Clearance(maximum = Label(5, setOf(1)))
assertTrue(cleared.mayWrite(Label(5, setOf(1))))
assertFalse(cleared.mayWrite(Label(5, setOf(1, 2))),
"a compartment outside the clearance was writable")
}
}