Blocking somebody now cancels their sealed passes too

§21.4 gave a lever to cut off one contact and nothing pulled it. That is
the failure this project has been fixing all day in other places — a
capability built and left unreachable.

Blocking is where it belongs, and the reason is stronger than
convenience. The relay CANNOT enforce a block against a sealed sender:
the request carries no sender to compare against a block list, and the
send route's own comment says so. So blocking stopped the identified
path and nothing else, and a blocked person's sealed messages were
dropped by the app after decryption rather than refused at the door.
Revoking their capabilities is what closes that.

The copy changes with it, because "buddies you block can't send you
messages" was not true and is the kind of overclaim a safety screen must
not make. It now says what blocking does, and names the gap left: the
shared key everybody holds is not that person's alone, so it takes
replacing to close completely — with the cost of that stated in the same
breath rather than in a different screen.

The sealed-sender section changes for the same reason. It said replacing
the key is "the only way" to cut somebody off, which was true when it
was written and is not now.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-30 19:36:59 -04:00
co-authored by Claude Opus 4.8
parent 79c2734f86
commit 8061f54e96
3 changed files with 54 additions and 2 deletions
@@ -855,8 +855,11 @@ fun ColumnScope.AccountScreen(
SettingsRow(
label = "Sealed sender",
description = "Messages you send are addressed to the recipient without naming you. "
+ "Replacing your delivery key is the only way to stop someone abusing it — and "
+ "it cuts off every contact until you next message them.",
+ "Because nothing names the sender, the server cannot block one — so blocking "
+ "somebody now also cancels the one-use passes you gave them, which is the part "
+ "that can be aimed at a single person. This replaces the shared key instead, "
+ "which everybody holds: it is the way to cut off somebody you cannot identify, "
+ "and it costs every contact until you next message them.",
) {
KitChip(text = "New key", selected = false,
onClick = model::rotateDeliveryKey, tag = "rotate-delivery-key")
@@ -1015,6 +1015,24 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
val done = withContext(Dispatchers.IO) {
runCatching {
current.api.setBlocked(userId, blocked)
// 🔴 **And take their sealed capabilities with it.** The relay
// cannot enforce a block against a sealed sender — the request
// carries no sender to compare against a block list, and the
// send route says so itself — so without this, blocking stops
// the identified path and nothing else, and a blocked person's
// sealed messages are dropped by this app after decryption
// rather than refused at the door.
//
// ⚠️ One of two doors, and this shuts one. They keep the shared
// delivery key of §14.4, which the relay also cannot attribute,
// until it is rotated — and rotating costs every other contact
// a redistribution. Best-effort and after the block has landed:
// the block is the safety action, and failing it because a
// follow-up failed would leave somebody unblocked who believes
// otherwise.
if (blocked) {
runCatching { current.conversations.revokeDeliveryTokens(userId) }
}
current.api.blocked().toSet() to current.roster()
}
}
@@ -271,6 +271,37 @@ class DeliveryTokenTest {
assertEquals(first, vault.issuedTokens(42).toSet())
}
/**
* 🔴 **This is the act the whole section exists to make possible.** The
* relay cannot enforce a block against a sealed sender — the request
* carries no sender to compare against a block list — so before §21.4,
* blocking stopped the identified path and nothing else, and a blocked
* person's sealed messages were dropped by the app after decryption rather
* than refused at the door.
*
* The view model calls this alongside the block; asserted at this layer
* because it is where the behaviour lives.
*/
@Test
fun `revoking is what stops a sealed sender the relay cannot identify`() {
val c = withDeliveryKey()
c.send(toUserId = 42, text = "hello")
assertEquals(64, held.size)
c.revokeDeliveryTokens(forUserId = 42)
// Every capability that person held is gone from the relay, so their
// next sealed send is refused with no sender ever named.
assertEquals(0, held.size)
assertTrue(vault.issuedTokens(42).isEmpty())
}
/** Revoking a contact who never held any is not an error. */
@Test
fun `revoking somebody with no batch does nothing and says so`() {
assertEquals(0, withDeliveryKey().revokeDeliveryTokens(forUserId = 99))
}
// MARK: - Receiving a batch
@Test