Count the sealed-to-unsealed fallback, and offer to refuse it (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, and the refusal is there for the people who would rather a message did not go than go attributable. The counters answer a question nobody could ask before. `Sent.sealed` says whether one send was sealed; the record says how often this happens to you, which is what somebody actually needs to decide whether the switch is worth throwing. ⚠️ Refusing costs nothing: `sealed` is false exactly when neither a capability nor a key was found, so there is no token to hand back. ⚠️ Both the policy and the counters are wired in ONE helper called from both sign-in paths. Two call sites building a `Session` and only one applying the policy is how a setting silently stops working on one of them. Taken by hand rather than cherry-picked — the original also rewrote a ChatScreens that has since changed underneath it. The settings switch for it is UI work still to come.
This commit is contained in:
@@ -104,4 +104,31 @@ class MediaSettings(context: Context) {
|
|||||||
val STORY_LIFETIME_CHOICES = listOf(1, 6, 24, 72, 168)
|
val STORY_LIFETIME_CHOICES = listOf(1, 6, 24, 72, 168)
|
||||||
private const val LINK_PREVIEWS = "link_previews"
|
private const val LINK_PREVIEWS = "link_previews"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -361,6 +361,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
api = MessengerApi(
|
api = MessengerApi(
|
||||||
credential.relayBaseUrl, credential.username, credential.appPassword),
|
credential.relayBaseUrl, credential.username, credential.appPassword),
|
||||||
)
|
)
|
||||||
|
session?.let { wireSealingPolicy(it) }
|
||||||
val fetchedBuddies = runCatching { session!!.roster() }
|
val fetchedBuddies = runCatching { session!!.roster() }
|
||||||
val fetchedGroups = runCatching { session!!.namedGroups() }
|
val fetchedGroups = runCatching { session!!.namedGroups() }
|
||||||
stored to Phase.SignedIn(
|
stored to Phase.SignedIn(
|
||||||
@@ -571,6 +572,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
relay = relay,
|
relay = relay,
|
||||||
api = io.flamenet.client.MessengerApi(config.baseUrl, account.username, credential),
|
api = io.flamenet.client.MessengerApi(config.baseUrl, account.username, credential),
|
||||||
)
|
)
|
||||||
|
session?.let { wireSealingPolicy(it) }
|
||||||
|
|
||||||
// 🔴 **Our return address, before the first federated send.** A relay
|
// 🔴 **Our return address, before the first federated send.** A relay
|
||||||
// that accepts a federated envelope records no sender, and our user id
|
// that accepts a federated envelope records no sender, and our user id
|
||||||
@@ -2052,6 +2054,21 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The reasons a report may give (§6.25).
|
* The reasons a report may give (§6.25).
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -384,6 +384,10 @@ class Session(
|
|||||||
record(Vault.threadForUser(peerUserId), text, mine = true, at = at,
|
record(Vault.threadForUser(peerUserId), text, mine = true, at = at,
|
||||||
link = link, messageId = mid, lifetimeSeconds = lifetimeSeconds,
|
link = link, messageId = mid, lifetimeSeconds = lifetimeSeconds,
|
||||||
delivery = if (sent.reachedSomeone) Delivery.DELIVERED else Delivery.PENDING)
|
delivery = if (sent.reachedSomeone) Delivery.DELIVERED else Delivery.PENDING)
|
||||||
|
// 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)
|
||||||
// 🔴 Queued, not abandoned. A friend who is simply signed out should
|
// 🔴 Queued, not abandoned. A friend who is simply signed out should
|
||||||
// not cost the sender a reminder to try again later.
|
// not cost the sender a reminder to try again later.
|
||||||
//
|
//
|
||||||
@@ -1158,6 +1162,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 = { _, _ -> }
|
||||||
|
|
||||||
fun reactionsFor(storyKey: String): Map<Long, String> = storyReactions[storyKey].orEmpty()
|
fun reactionsFor(storyKey: String): Map<Long, String> = storyReactions[storyKey].orEmpty()
|
||||||
|
|
||||||
fun myReactionTo(storyKey: String): String? = storyReactions[storyKey]?.get(userId)
|
fun myReactionTo(storyKey: String): String? = storyReactions[storyKey]?.get(userId)
|
||||||
|
|||||||
@@ -212,6 +212,15 @@ class Conversations(
|
|||||||
* at the sender as an incoming one. The sender's own copy is written
|
* at the sender as an incoming one. The sender's own copy is written
|
||||||
* locally by the caller.
|
* locally by the caller.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* 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 }
|
||||||
|
|
||||||
fun sendToGroup(group: RelayClient.Group, text: String, myUserId: Long,
|
fun sendToGroup(group: RelayClient.Group, text: String, myUserId: Long,
|
||||||
mentions: List<Int> = emptyList(),
|
mentions: List<Int> = emptyList(),
|
||||||
/**
|
/**
|
||||||
@@ -737,6 +746,14 @@ class Conversations(
|
|||||||
// cannot tell it from a photo (§14.4).
|
// cannot tell it from a photo (§14.4).
|
||||||
val sealed = peerToken != null || peerKey != null
|
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(
|
val preludes = listOfNotNull(
|
||||||
ourDeliveryKeyEnvelopeFor(toUserId),
|
ourDeliveryKeyEnvelopeFor(toUserId),
|
||||||
ourKeyLogHeadEnvelopeFor(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. */
|
/** A 2xx whose body was not what the route promised. */
|
||||||
class Malformed(message: String) : RelayException(message)
|
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