The Files security lattice in Kotlin (labels, clearance) #76
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user