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:
Paul Hitt
2026-09-02 17:57:00 -04:00
co-authored by Claude Opus 4.8
parent 1316996ac5
commit 4c3f30e180
9 changed files with 284 additions and 0 deletions
@@ -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)
}
}