Count the sealed-to-unsealed fallback, and offer to refuse it
Android half of ios#16. `Sent.sealed` already reported each send's outcome, but nothing accumulated, so nobody could answer "how often does this happen to me". 🔴 The copy distinguishes sender from content, with tests asserting it. An unsealed send is still end-to-end encrypted; what the relay learns is who sent it. Somebody who reads "sealed" as "encrypted" makes the wrong decision in both directions. ⚠️ Aggregate only. Per-contact counts would be more useful and would also build a record of who this account talks to and how often — the precise thing sealing exists to keep off a server. Refusing is offered and off by default: it turns a metadata leak into an undelivered message, which for most people is the worse outcome. It has its own exception type so the refusal can explain itself. ⚠️ Refusing spends nothing. `sealed` is false exactly when neither a capability nor a key was found, so `takePeerToken` returned null and there is no token to hand back — my first cut had a line handing one back, which was unreachable. ⚠️ The policy and the counter are injected as lambdas, since `client` and the engine have no Android dependency, and both are wired in one helper. Two sign-in paths build a session, and only one of them applying the policy is how a setting silently stops working on half the app. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
1316996ac5
commit
4c3f30e180
@@ -765,3 +765,24 @@ platform asserting the lists are equal — a reason the relay rejects is a repor
|
||||
somebody believed they had filed. CSAM is its own reason on both rather than
|
||||
folded into abuse: it carries a different obligation, and an operator cannot
|
||||
triage what nobody can name.
|
||||
## Sealed-to-unsealed fallback (§14.5, ios#16)
|
||||
Sealing hides which account sent a message from the relay. When the peer's
|
||||
delivery key or capability is missing, the send still goes — end-to-end
|
||||
encrypted as always — but unsealed, and the relay learns the sender. Both
|
||||
platforms now count every outcome and surface the share; both offer refusing
|
||||
rather than degrading, **off by default**.
|
||||
🔴 **The copy distinguishes sender from content on both, with tests asserting
|
||||
it.** Somebody who reads "sealed" as "encrypted" makes the wrong decision in
|
||||
both directions — relaxed when it is off, and alarmed when it degrades on a
|
||||
message the server could never read anyway.
|
||||
⚠️ **Aggregate counts only.** Per-contact counts would be more useful and would
|
||||
also build a record of who this account talks to and how often — the precise
|
||||
thing sealing exists to keep off a server, kept instead on the device.
|
||||
⚠️ **Zero sends reports as "nothing sent yet"**, never a reassuring 0%.
|
||||
⚠️ **Refusing costs nothing that was already spent.** On both platforms the
|
||||
gate sits where `sealed` is false exactly when no capability was taken, so
|
||||
refusing cannot burn a token per attempt and drain the contact's batch.
|
||||
⚠️ Android injects the policy and the counter into `Conversations`/`Session` as
|
||||
lambdas — those modules have no Android dependency — and wires them in ONE
|
||||
helper, because two sign-in paths building a session and only one applying the
|
||||
policy is how a setting silently stops working on half the app.
|
||||
|
||||
@@ -737,6 +737,8 @@ fun ColumnScope.AccountScreen(
|
||||
SettingsSection { PresencePicker(model, phase.presence) }
|
||||
|
||||
SectionBand()
|
||||
// ios#16 — beside the other privacy settings.
|
||||
SettingsSection { SealingSection(model) }
|
||||
SettingsSection { StoryAudienceSection(model, phase) }
|
||||
// ios#54 — beside the audience setting, because both answer "what happens
|
||||
// to my stories".
|
||||
@@ -1174,6 +1176,45 @@ fun ColumnScope.StoryArchiveSection(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The fallback from sealed to unsealed, which used to happen silently (ios#16).
|
||||
*
|
||||
* 🔴 Names exactly what sealing protects, and what it does not. Somebody who
|
||||
* reads "sealed" as "encrypted" makes the wrong decision here in both
|
||||
* directions — relaxed when it is off, and alarmed when it degrades on a
|
||||
* message that was never readable by the server anyway.
|
||||
*/
|
||||
@Composable
|
||||
fun ColumnScope.SealingSection(model: MessengerViewModel) {
|
||||
val context = LocalContext.current
|
||||
val settings = remember { MediaSettings(context) }
|
||||
var refuse by remember { mutableStateOf(settings.requireSealedSending) }
|
||||
val record = remember(refuse) { settings.sealingRecord }
|
||||
|
||||
Text("Who the server can see is sending", style = MaterialTheme.typography.titleMedium)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = Space.sm),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text("Refuse to send if it can't be sealed", modifier = Modifier.weight(1f))
|
||||
androidx.compose.material3.Switch(
|
||||
checked = refuse,
|
||||
onCheckedChange = { settings.requireSealedSending = it; refuse = it },
|
||||
modifier = Modifier.testTag("require-sealed"),
|
||||
)
|
||||
}
|
||||
Text(record.summary, fontSize = 12.sp, color = Palette.SecondaryText)
|
||||
Spacer(Modifier.size(Space.sm))
|
||||
Text(
|
||||
"Your messages are always end-to-end encrypted. Sealing hides something " +
|
||||
"further: which account sent them. It needs a key your contact's device " +
|
||||
"supplies, so it can't always be done — usually before they have replied " +
|
||||
"to you for the first time. Refusing means those messages don't go at all.",
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ColumnScope.StoryAudienceSection(
|
||||
model: MessengerViewModel,
|
||||
|
||||
@@ -61,6 +61,33 @@ class MediaSettings(context: Context) {
|
||||
get() = prefs.getBoolean("stories.archiveEnabled", false)
|
||||
set(value) { prefs.edit().putBoolean("stories.archiveEnabled", value).apply() }
|
||||
|
||||
/**
|
||||
* Refuse to send at all when the send cannot be sealed (§14.5, ios#16).
|
||||
*
|
||||
* 🔴 **Off by default, and that is the honest default rather than the lazy
|
||||
* one.** An unsealed send is still end-to-end encrypted; what leaks is that
|
||||
* this account sent something to that one. Refusing turns a metadata leak
|
||||
* into an undelivered message, which for most people is the worse outcome
|
||||
* — so the default degrades and SAYS SO.
|
||||
*/
|
||||
var requireSealedSending: Boolean
|
||||
get() = prefs.getBoolean("privacy.requireSealed", false)
|
||||
set(value) { prefs.edit().putBoolean("privacy.requireSealed", value).apply() }
|
||||
|
||||
/** Counters for how often sealing degraded (ios#16). */
|
||||
var sealingRecord: io.flamenet.e2e.SealingRecord
|
||||
get() = io.flamenet.e2e.SealingRecord(
|
||||
sealed = prefs.getInt("privacy.sealing.sealed", 0),
|
||||
unsealed = prefs.getInt("privacy.sealing.unsealed", 0),
|
||||
lastUnsealedAt = prefs.getLong("privacy.sealing.lastUnsealedAt", 0L))
|
||||
set(value) {
|
||||
prefs.edit()
|
||||
.putInt("privacy.sealing.sealed", value.sealed)
|
||||
.putInt("privacy.sealing.unsealed", value.unsealed)
|
||||
.putLong("privacy.sealing.lastUnsealedAt", value.lastUnsealedAt)
|
||||
.apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse to send unless the relay can prove the recipient's keys appear in
|
||||
* the log it publishes.
|
||||
|
||||
@@ -370,6 +370,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
||||
it.archiveEnabled = {
|
||||
MediaSettings(getApplication()).storyArchiveEnabled
|
||||
}
|
||||
wireSealingPolicy(it)
|
||||
}
|
||||
val fetchedBuddies = runCatching { session!!.roster() }
|
||||
val fetchedGroups = runCatching { session!!.namedGroups() }
|
||||
@@ -582,6 +583,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
||||
api = io.flamenet.client.MessengerApi(config.baseUrl, account.username, credential),
|
||||
).also {
|
||||
it.archiveEnabled = { MediaSettings(getApplication()).storyArchiveEnabled }
|
||||
wireSealingPolicy(it)
|
||||
}
|
||||
|
||||
// 🔴 **Our return address, before the first federated send.** A relay
|
||||
@@ -2125,6 +2127,21 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
||||
update { it.copy(error = message) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire the sealing policy and counters into a fresh session (ios#16).
|
||||
*
|
||||
* ⚠️ In one place, because two call sites building a `Session` and only
|
||||
* one of them applying the policy is how a setting silently stops working
|
||||
* on one of the two sign-in paths.
|
||||
*/
|
||||
private fun wireSealingPolicy(session: Session) {
|
||||
val settings = MediaSettings(getApplication())
|
||||
session.conversations.requireSealed = { settings.requireSealedSending }
|
||||
session.onSendSealed = { wasSealed, at ->
|
||||
settings.sealingRecord = settings.sealingRecord.recording(wasSealed, at)
|
||||
}
|
||||
}
|
||||
|
||||
/** Change who may see this account's stories. */
|
||||
fun setStoryAudience(audience: Session.StoryAudience) = viewModelScope.launch {
|
||||
val current = session ?: return@launch
|
||||
|
||||
@@ -360,6 +360,11 @@ class Session(
|
||||
// 🔴 The sender's own copy expires too. A timer that only cleared the
|
||||
// recipient's phone would be the opposite of the promise: the person
|
||||
// who set it would be the one still holding the message.
|
||||
|
||||
// ios#16 — every send's outcome, not just this one's. `Sent.sealed`
|
||||
// answers "was that sealed"; the record answers "how often does this
|
||||
// happen to me", which nobody could ask before.
|
||||
onSendSealed(sent.sealed, at)
|
||||
record(Vault.threadForUser(peerUserId), text, mine = true, at = at,
|
||||
link = link, lifetimeSeconds = lifetimeSeconds,
|
||||
delivery = if (sent.reachedSomeone) Delivery.DELIVERED else Delivery.PENDING)
|
||||
@@ -1228,6 +1233,14 @@ class Session(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Told after every 1:1 send whether it was sealed (ios#16).
|
||||
*
|
||||
* ⚠️ Injected: the counters live in Android preferences and this layer has
|
||||
* no Android dependency.
|
||||
*/
|
||||
var onSendSealed: (Boolean, Long) -> Unit = { _, _ -> }
|
||||
|
||||
/**
|
||||
* Members of a group we are in, or null if the relay does not list it for
|
||||
* us (audit H1). Cached for a short window: a poll pass can carry many
|
||||
|
||||
@@ -164,6 +164,15 @@ class Conversations(
|
||||
}
|
||||
|
||||
/** What happened to one send. Partial success is the normal case, not an error. */
|
||||
/**
|
||||
* Whether this device refuses to send when it cannot seal (ios#16).
|
||||
*
|
||||
* ⚠️ Injected rather than read from preferences here: this module has no
|
||||
* Android dependency. An unset value must read as FALSE — degrade and say
|
||||
* so, rather than silently refusing to send on a fresh install.
|
||||
*/
|
||||
var requireSealed: () -> Boolean = { false }
|
||||
|
||||
data class Sent(
|
||||
val delivered: List<String>,
|
||||
val failed: Map<String, String>,
|
||||
@@ -685,6 +694,14 @@ class Conversations(
|
||||
// cannot tell it from a photo (§14.4).
|
||||
val sealed = peerToken != null || peerKey != null
|
||||
|
||||
// ios#16 — refuse rather than degrade, for the people who would rather
|
||||
// a message did not go than go attributable.
|
||||
//
|
||||
// ⚠️ Nothing is spent by refusing here: `sealed` is false exactly when
|
||||
// neither a capability nor a key was found, so `takePeerToken` above
|
||||
// returned null and there is no token to hand back.
|
||||
if (!sealed && requireSealed()) throw RelayException.SealingRequired()
|
||||
|
||||
val preludes = listOfNotNull(
|
||||
ourDeliveryKeyEnvelopeFor(toUserId),
|
||||
ourKeyLogHeadEnvelopeFor(toUserId),
|
||||
|
||||
@@ -90,6 +90,20 @@ sealed class RelayException(message: String, cause: Throwable? = null) : Excepti
|
||||
|
||||
/** A 2xx whose body was not what the route promised. */
|
||||
class Malformed(message: String) : RelayException(message)
|
||||
|
||||
/**
|
||||
* Sealing was required and this send could not be sealed (§14.5, ios#16).
|
||||
*
|
||||
* ⚠️ Its own type rather than a generic failure. The user turned this on
|
||||
* deliberately and the message did not go for a reason they chose — a bare
|
||||
* "couldn't send" reads as the app being broken, and the next thing
|
||||
* somebody does is switch the setting off without knowing that is what
|
||||
* they are doing.
|
||||
*/
|
||||
class SealingRequired : RelayException(
|
||||
"Not sent. This message could not be sealed, so the server would have seen " +
|
||||
"that you sent it — and you asked Flamenet to refuse rather than send " +
|
||||
"anyway. It will seal again once they message you.")
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package io.flamenet.e2e
|
||||
|
||||
/**
|
||||
* How often sending falls back from sealed to unsealed (§14.5, ios#16).
|
||||
*
|
||||
* 🔴 **The fallback is the thing worth counting.** Sealing hides WHO SENT a
|
||||
* message from the relay. When the peer's delivery key or capability is missing
|
||||
* the send still goes — end-to-end encrypted as always — but unsealed, so the
|
||||
* relay learns the sender. The outcome was reported per send and nothing
|
||||
* accumulated, so nobody could answer "how often does this happen to me".
|
||||
*
|
||||
* ⚠️ **Aggregate only, deliberately.** Counting per contact would be more
|
||||
* useful and would also build a record of who this account talks to and how
|
||||
* often — the precise thing sealing exists to keep off a server, kept instead
|
||||
* on the device. Totals answer the question without producing a social graph as
|
||||
* a side effect.
|
||||
*/
|
||||
data class SealingRecord(
|
||||
/** Sends where the relay could not see who sent it. */
|
||||
val sealed: Int = 0,
|
||||
/** Sends where it could. */
|
||||
val unsealed: Int = 0,
|
||||
/** When the last unsealed send happened, unix seconds. 0 = never. */
|
||||
val lastUnsealedAt: Long = 0,
|
||||
) {
|
||||
val total: Int get() = sealed + unsealed
|
||||
|
||||
/**
|
||||
* What share of sends the relay could attribute, 0..1.
|
||||
*
|
||||
* ⚠️ Zero sends is not "nothing leaked", it is "nothing sent". Returning
|
||||
* 0.0 would render as a reassuring 0% on a fresh install.
|
||||
*/
|
||||
val unsealedShare: Double? get() =
|
||||
if (total == 0) null else unsealed.toDouble() / total.toDouble()
|
||||
|
||||
/**
|
||||
* The sentence shown to the user.
|
||||
*
|
||||
* 🔴 **Says what is exposed, not that the message was readable.** An
|
||||
* unsealed send is still end-to-end encrypted: the relay learns who sent
|
||||
* it, not what it said. Copy that blurred the two would either frighten
|
||||
* people about nothing or, worse, teach them that "sealed" means
|
||||
* "encrypted" and leave them relaxed when it is off.
|
||||
*/
|
||||
val summary: String get() {
|
||||
val share = unsealedShare ?: return "Nothing sent from this device yet."
|
||||
if (unsealed == 0) {
|
||||
return "All $sealed messages sent from this device were sealed — " +
|
||||
"the server was never told who sent them."
|
||||
}
|
||||
val percent = Math.round(share * 100).toInt()
|
||||
return "$unsealed of $total messages sent from this device ($percent%) went " +
|
||||
"unsealed. Those were still end-to-end encrypted — the server could see " +
|
||||
"who sent them, not what they said."
|
||||
}
|
||||
|
||||
/** Fold one send's outcome in. */
|
||||
fun recording(wasSealed: Boolean, at: Long): SealingRecord =
|
||||
if (wasSealed) copy(sealed = sealed + 1)
|
||||
// ⚠️ A sealed send must not clear the stamp: "when did this last
|
||||
// happen" is the question, and one good send afterwards does not
|
||||
// unhappen it.
|
||||
else copy(unsealed = unsealed + 1, lastUnsealedAt = at)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package io.flamenet.e2e
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/** Counting the sealed-to-unsealed fallback (§14.5, ios#16). */
|
||||
class SealingRecordTest {
|
||||
|
||||
@Test
|
||||
fun `a sealed send is counted`() {
|
||||
val r = SealingRecord().recording(wasSealed = true, at = 5)
|
||||
assertEquals(1, r.sealed)
|
||||
assertEquals(0, r.unsealed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unsealed send is counted and stamped`() {
|
||||
val r = SealingRecord().recording(wasSealed = false, at = 1_700_000_000)
|
||||
assertEquals(1, r.unsealed)
|
||||
assertEquals(1_700_000_000, r.lastUnsealedAt)
|
||||
}
|
||||
|
||||
/** ⚠️ One good send afterwards does not unhappen the last bad one. */
|
||||
@Test
|
||||
fun `a sealed send does not clear the last unsealed stamp`() {
|
||||
val r = SealingRecord()
|
||||
.recording(wasSealed = false, at = 100)
|
||||
.recording(wasSealed = true, at = 200)
|
||||
assertEquals(100, r.lastUnsealedAt)
|
||||
}
|
||||
|
||||
/** 🔴 Zero sends is no data, not zero leakage. */
|
||||
@Test
|
||||
fun `no sends has no share rather than zero`() {
|
||||
assertNull(SealingRecord().unsealedShare)
|
||||
assertTrue(SealingRecord().summary.contains("Nothing sent"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the share is what went unsealed`() {
|
||||
var r = SealingRecord()
|
||||
repeat(3) { r = r.recording(wasSealed = true, at = 1) }
|
||||
r = r.recording(wasSealed = false, at = 1)
|
||||
assertEquals(0.25, r.unsealedShare!!, 0.0001)
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 **Must not say the message was readable.** An unsealed send is still
|
||||
* end-to-end encrypted; the relay learns the sender, not the content.
|
||||
*/
|
||||
@Test
|
||||
fun `the summary distinguishes sender from content`() {
|
||||
val summary = SealingRecord().recording(wasSealed = false, at = 1).summary
|
||||
assertTrue(summary.contains("end-to-end encrypted"), summary)
|
||||
assertTrue(summary.contains("who sent them"), summary)
|
||||
assertTrue(summary.contains("not what they said"), summary)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a clean record says so without overclaiming`() {
|
||||
var r = SealingRecord()
|
||||
repeat(5) { r = r.recording(wasSealed = true, at = 1) }
|
||||
assertTrue(r.summary.contains("were sealed"), r.summary)
|
||||
assertFalse(r.summary.contains("%"), r.summary)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user