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:
Paul Hitt
2026-09-03 19:22:26 -04:00
parent a0bc8e68d2
commit 3f0e36fffc
7 changed files with 221 additions and 0 deletions
@@ -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<Int> = 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),
@@ -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.")
}
/**