diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MediaSettings.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MediaSettings.kt index 9a338a7..a57b87c 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MediaSettings.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MediaSettings.kt @@ -104,4 +104,31 @@ class MediaSettings(context: Context) { val STORY_LIFETIME_CHOICES = listOf(1, 6, 24, 72, 168) 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() + } } 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 24f2010..6d7e879 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt @@ -361,6 +361,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { api = MessengerApi( credential.relayBaseUrl, credential.username, credential.appPassword), ) + session?.let { wireSealingPolicy(it) } val fetchedBuddies = runCatching { session!!.roster() } val fetchedGroups = runCatching { session!!.namedGroups() } stored to Phase.SignedIn( @@ -571,6 +572,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { relay = relay, api = io.flamenet.client.MessengerApi(config.baseUrl, account.username, credential), ) + session?.let { wireSealingPolicy(it) } // 🔴 **Our return address, before the first federated send.** A relay // 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). * diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt index 8b3d145..e2b6560 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt @@ -384,6 +384,10 @@ class Session( record(Vault.threadForUser(peerUserId), text, mine = true, at = at, link = link, messageId = mid, lifetimeSeconds = lifetimeSeconds, 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 // 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 = storyReactions[storyKey].orEmpty() fun myReactionTo(storyKey: String): String? = storyReactions[storyKey]?.get(userId) diff --git a/client/src/main/kotlin/io/flamenet/client/Conversations.kt b/client/src/main/kotlin/io/flamenet/client/Conversations.kt index 421623f..87bf21b 100644 --- a/client/src/main/kotlin/io/flamenet/client/Conversations.kt +++ b/client/src/main/kotlin/io/flamenet/client/Conversations.kt @@ -212,6 +212,15 @@ class Conversations( * at the sender as an incoming one. The sender's own copy is written * 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, mentions: List = emptyList(), /** @@ -737,6 +746,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), diff --git a/client/src/main/kotlin/io/flamenet/client/RelayException.kt b/client/src/main/kotlin/io/flamenet/client/RelayException.kt index 1332695..765eb19 100644 --- a/client/src/main/kotlin/io/flamenet/client/RelayException.kt +++ b/client/src/main/kotlin/io/flamenet/client/RelayException.kt @@ -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.") } /** diff --git a/engine/src/main/kotlin/io/flamenet/e2e/SealingRecord.kt b/engine/src/main/kotlin/io/flamenet/e2e/SealingRecord.kt new file mode 100644 index 0000000..6380626 --- /dev/null +++ b/engine/src/main/kotlin/io/flamenet/e2e/SealingRecord.kt @@ -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) +} diff --git a/engine/src/test/kotlin/io/flamenet/e2e/SealingRecordTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/SealingRecordTest.kt new file mode 100644 index 0000000..238d58c --- /dev/null +++ b/engine/src/test/kotlin/io/flamenet/e2e/SealingRecordTest.kt @@ -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) + } +}