From d915001dcce1b0b19141fce58043714ad062c201 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Fri, 4 Sep 2026 14:48:56 -0400 Subject: [PATCH 1/2] The wallet screen (Android) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Create a wallet, restore one from a phrase, see the balance and the history, and take a fresh address to be paid at. πŸ”΄ An unread balance is 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 β€” on every flaky connection, which is exactly when they are most likely to believe it. The same rule governs the activity list: "not read yet" and "nothing yet" are different sentences and the screen says whichever is true. Both cards are always present once a wallet exists, because a screen that hides them until it has something looks broken at the moment somebody wants reassurance nothing went wrong. πŸ”΄ 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. The card says both halves at once: anybody who has these words owns the wallet, and anybody who loses them loses it. Saying only the first makes people hide it; saying only the second makes them screenshot it. ⚠️ Copy is offered for the phrase and is the SECOND option, not the first. It puts the words on a clipboard every other app can read β€” offered anyway because people who are not given it photograph the screen, which is worse and permanent. The text asks for paper. ⚠️ The receive card says the address changes BEFORE somebody writes one down. A fresh address per payment is the one linkability a wallet can prevent, and an address that silently changed would read as a bug to anybody who had saved it. Older addresses keep working, and the card says that too. ⚠️ Reached from Account, not the tab bar. The bar is four icons by design β€” the kit's artboard is "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. πŸ”΄ An account below the rent-exempt minimum is RECLAIMED by the network, so `spendable` holds back the fee and that minimum: emptying a wallet to the last lamport does not leave it empty, it deletes it. Named constants rather than an expression, so the reason survives. ⚠️ http endpoints are 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 one showing nothing. 8 display tests. App builds, every JVM suite green. Three PARITY wallet rows are now βœ…. Co-Authored-By: Claude Opus 5 --- PARITY.md | 16 +- .../io/flamenet/messenger/app/ChatScreens.kt | 23 ++ .../io/flamenet/messenger/app/MainActivity.kt | 2 + .../messenger/app/MessengerViewModel.kt | 177 ++++++++- .../io/flamenet/messenger/app/WalletChain.kt | 72 ++++ .../io/flamenet/messenger/app/WalletScreen.kt | 363 ++++++++++++++++++ .../messenger/app/WalletDisplayTest.kt | 92 +++++ 7 files changed, 741 insertions(+), 4 deletions(-) create mode 100644 app/src/main/kotlin/io/flamenet/messenger/app/WalletChain.kt create mode 100644 app/src/main/kotlin/io/flamenet/messenger/app/WalletScreen.kt create mode 100644 app/src/test/kotlin/io/flamenet/messenger/app/WalletDisplayTest.kt diff --git a/PARITY.md b/PARITY.md index 315f27f..84c9ebe 100644 --- a/PARITY.md +++ b/PARITY.md @@ -341,9 +341,9 @@ the messenger rather than staying separate apps. | 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 diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt index 81cd221..e8b7c28 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt @@ -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() } diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt index b0e1488..203bf25 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt @@ -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 -> diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt index 85c5b4e..a98be2d 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt @@ -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? = 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() diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/WalletChain.kt b/app/src/main/kotlin/io/flamenet/messenger/app/WalletChain.kt new file mode 100644 index 0000000..e22bbbf --- /dev/null +++ b/app/src/main/kotlin/io/flamenet/messenger/app/WalletChain.kt @@ -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" } + } +} diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/WalletScreen.kt b/app/src/main/kotlin/io/flamenet/messenger/app/WalletScreen.kt new file mode 100644 index 0000000..40ae9c6 --- /dev/null +++ b/app/src/main/kotlin/io/flamenet/messenger/app/WalletScreen.kt @@ -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, + ) +} diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/WalletDisplayTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/WalletDisplayTest.kt new file mode 100644 index 0000000..0f468c1 --- /dev/null +++ b/app/src/test/kotlin/io/flamenet/messenger/app/WalletDisplayTest.kt @@ -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://")) + } +} -- 2.54.0 From 6a0dce6d4e5e87cfa8f4be456f3ab7c8d7c27c93 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Fri, 4 Sep 2026 14:53:26 -0400 Subject: [PATCH 2/2] The Files security lattice in Kotlin (labels, clearance) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Label` and `Clearance`: the two-dimensional lattice Files is built on, and the ten-byte encoding that is authenticated data for every construction using it. πŸ”΄ The encoding vectors were computed independently, not read off this implementation. A label that encodes differently on two 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. πŸ”΄ Two dimensions, and the tests are shaped around the failure that READS AS WORKING: an implementation comparing classification and forgetting compartments behaves correctly on every test built from levels alone, right up until somebody sees a compartment they are not in. Domination needs a superset of areas, not an overlap; two labels can be incomparable in both directions. ⚠️ Bit 63 is the sign bit of a Long. Building the mask with an Int shift loses it, and the compartment that vanishes is the one nobody notices until somebody sees a file they are not cleared for. Pinned, along with the all-ones case where a sign-extension mistake still produces plausible-looking bytes. πŸ”΄ **A finding, and a divergence I deliberately did NOT create.** Files implements the simple security property (no read up) and NOT the β˜…-property (no write down) β€” on either client. iOS gates `ensureLabelKey` and `use` on `clearance.permits` and nothing else, so Kotlin's `mayWrite` calls the same check, with a comment saying why and a test asserting the current behaviour that will fail loudly the day it changes. Implementing it on Android alone would mean 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, because an apparent bug gets worked around. Filed as android#15 / ios#65 with the trade written out: no-write-down is the half a lattice actually exists for, and it is also the half that is awkward enough to need a stated downgrade path rather than a bare refusal. It interacts with sharing a file into a chat β€” a write to a place with no label at all β€” so it needs an answer before that ships. 16 tests. App builds, every JVM suite green. Co-Authored-By: Claude Opus 5 --- PARITY.md | 2 +- .../src/main/kotlin/io/flamenet/e2e/Label.kt | 101 +++++++++ .../test/kotlin/io/flamenet/e2e/LabelTest.kt | 196 ++++++++++++++++++ 3 files changed, 298 insertions(+), 1 deletion(-) create mode 100644 engine/src/main/kotlin/io/flamenet/e2e/Label.kt create mode 100644 engine/src/test/kotlin/io/flamenet/e2e/LabelTest.kt diff --git a/PARITY.md b/PARITY.md index 84c9ebe..c113e40 100644 --- a/PARITY.md +++ b/PARITY.md @@ -337,7 +337,7 @@ 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 | diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Label.kt b/engine/src/main/kotlin/io/flamenet/e2e/Label.kt new file mode 100644 index 0000000..2068b07 --- /dev/null +++ b/engine/src/main/kotlin/io/flamenet/e2e/Label.kt @@ -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 = 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) +} diff --git a/engine/src/test/kotlin/io/flamenet/e2e/LabelTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/LabelTest.kt new file mode 100644 index 0000000..838bcd8 --- /dev/null +++ b/engine/src/test/kotlin/io/flamenet/e2e/LabelTest.kt @@ -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 { Label(0, setOf(64)) } + assertFailsWith { 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") + } +} -- 2.54.0