Hand a contact the bridge you use (§6.19)

A bridge only works while whoever wants to block it does not have the
line. Every ordinary way of distributing one — a web page, an email, a
channel — is a place a censor can go and collect them, which is why Tor
needs BridgeDB, moat and domain-fronted rendezvous and still gets its
bridges enumerated. This app already has the thing that solves it: an
encrypted channel between people who know each other. A line handed over
that rail is invisible to the relay, and enumerating it means infiltrating
one friendship at a time.

🔴 **An offer, never a setting.** A bridge line tells a phone to route
every relay connection through a machine somebody else chose. Applying
one on arrival would let a contact — or whoever has taken their account —
move another person's traffic onto a host they control. They could not
read it, because the ratchet does not care what carries it, but they
would learn who is online and when, and could cut them off at will. So
the transport stores it and a button applies it, pressed by the person it
was sent to.

The test that holds that line asserts storing an offer writes ONLY the
offers record. Make `saveBridgeOffer` also write a configured bridge and
it reddens, alone, while every other test in the file still passes —
which is what makes it worth having.

The line is shown in full on the screen, not hidden behind "Accept".
Somebody may want to check the fingerprint against another source before
routing their traffic through it, and they cannot check what they cannot
see. A line that arrived broken says which part is wrong, using the same
validator the paste field uses, rather than failing when accepted.

⚠️ **`"ctl":1`, so 0.10 swallows it.** A build that has never heard of
this kind consumes the envelope and shows nothing. The alternative — a
bubble full of JSON in somebody's conversation — is the exact bug the
`ctl` flag was introduced to stop, and it would have shipped to every
older install. Asserted on the wire as the NUMBER one, because iOS
decodes it as a number and a boolean fails the whole envelope's decode
rather than merely failing to set the flag.

1175 tests, 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-06 19:56:58 -04:00
co-authored by Claude Opus 5
parent 9ab6384638
commit 1fd61ce124
9 changed files with 468 additions and 3 deletions
@@ -1087,6 +1087,31 @@ class Conversations(
sendPlaintext(toUserId, Content.disappearing(seconds))
}
/**
* Offer a contact the bridge this phone uses (§6.19).
*
* 🔴 **Nothing is stored locally, unlike [setDisappearing].** This changes
* nothing here — it hands somebody a line and lets them decide. The
* asymmetry is the point: a timer is a property of a conversation both
* ends must share, and a bridge is a route one person chooses for
* themselves.
*
* ⚠️ Rides the ratchet like any other message, so the relay sees a
* conversation happening and not a bridge being handed out. That is most
* of why this exists: a bridge is only useful while whoever wants to block
* it does not have the line, and every other way of distributing one —
* a web page, an email, a channel — is a place to go and collect them.
*/
fun shareBridge(toUserId: Long, line: String) {
sendPlaintext(toUserId, Content.bridgeOffer(line))
}
/** Bridge lines contacts have offered, newest first. Never applied here. */
fun bridgeOffers(): List<Vault.BridgeOffer> = vault.bridgeOffers()
/** Forget an offer, whether it was accepted or turned down. */
fun dismissBridgeOffer(fromUser: Long) = vault.removeBridgeOffer(fromUser)
/** This conversation's timer in seconds, 0 when off. */
fun disappearSeconds(toUserId: Long): Int =
vault.disappearSeconds(Vault.threadForUser(toUserId))
@@ -1685,6 +1710,18 @@ class Conversations(
// The other side set a timer (§6.11). Adopted rather than offered:
// a timer both parties have to agree to separately is a timer that
// is off whenever one of them has not noticed the prompt.
// A contact offering the bridge they use (§6.19).
//
// 🔴 **Stored as an OFFER and never applied.** Turning this on
// because it arrived would let a contact — or whoever has taken
// their account — move this phone's traffic onto a host they
// chose. They could not read it, but they would learn who is
// online and when, and could cut them off. The person it was sent
// to is the only thing that turns it on.
Content.Kind.BRIDGE -> {
val line = Content.bridgeLine(content) ?: return
vault.saveBridgeOffer(fromUser, line, at = System.currentTimeMillis() / 1000)
}
Content.Kind.DISAPPEAR -> {
val seconds = Content.disappearSeconds(content) ?: return
vault.saveDisappearSeconds(Vault.threadForUser(fromUser), seconds)