Check every key the relay can prove, and reach other relays

Two things this app could describe and not do.

**Key transparency was a switch almost nobody found.** `requireKeyLog`
was off by default, so the log was published, verified in tests, and
checked for approximately no one. The reason it was opt-in — a relay
predating §19 keeps no log, and refusing every peer there is worse than
the attack — argues for skipping relays that CANNOT answer, never for
skipping the ones that can. `enforceKeyLogIfServed` draws that line: ask
the head route once, enforce fully if it answers, skip if it is absent.
The switch survives as the stricter question underneath — refuse a relay
that keeps no log at all — which is a real choice for somebody who knows
their server publishes one.

Two things make that safe rather than merely automatic. The head is
asked for SEPARATELY, because `enforceKeyLog` reads three routes and a
relay wanting to skip the check would otherwise only have to 404 the
middle one. And a relay that has answered before is remembered, so it
cannot publish a log until clients are talking and then stop — logs do
not disappear, and that is a fork rather than an absence. The flag is
its own vault entry rather than the pin, because the pin can arrive
from gossip and would make a logless relay look dishonest.

Both subject forms are now accepted for a leaf. A leaf names `fnid:`
once an account has claimed an address and `local:` before, and the hash
committed to whichever was true when it was written — so a contact who
registered a device and then claimed an address has older leaves under
one form and newer ones under the other. A verifier computing a single
subject would refuse half the network on the day claiming turned on.
Either candidate still requires the relay to have appended a leaf
binding the exact key it served.

**Federation was iOS-only.** The relay has spoken it for a while and so
has iPhone; Android could neither send to an address nor read one that
arrived. Now: the three remote routes, an always-sealed send that
carries our address as the return path, and a receive side that files a
federated message under that address rather than under `from_user`,
which their relay never set. Filing by user id would drop every message
from every other relay into thread 0 — or worse, into a local contact's,
if their row numbers happened to collide.

The address keyspace is kept apart from the local one throughout, and
that is the load-bearing decision. A remote sender is pinned, bound and
threaded by `fnid@relay`; they cannot write a local delivery key or
avatar, cannot post into a group, and cannot ring a phone, because all
four are addressed by a user id that means nothing across the boundary.

Wired end to end rather than left as a capability: the add-contact field
recognises an address and offers to message it — the QR scanner already
prefilled that field and had its address looked up as a handle — and
conversations on other relays get their own section, without which a
reply is unreachable short of retyping 52 characters of base32.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-30 15:40:28 -04:00
co-authored by Claude Opus 4.8
parent 0a7c112b8b
commit 91f58956cf
14 changed files with 1601 additions and 67 deletions
@@ -76,6 +76,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import io.flamenet.client.IdentityTrust
import io.flamenet.e2e.ContactCard
import io.flamenet.e2e.SafetyNumber
import io.flamenet.e2e.Vault
import java.text.SimpleDateFormat
@@ -159,6 +160,60 @@ fun ColumnScope.BuddyList(
query.isBlank() ||
it.displayName.contains(query, true) || it.handle.contains(query, true)
}
// 🔴 **Conversations on other relays get their own section**, because they
// are not contacts and cannot be made into any. A remote peer has no
// account here, so nothing adds them to a roster — and without a row, a
// reply that arrives sits in a thread with no way back to it short of
// retyping a 52-character derived key.
val remote = phase.remoteAddresses.reversed().filter {
query.isBlank() || it.contains(query, true)
}
if (remote.isNotEmpty()) {
SectionLabel("Other relays")
Column(modifier = Modifier.fillMaxWidth().testTag("remote-threads")) {
remote.forEach { address ->
val unread = Vault.threadForAddress(address) in phase.unread
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { model.openAddressChat(address) }
.padding(vertical = Kit.rowGap / 2)
.testTag("remote-$address"),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Kit.gutter),
) {
// ⚠️ No presence dot and no initials. We know nothing about
// this person beyond an address — their relay does not tell
// ours whether they are online, and initials taken off a
// base32 key would be two meaningless characters dressed up
// as a name.
Avatar(name = "", online = false, picture = null, model = model)
Column(modifier = Modifier.weight(1f)) {
Text(
address.substringAfter('@'),
fontSize = 16.sp,
fontWeight = if (unread) FontWeight.Bold else FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
// The address in full underneath, monospaced. An fnid
// is a derived key, not a name — one that is nearly
// right belongs to somebody else — so it is never
// abbreviated to something that merely looks familiar.
Text(
address.substringBefore('@'),
fontSize = 11.sp,
fontFamily = FontFamily.Monospace,
color = Palette.SecondaryText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
SectionLabel("Contacts")
}
Column(modifier = Modifier.fillMaxWidth().testTag("buddies")) {
shown.forEach { buddy ->
val name = buddy.displayName.ifBlank { buddy.handle }
@@ -773,11 +828,18 @@ fun ColumnScope.AccountScreen(
Spacer(Modifier.height(Space.sm))
}
// 🔴 This switch used to be what turned checking ON, and it was off by
// default — so the protection shipped and almost nobody had it.
// Checking is now automatic on any server that keeps a log; what is
// left here is the narrower question of what to do where there is
// nothing to check against.
SettingsRow(
label = "Require published keys",
description = "Refuse to send unless this server can prove the recipient's keys "
+ "appear in the public log it publishes. Older servers keep no log and every "
+ "message to them will fail.",
label = "Also refuse servers with no log",
description = "Your app already checks that a contact's keys appear in the public "
+ "log this server publishes, on its own, before the first message. A server "
+ "old enough to keep no log has nothing to show, and those chats go ahead "
+ "unchecked — turn this on to refuse them instead. Only if you know your "
+ "server publishes a log, or nobody on it will be reachable.",
) {
Switch(
checked = phase.requireKeyLog,
@@ -2604,27 +2666,52 @@ fun ColumnScope.AddContactForm(
return
}
// 🔴 **A scanned code is an ADDRESS, and this form treated everything
// as a username.** The scanner already prefills here, so somebody who
// scanned a card from another relay got their address looked up as a
// handle on ours, was told no such person exists, and had no other way
// in. One field either way — what it is, is a fact about the text, not
// a mode the person has to pick.
val card = remember(username) { ContactCard.decode(username) }
OutlinedTextField(
value = username,
onValueChange = {
username = it
if (phase.lookedUp != null || phase.lookupMiss != null) model.clearLookup()
},
label = { Text("Username") },
label = { Text(if (card != null) "Address" else "Username or address") },
singleLine = true,
modifier = Modifier.fillMaxWidth().testTag("new-contact"),
)
// ⚠️ **Look up first, then add.** Adding straight from a typed string
// means a typo silently becomes a contact who is a stranger, and the
// first thing sent to them is a message meant for somebody else.
KitPrimaryButton(
text = "Look up",
onClick = { model.lookupMember(username) },
enabled = username.isNotBlank() && !phase.busy,
modifier = Modifier.fillMaxWidth(),
tag = "lookup-contact",
)
if (card != null) {
Text(
"That's an address on ${card.relay}. Messages go straight there — nothing "
+ "to add to your contacts first, and you don't need an account on it.",
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.testTag("address-recognised"),
)
KitPrimaryButton(
text = "Message them",
onClick = { model.openAddressChat(username); onDone() },
enabled = !phase.busy,
modifier = Modifier.fillMaxWidth(),
tag = "message-address",
)
} else {
// ⚠️ **Look up first, then add.** Adding straight from a typed
// string means a typo silently becomes a contact who is a stranger,
// and the first thing sent to them is a message meant for somebody
// else.
KitPrimaryButton(
text = "Look up",
onClick = { model.lookupMember(username) },
enabled = username.isNotBlank() && !phase.busy,
modifier = Modifier.fillMaxWidth(),
tag = "lookup-contact",
)
}
phase.lookupMiss?.let {
Text(
@@ -11,6 +11,8 @@ import io.flamenet.client.RelayException
import io.flamenet.e2e.AccountKdf
import io.flamenet.e2e.DeviceIdentity
import io.flamenet.e2e.Ed25519
import io.flamenet.client.AccountIdentity
import io.flamenet.e2e.ContactCard
import io.flamenet.e2e.FNID
import io.flamenet.e2e.HistoryExport
import io.flamenet.e2e.Vault
@@ -95,6 +97,18 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
*/
val snoozedThreads: Set<String> = emptySet(),
/**
* Conversations with people on other relays, most recent last.
*
* 🔴 **Without this a federated conversation is write-only.** A
* remote peer is not in the roster — they have no account here and
* no user id — so a reply that arrives lands in a thread with
* nothing on any screen pointing at it. The address had to be
* retyped to get back in, which for a 52-character derived key is
* the same as unreachable.
*/
val remoteAddresses: List<String> = emptyList(),
/** Same question for the group list. See [buddiesLoaded]. */
val groupsLoaded: Boolean = false,
val groups: List<io.flamenet.client.RelayClient.Group> = emptyList(),
@@ -204,6 +218,18 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
val lines: List<Session.Line>,
val peerId: Long? = null,
val group: io.flamenet.client.RelayClient.Group? = null,
/**
* `fnid@relay` when this conversation is with somebody on another
* relay, null when it is not.
*
* ⚠️ A third case beside [peerId] and [group], not a variant of the
* first. A remote peer's user id belongs to their relay and means
* nothing here, so every path that dispatches on `peerId` — sending,
* calling, the safety number, the disappearing timer — has to see
* `null` and take the address branch instead of quietly using a row
* number that points at somebody else.
*/
val address: String? = null,
/**
* What we believe about the other side's identity keys (§19).
*
@@ -462,6 +488,20 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
relay.registerDevice(device, preKeys)
vault.saveIdentity(device)
// 🔴 **Register the address, don't just derive it.** Deriving it makes
// it ours; claiming is what makes us *reachable* at it. A peer relay
// resolving `fnid@here` asks this relay, and until the claim lands the
// answer is "no such address" — so every account had an address it
// could show on a card and nobody could send to.
//
// ⚠️ `runCatching`, like the derivation above: a relay that is down,
// too old to have the route, or simply slow must not keep somebody out
// of their own messages. It is idempotent server-side and retried on
// the next sign-in, so losing one attempt costs one launch.
if (accountKeySeed != null) {
runCatching { AccountIdentity.claim(relay, accountKeySeed, account.userId) }
}
vault.saveCredential(Vault.Credential(
relayBaseUrl = config.baseUrl,
username = account.username,
@@ -479,6 +519,18 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
api = io.flamenet.client.MessengerApi(config.baseUrl, account.username, credential),
)
// 🔴 **Our return address, before the first federated send.** A relay
// that accepts a federated envelope records no sender, and our user id
// means nothing on it — so without this a message crosses the boundary,
// decrypts on their phone, and cannot be replied to.
//
// ⚠️ Through `ContactCard.canonical` rather than string-built: the
// recipient parses it with the same rules, and an address that would
// not survive the trip is better left unset than sealed. Half an
// address is worse than none.
session!!.conversations.myAddress =
fnid?.let { ContactCard.canonical("$it@${config.host}") }
val fetchedBuddies = runCatching { session!!.roster() }
val fetchedGroups = runCatching { session!!.relay.listGroups() }
@@ -502,6 +554,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
// while sends were being refused, which is the same confusion in
// the opposite direction.
requireKeyLog = strictKeyLog,
remoteAddresses = session!!.remoteAddresses(),
buddies = fetchedBuddies.getOrDefault(emptyList()),
buddiesLoaded = fetchedBuddies.isSuccess,
avatars = session?.avatars(
@@ -610,6 +663,41 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
}
}
/**
* Open a conversation with somebody on another relay.
*
* ⚠️ **Nothing is fetched here, and nothing is verified.** A remote thread
* exists as soon as an address has been typed, exactly as a local one
* exists before the first message — the address is checked when a message
* is actually sent, where a refusal has something to say to the person who
* caused it. Reaching across a relay boundary to validate a paste would
* also tell that relay somebody was thinking about one of its users.
*
* A malformed address opens nothing rather than opening a thread that can
* never send. `ContactCard.decode` is the one place that decides.
*/
fun openAddressChat(text: String) {
val current = session ?: return
val card = ContactCard.decode(text) ?: run {
update { it.copy(error = "That is not an address. It looks like " +
"fnid:… followed by @ and the relay's hostname.") }
return
}
if (card.address == current.conversations.myAddress) {
update { it.copy(error = "That is your own address.") }
return
}
val thread = Vault.threadForAddress(card.address)
update {
it.copy(
openChat = Phase.Chat(
thread, card.address, current.history(thread), address = card.address),
unread = it.unread - thread,
error = null,
)
}
}
fun openGroup(group: io.flamenet.client.RelayClient.Group) {
val current = session ?: return
val thread = Vault.threadForGroup(group.id)
@@ -1836,7 +1924,22 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
val result = withContext(Dispatchers.IO) {
runCatching {
val group = chat.group
val address = chat.address
if (group != null) current.sendGroup(group, text)
// 🔴 Before the `peerId!!` below, because a remote thread has
// no peer id and that assertion would crash rather than send.
//
// ⚠️ No link preview. §6.12 fetches the card on the SENDER's
// device to keep the recipient off the URL, and doing that here
// would tell the site that somebody on this relay is writing to
// somebody on another one — a correlation the whole federated
// path exists to avoid. A plain URL is a perfectly good thing
// for a message to contain.
else if (address != null) {
val card = ContactCard.decode(address)
?: error("this conversation's address is unreadable")
current.sendToAddress(card, text)
}
// §6.12 — fetched HERE, on the sender's device, and carried
// inside the ciphertext. The recipient never touches the URL: a
// card each of them fetched would tell the site who read the
@@ -2023,6 +2126,14 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
// A thread that is already open is being read,
// so it does not also become unread.
unread = state.unread + threads - setOfNotNull(open?.threadId),
// ⚠️ Refreshed here, not only at sign-in. A
// first message from another relay creates a
// thread nothing else on any screen points at —
// its sender is not in the roster and never will
// be — so a list computed once at launch would
// leave the reply unreachable until the next
// one.
remoteAddresses = current.remoteAddresses(),
)
}
}
@@ -4,6 +4,7 @@ import io.flamenet.client.Conversations
import io.flamenet.client.MessengerApi
import io.flamenet.client.RelayClient
import io.flamenet.e2e.Attachment
import io.flamenet.e2e.ContactCard
import io.flamenet.e2e.Content
import io.flamenet.e2e.DeviceIdentity
import io.flamenet.e2e.Vault
@@ -305,6 +306,25 @@ class Session(
return sent
}
/**
* Send to somebody on another relay, and record it in their thread.
*
* ⚠️ **Not queued on failure, unlike [send].** The local outbox retries by
* user id against this relay; a remote address has neither. A federated
* send that fails is reported so the person can try again rather than
* silently held in a queue that will never drain.
*/
fun sendToAddress(card: ContactCard, text: String): Conversations.Sent {
val sent = conversations.sendToAddress(card, Content.text(text))
record(Vault.threadForAddress(card.address), text, mine = true,
at = System.currentTimeMillis(),
delivery = if (sent.reachedSomeone) Delivery.DELIVERED else Delivery.PENDING)
return sent
}
/** Addresses on other relays this device has exchanged messages with. */
fun remoteAddresses(): List<String> = conversations.remoteAddresses()
/** One call, as the list draws it. */
data class CallEntry(
val peerId: Long,
@@ -670,8 +690,14 @@ class Session(
fun receive(): Set<String> {
val everything = conversations.poll()
everything.forEach { message ->
message.content.call?.let { signal ->
onCallSignal?.invoke(message.fromUser, signal, message.at)
// ⚠️ Local calls only. Signalling is addressed by user id the
// whole way down — the answer, the ICE candidates, the hang-up —
// so a federated offer would ring and then have nowhere to reply
// to. Ignored rather than half-rung.
if (message.fromAddress == null) {
message.content.call?.let { signal ->
onCallSignal?.invoke(message.fromUser, signal, message.at)
}
}
}
val arrived = everything.filter { it.content.isRenderable }
@@ -686,7 +712,7 @@ class Session(
// One that arrives already expired — posted while this device was
// offline — is dropped rather than shown late.
val ref = message.content.attachment
if (ref != null && ref.kind == Attachment.STORY) {
if (ref != null && ref.kind == Attachment.STORY && message.fromAddress == null) {
val story = Vault.Story(
id = ref.id,
authorId = message.fromUser,
@@ -708,15 +734,25 @@ class Session(
// member we REMOVED, whose 1:1 session outlives their membership —
// post into a group they do not belong to. The roster is the
// relay's; a group it does not list for us is not ours.
val gid = message.content.groupId
// ⚠️ A group id from another relay names a group on THEIR relay,
// which our roster check would look up here and either miss or,
// worse, match by coincidence. Groups do not federate yet, so a
// remote sender's group claim is ignored and the message files in
// their own thread.
val gid = if (message.fromAddress != null) null else message.content.groupId
if (gid != null) {
val known = groupRoster(gid)
if (known == null || message.fromUser !in known) {
continue
}
}
// 🔴 **A federated message files under its ADDRESS.** Their relay
// told ours nothing about who sent it, so `fromUser` is 0 — filing
// by it would drop every message from every other relay into one
// thread with no name on it, mixed together.
val thread = gid
?.let(Vault::threadForGroup)
?: message.fromAddress?.let(Vault::threadForAddress)
?: Vault.threadForUser(message.fromUser)
record(thread, message.content.text, mine = false,
attachment = message.content.attachment,
@@ -0,0 +1,140 @@
package io.flamenet.client
import io.flamenet.e2e.Ed25519
import io.flamenet.e2e.FNID
/**
* Turning a passphrase into an address, and telling relays where to deliver.
*
* ## Why this exists at all
*
* A relay's `user_id` is a row number it assigned itself. Register at one relay
* and everything about you — your name there, your devices, your mailbox — is
* that relay's to keep or to lose. This makes the *identity* the user's
* instead: an Ed25519 key stretched from the passphrase, whose public half is
* the address. No server issues it, so no server can take it away.
*
* The practical consequence is the one worth stating: sign in with the same
* passphrase at a relay that has never heard of you and you get **the same
* address**. Contacts who accepted you before still see the same person. A
* relay shutting down costs a mailbox, not an identity.
*
* ## Why it is here and not in the engine
*
* The engine holds no network. Deriving the key and building the bytes to sign
* are engine work and live in [FNID] and `AccountKdf`; presenting the result to
* a relay is client work and lives here, so the engine stays testable without
* one and this stays swappable without touching crypto.
*/
object AccountIdentity {
/** The address for an account seed. Derived, never fetched. */
fun address(seed: ByteArray): String = FNID.address(Ed25519.fromSeed(seed).publicKey)
/**
* Claim the address at [relay], learning which audience to bind from the
* relay itself.
*
* ⚠️ **The audience is not the caller's to guess.** It is the string the
* relay checks the signature against, so a caller that inferred it from a
* URL would produce a claim that verifies nowhere — silently, because at
* the point of failure a wrong audience and a wrong key look identical.
* Reading it from the descriptor means the only way to get it wrong is for
* the relay to lie about its own name, which costs it the claim rather than
* us the identity.
*
* Self-certifying: the signature is made with the very key being
* registered, so the relay confirms possession and forms no opinion about
* whose key it is — the property that lets a hostile relay be tolerated.
*
* Returns the address the relay recorded.
*/
fun claim(relay: RelayClient, seed: ByteArray, userId: Long): String {
val key = Ed25519.fromSeed(seed)
val pub = b64(key.publicKey)
val audience = relay.relayDescriptor().getString("audience")
val sig = b64(Ed25519.sign(seed, FNID.claimCanonical(audience, userId, pub)))
return relay.claimAccountIdentity(pub, sig).getString("fnid")
}
/**
* Publish where this account keeps mailboxes, to every relay in the list.
*
* Every listed relay is told, not just the one being added: a home that
* does not know it is one will still accept mail, but a *sender* asking it
* for the record would be handed a stale list and would skip the others.
* Delivering the record everywhere is what keeps them from disagreeing.
*
* Takes a client **per home**, and that is the shape of the feature rather
* than an implementation detail: a relay will not accept a record from
* somebody with no account there, and a token minted by one relay is
* rejected by every other because the audience is bound. So homing
* somewhere means registering there first, and a map makes that a
* compile-time fact instead of a runtime surprise.
*
* Returns the relays that refused, with why. Reported rather than thrown on
* the first failure: one home being unreachable while the rest accept is a
* partial success the user can act on, and collapsing it into a single
* error would hide which.
*/
fun publishHomes(
seed: ByteArray, fnid: String, serial: Int, homes: Map<String, RelayClient>
): Map<String, Throwable> {
val relays = homes.keys.toList()
val sig = b64(Ed25519.sign(seed, FNID.homesCanonical(fnid, serial, relays)))
val failures = LinkedHashMap<String, Throwable>()
for ((host, client) in homes) {
runCatching { client.publishHomes(serial, relays, sig) }
.onFailure { failures[host] = it }
}
return failures
}
/**
* The next serial to publish, read from what is already out there rather
* than from local state.
*
* A device that was offline while another added a relay would otherwise
* reuse a serial and have its record refused as a rollback — by the very
* check that exists to stop an attacker doing the same thing.
*/
fun nextSerial(fnid: String, clients: Collection<RelayClient>): Int {
var highest = 0
for (c in clients) {
val record = runCatching { c.homes(fnid) }.getOrNull() ?: continue
highest = maxOf(highest, record.optInt("serial", 0))
}
return highest + 1
}
/**
* Check a home record against the address it claims to be for.
*
* Needs nothing but the record, because the address *is* the public key —
* which is the point. A sender must not have to ask a relay whether that
* relay is telling the truth about where to deliver.
*/
fun verifyHomes(fnid: String, serial: Int, relays: List<String>, signatureB64: String): Boolean {
val pub = FNID.decode(fnid) ?: return false
val sig = runCatching { unb64(signatureB64) }.getOrNull() ?: return false
return Ed25519.verify(pub, sig, FNID.homesCanonical(fnid, serial, relays))
}
/** Relays named by a record, but only once it verifies. */
fun verifiedRelays(record: org.json.JSONObject): List<String>? {
val fnid = record.optString("fnid").ifEmpty { return null }
val serial = record.optInt("serial", -1)
if (serial < 0) return null
val array = record.optJSONArray("relays") ?: return null
val relays = (0 until array.length()).map { array.getString(it) }
val sig = record.optString("sig").ifEmpty { return null }
return if (verifyHomes(fnid, serial, relays, sig)) relays else null
}
// The module's own helper, so this encodes identically to every other blob
// the transport sends. `android.util.Base64` is deliberately not reachable
// here: this module is plain JVM precisely so its tests run against a real
// relay with no emulator.
private fun b64(b: ByteArray): String = Wire.b64(b)
private fun unb64(s: String): ByteArray = Wire.unb64(s)
}
@@ -1,6 +1,7 @@
package io.flamenet.client
import io.flamenet.e2e.Attachment
import io.flamenet.e2e.ContactCard
import io.flamenet.e2e.Content
import io.flamenet.e2e.DeviceIdentity
import io.flamenet.e2e.E2ESession
@@ -63,6 +64,22 @@ class Conversations(
* Carried for call signalling — see [RelayClient.Incoming.createdAt].
*/
val at: Long = 0,
/**
* `fnid@relay` when this came from another relay, null when it did not.
*
* 🔴 **This, not [fromUser], is who wrote a federated message.** Our
* relay records no sender for an envelope a peer deposited, and the
* `from_user` sealed inside it is a row number on a machine we have
* never heard of — user 12 there and user 12 here are unrelated people.
* Filing one of these by user id would drop every federated message
* into thread 0, or worse, into a local contact's thread.
*
* Bound to the sending device first-use-wins, exactly as [fromUser] is:
* the ratchet proves the device, the address is a claim from inside the
* seal, so a device that later claims a different address keeps the one
* it was first seen with.
*/
val fromAddress: String? = null,
) {
val text: String get() = content.text
}
@@ -391,9 +408,9 @@ class Conversations(
* interception, so this stops and asks rather than guessing. Accepting
* is [Trust.acceptIdentityChange].
* - [KeyNotLoggedException] / [KeyLogForkException] — the relay cannot
* prove the key it served is in the log it publishes. Raised only when
* `trust.requireKeyLog` is on, because a relay predating the log has
* none and refusing everyone there is the worse failure.
* prove the key it served is in the log it publishes. Raised on any
* relay that keeps a log, with no setting to find first; a relay that
* keeps none is skipped unless `trust.requireKeyLog` says otherwise.
*
* ⚠️ A fork already detected by gossip fails the send outright, whatever
* this particular bundle looks like. A relay caught serving two logs has
@@ -413,11 +430,7 @@ class Conversations(
) {
throw IdentityChangedException(toUserId)
}
if (!trust.requireKeyLog) return
if (trust.forkDetected) {
throw KeyLogForkException("a fork was already detected on this relay")
}
theirs.forEach { trust.enforceKeyLog(it, toUserId) }
theirs.forEach { trust.enforceKeyLogIfServed(it, toUserId) }
}
private fun sendPlaintext(toUserId: Long, text: String): Sent {
@@ -575,6 +588,171 @@ class Conversations(
return Sent(delivered, failed, sealed = peerKey != null)
}
// -- Federation (§20.5) --------------------------------------------------
/**
* Send to somebody on another relay.
*
* 🔴 **A parallel path, and [sendPlaintext] is untouched.** The local one
* is keyed on a row number this relay assigned; a remote peer has an
* address and a home. Folding the two together would mean editing the code
* where a mistake loses a message silently, on the path that already works.
*
* 🔴 **Always sealed, and here that is not a preference.** A relay that
* accepts a federated envelope stores it with no sender — it was not told
* and must not invent one — so an unsealed envelope arrives unattributable
* and is dropped on the floor. Locally, sealing is an improvement; across a
* boundary it is the only thing that makes the message deliverable at all.
*
* ⚠️ **Our own address is required**, because the receiving relay records
* none. Without it the message decrypts on their device, appears, and has
* nowhere to reply to — which reads to the recipient as the app being
* broken. Refused up front rather than sent into that.
*/
fun sendToAddress(card: ContactCard, text: String): Sent {
val mine = myAddress
?: return Sent(emptyList(), mapOf(card.address to
"this device has no address of its own yet, so a reply could not reach it"))
// The directory, not the bundle route — this consumes nothing on the
// far side, and the far side's prekey pool belongs to somebody who
// cannot see why it is draining.
val directory = relay.remoteDevices(card.fnid, card.relay)
val stubs = (0 until directory.length()).map {
val o = directory.getJSONObject(it)
PeerBundle(
deviceId = o.optString("device_id"),
identityDH = runCatching { Wire.unb64(o.optString("ik_dh")) }
.getOrDefault(ByteArray(0)),
identitySigning = runCatching { Wire.unb64(o.optString("ik_sig")) }
.getOrDefault(ByteArray(0)),
signedPreKeyId = 0,
signedPreKey = ByteArray(0),
signedPreKeySignature = ByteArray(0),
oneTimePreKeyId = 0,
oneTimePreKey = null,
proto = o.optInt("proto", 1),
)
}
if (stubs.isEmpty()) {
return Sent(emptyList(), mapOf(card.address to "that address has no devices"))
}
// The same first-use gate as the local path, in the address keyspace.
// Trust here cannot borrow from the local one: a user id on their relay
// means nothing on ours, so a pin has to hang off the address.
if (!recordRemoteTrust(card.address, stubs.map { it.identitySigning })) {
throw IdentityChangedException(0L)
}
// Full bundles only when something genuinely needs establishing. Same
// stub/full union as the local path and for the same reason: a stub
// cannot open a session, and a device silently dropping out of the
// fan-out is how somebody stops receiving on their tablet with nothing
// anywhere saying so.
val needsEstablish = stubs.any { existingSession(it.deviceId) == null }
val bundles = if (!needsEstablish) stubs else {
// ⚠️ Fetched ONCE into a local. This route pops a one-time prekey
// per device on the far relay, so calling it inside the loop would
// drain a stranger's pool at one request per device per send.
val served = relay.remoteBundles(card.fnid, card.relay)
val full = (0 until served.length())
.mapNotNull { runCatching { PeerBundle.fromWire(served.getJSONObject(it)) }
.getOrNull() }
.associateBy { it.deviceId }
stubs.map { full[it.deviceId] ?: it }
}
// ⚠️ One id per MESSAGE, not per device and not per mailbox. A
// recipient polling several homes collapses the copies by it; a fresh
// id per copy would show them the same message once per mailbox.
val envelopeId = java.util.UUID.randomUUID().toString()
val messages = mutableListOf<RelayClient.RemoteMessage>()
val delivered = mutableListOf<String>()
val failed = LinkedHashMap<String, String>()
for (bundle in bundles) {
try {
val session = sessionFor(bundle)
val envelope = session.encrypt(text)
// Saved before the request, as locally: a relay that accepts an
// envelope our ratchet has not advanced past leaves the peer
// holding a message neither side can recover. The reverse costs
// one unused message key.
vault.saveSession(bundle.deviceId, session)
val blob = SealedSender.seal(
envelope = envelope,
fromUser = myUserId ?: 0L,
fromDevice = identity.deviceId,
toDevice = bundle.deviceId,
recipientIKdh = bundle.identityDH,
fromAddress = mine,
)
messages += RelayClient.RemoteMessage(
bundle.deviceId, blob.toPayload(), envelopeId)
delivered += bundle.deviceId
} catch (e: Exception) {
failed[bundle.deviceId] = e.message ?: e::class.simpleName.orEmpty()
}
}
if (messages.isEmpty()) return Sent(delivered, failed, sealed = false)
val result = relay.sendRemote(card.fnid, card.relay, messages)
// 🔴 **`accepted` first.** Checking only the failure fields reported a
// send as successful when the far relay had taken nothing at all: the
// app said sent, the peer's inbox stayed empty, and no error anywhere
// explained it. Zero accepted is a failed send whatever else the reply
// says.
val accepted = result.optInt("accepted", 0)
if (accepted <= 0) {
val unknown = result.optJSONArray("unknown")?.length() ?: 0
failed[card.address] = if (unknown > 0) {
"${card.relay} does not know that address"
} else {
"${card.relay} accepted the request but took no copies"
}
return Sent(emptyList(), failed, sealed = true)
}
// Partial delivery is a real outcome once an account has several
// mailboxes, and silence about it is how somebody believes a message
// landed when a copy did not.
result.optJSONArray("unreachable")?.let { list ->
if (list.length() > 0) {
failed[card.address] = "some of their mailboxes did not take it: " +
(0 until list.length()).joinToString(", ") { list.getString(it) }
}
}
vault.rememberRemoteAddress(card.address)
return Sent(delivered, failed, sealed = true)
}
/**
* This device's own `fnid@relay`, sealed into every federated envelope as
* the return address.
*
* Set by the app once the account key has been derived, because the fnid
* comes off the passphrase and this layer deliberately never sees one.
*/
@Volatile var myAddress: String? = null
/** Trust on first use, in the address keyspace. */
private fun recordRemoteTrust(address: String, offered: List<ByteArray>): Boolean {
val usable = offered.filter { it.isNotEmpty() }
val pinned = vault.pinnedAddressIdentities(address)
if (pinned.isEmpty()) {
if (usable.isEmpty()) return false
vault.pinAddressIdentities(address, usable)
return true
}
// Every offered key must already be pinned. A key never seen is either
// a new device of theirs or one their relay minted, and nothing on the
// wire tells those apart.
return usable.isNotEmpty() && usable.all { key -> pinned.any { it.contentEquals(key) } }
}
/** Addresses this device has exchanged federated messages with. */
fun remoteAddresses(): List<String> = vault.remoteAddresses()
// -- Disappearing messages (§6.11) ---------------------------------------
/**
@@ -674,10 +852,29 @@ class Conversations(
Wire.unb64(envelope.handshake!!.getString("ik_sig"))
}.getOrNull()
val claimant = opened?.claimedFromUser ?: message.fromUser
if (offeredIK == null || claimant <= 0L ||
trust.record(claimant, listOf(offeredIK), trust.pinnedFnid(claimant))
== IdentityTrust.CHANGED
) {
// 🔴 **The gate applies in whichever keyspace the
// sender is in.** A first handshake from another relay
// carries a user id that means nothing here, so
// checking it against the local pins would either
// compare it to an unrelated contact's keys or fall
// through the `claimant <= 0` guard and refuse every
// federated first contact. The address keyspace has its
// own pin, set on our first send to them and on their
// first message to us, and it is the one that applies.
val addressClaim = opened?.claimedFromAddress
?.let { ContactCard.canonical(it) }
val refused = when {
offeredIK == null -> true
addressClaim != null ->
!recordRemoteTrust(
vault.bindRemoteAddress(addressClaim, fromDevice),
listOf(offeredIK))
claimant <= 0L -> true
else -> trust.record(
claimant, listOf(offeredIK), trust.pinnedFnid(claimant)
) == IdentityTrust.CHANGED
}
if (refused) {
// Refusing costs this message; establishing a session
// on a planted key costs the whole conversation.
throw IdentityChangedException(claimant)
@@ -720,29 +917,51 @@ class Conversations(
// renegotiation — a device claiming a different user now is
// lying, and its message files under the user it is bound to.
val claimed = opened?.claimedFromUser ?: message.fromUser
val fromUser = vault.bindDeviceOwner(claimed, fromDevice)
val senderProven = fromUser == claimed
// 🔴 **A federated sender is resolved in the ADDRESS keyspace,
// never the local one.** Their `from_user` is a row number on
// their relay; binding a device to it here would either file
// the message under a local contact who happens to share the
// number, or — where the relay recorded no sender at all —
// under user 0. Both are silent, and both put somebody else's
// words in a thread with the wrong name on it.
val fromAddress = opened?.claimedFromAddress
?.let { ContactCard.canonical(it) }
?.let { vault.bindRemoteAddress(it, fromDevice) }
val fromUser = if (fromAddress != null) 0L
else vault.bindDeviceOwner(claimed, fromDevice)
// Control traffic needs a sender the ratchet tied to an
// identity we already hold. A remote address is bound to a
// device and pinned on first use, which is the same standard —
// so a proven remote sender may write control envelopes into
// its own thread and nowhere else.
val senderProven = if (fromAddress != null) true else fromUser == claimed
val content = Content.parse(plaintext)
// Control traffic (delivery key, avatar) only from a proven
// sender: poisoning another user's delivery key forces our next
// send to them UNSEALED, which hands the relay the sender that
// sealing exists to hide.
if (senderProven) consumeControl(fromUser, content)
// ⚠️ Local control writes are keyed by user id, so they are
// skipped entirely for a federated sender: `consumeControl(0L,
// ...)` would poison user 0's delivery key and avatar, which is
// every unattributed message on this device.
if (senderProven && fromAddress == null) consumeControl(fromUser, content)
// 🔴 An UNSEALED message from a contact we have told our
// delivery key means their copy is gone — a reinstall wiped
// it. Sealing to us requires that key, and a peer who holds
// it seals everything. Un-mark them so the next send re-tells
// it; until then the relay sees their sender on every message
// they write us. Worst case: one redundant control envelope.
if (!sealed && fromUser != 0L) {
if (!sealed && fromUser != 0L && fromAddress == null) {
vault.loadDelivery()?.let { d ->
if (fromUser in d.toldPeers) {
vault.saveDelivery(d.copy(toldPeers = d.toldPeers - fromUser))
}
}
}
if (fromAddress != null) vault.rememberRemoteAddress(fromAddress)
out += Received(message.id, fromUser, fromDevice, content,
sealed = sealed, at = message.createdAt)
sealed = sealed, at = message.createdAt,
fromAddress = fromAddress)
} catch (_: Exception) {
// Deliberately swallowed and skipped. One undecryptable message
// is not a reason to stop reading the inbox.
@@ -371,6 +371,110 @@ class RelayClient(
null
}
// -- Federation (§20.5) --------------------------------------------------
//
// These three go to OUR relay, not to the peer's. A client never speaks to
// a relay it has not signed in to: ours signs the server-to-server request
// with its own federation key, so pointing an app at somebody else's relay
// cannot forge traffic between two servers.
/**
* The devices a remote address currently has.
*
* ⚠️ The directory, not the prekey route — this consumes nothing. Calling
* the bundle route once per message is what drained a local prekey pool
* already; the same mistake costs more here, because the pool being emptied
* belongs to somebody on another relay who cannot see why.
*/
fun remoteDevices(fnid: String, relay: String): JSONArray =
getJson("/e2e/remote/$relay/devices/$fnid").optJSONArray("devices") ?: JSONArray()
/**
* Prekey bundles for a remote address.
*
* 🔴 **Pops a one-time prekey on the far side**, exactly as the local
* `/e2e/keys` route does. Ask only when there is genuinely a session to
* open, never to find out who somebody is.
*/
fun remoteBundles(fnid: String, relay: String): JSONArray =
getJson("/e2e/remote/$relay/bundles/$fnid").optJSONArray("devices") ?: JSONArray()
/**
* Hand envelopes to our relay for delivery to [toFnid] at [toRelay].
*
* The reply says how many copies were taken, which addresses the far relay
* did not recognise, and which of that account's mailboxes could not be
* reached — all three matter, because with several homes a send is
* genuinely partial and a client that cannot tell cannot decide between
* retrying and warning.
*/
fun sendRemote(toFnid: String, toRelay: String, messages: List<RemoteMessage>): JSONObject {
val array = JSONArray()
for (m in messages) {
array.put(JSONObject()
.put("to_device", m.toDevice)
.put("payload", m.payload)
.put("envelope_id", m.envelopeId))
}
return postJson("/e2e/send/remote", JSONObject()
.put("to_fnid", toFnid)
.put("to_relay", toRelay)
.put("messages", array))
}
/**
* One copy of one message, for one of the recipient's devices.
*
* ⚠️ [envelopeId] is per MESSAGE, not per device or per mailbox. A
* recipient polling several homes collapses the copies by it; a fresh id
* per copy would show them the same message once per mailbox.
*/
data class RemoteMessage(
val toDevice: String,
val payload: String,
val envelopeId: String,
)
// -- Multi-homing (§20.4) ------------------------------------------------
/**
* `POST /e2e/account/homes` — say which relays hold mailboxes for this
* account, so a sender delivers to all of them and losing one loses
* nothing.
*
* ⚠️ **The relay being written to must be in [relays].** Otherwise an
* account could point every sender elsewhere while still reading an inbox
* here, and mail addressed by the record would never reach the box the
* person actually opens. The relay refuses that rather than storing it.
*
* [serial] must exceed whatever is already published. It is not a version
* for humans to read — it is the only thing that stops a relay replaying an
* older, genuinely signed list that happens to name only itself.
*/
fun publishHomes(serial: Int, relays: List<String>, signatureB64: String): JSONObject =
postJson("/e2e/account/homes", JSONObject()
.put("serial", serial)
.put("relays", JSONArray(relays))
.put("sig", signatureB64))
/**
* `GET /e2e/account/homes/{fnid}` — where to deliver, with the signature.
*
* Unauthenticated, and hands back the proof alongside the list precisely so
* the caller can check it instead of trusting this relay's copy. A relay
* serving a truncated list is the exact failure multi-homing exists to
* survive, so the answer has to be verifiable by whoever receives it — see
* [AccountIdentity.verifyHomes].
*
* Null for an account that has published nothing, which is the ordinary
* case and not an error: it means one mailbox, at the relay you asked.
*/
fun homes(fnid: String): JSONObject? = try {
getJson("/e2e/account/homes/$fnid")
} catch (_: RelayException.NotFound) {
null
}
// -- Transparency log (§19) ---------------------------------------------
//
// Unauthenticated on the relay, and no proof headers are sent: the value of
@@ -106,11 +106,13 @@ class IdentityChangedException(val userId: Long) :
* so inclusion is only meaningful against a head this device already
* pinned and can demand a consistency proof for.
*
* ⚠️ Deliberately not automatic. [requireKeyLog] is off by default for the
* same reason the web engine's is: a relay predating the log has none, and
* refusing every peer on a deployment that cannot answer is a worse failure
* than the one being prevented. Pinning is always on — it costs nothing and
* needs no cooperation from the relay.
* 🔴 **Automatic wherever the relay can answer.** This used to be behind
* [requireKeyLog], off by default — so the protection shipped and almost
* nobody had it. The argument for opt-in (a relay predating the log has none,
* and refusing every peer there is worse than the attack) was an argument for
* skipping relays that *cannot* answer, never for skipping the ones that can.
* [enforceKeyLogIfServed] draws exactly that line. Pinning is always on — it
* costs nothing and needs no cooperation from the relay.
*/
class Trust(
private val identity: DeviceIdentity,
@@ -118,7 +120,19 @@ class Trust(
private val relay: RelayClient,
) {
/** Refuse a peer whose served key is not provably in the relay's log. */
/**
* Refuse a peer even when the relay keeps **no log at all**.
*
* ⚠️ Not the switch that turns checking on — checking is automatic, see
* [enforceKeyLogIfServed]. This is the narrower question underneath it:
* what to do about a relay that publishes no log, where there is nothing to
* check a key against.
*
* Off by default because the failure it prevents is rarer than the one it
* causes. A relay predating §19 answers nothing, so turning this on there
* makes every contact unreachable, and somebody locked out of their
* messages moves to something with no ratchet at all.
*/
var requireKeyLog: Boolean = false
/**
@@ -328,8 +342,59 @@ class Trust(
* contact has always been**, not under whatever subject the relay would
* like to file it against today.
*/
fun enforceKeyLog(bundle: PeerBundle, userId: Long, fnid: String? = pinnedFnid(userId)) {
val head = head()
/**
* §19 enforcement, applied wherever the relay can answer.
*
* ## Why the head is asked for separately
*
* The obvious shape — call [enforceKeyLog] and read a 404 as "no log here"
* — hands the relay the switch. [enforceKeyLog] reads the head, then the
* device's leaves, then an inclusion proof, so a relay wanting to skip the
* check need only 404 the *middle* one to make a substituted key look like
* a deployment without transparency. The "is there a log" question is
* therefore asked once, of the head route alone, and every later 404 means
* what it has always meant: this key is not in the log.
*
* ## Why a relay cannot stop answering
*
* The pin is in the vault and outlives a restart. A relay that served a
* head before and 404s now has not lost its log — logs do not disappear —
* so that is a fork, not an absence. Without this the downgrade is one
* line: serve the log until a key is pinned, then stop.
*/
fun enforceKeyLogIfServed(
bundle: PeerBundle, userId: Long, fnid: String? = pinnedFnid(userId)
) {
val head = try {
head().also { vault.rememberKeyLogServed() }
} catch (e: RelayException.NotFound) {
// Only a route that is genuinely absent. A timeout, a 500 or a 429
// is a relay having a bad day and must not read as one without
// transparency — that would make enforcement skippable by anybody
// able to make one request fail.
if (vault.keyLogServed()) {
throw KeyLogForkException("this relay served a log before and now serves none")
}
if (requireKeyLog) throw KeyNotLoggedException(bundle.deviceId, hadEntries = false)
return
}
if (forkDetected) {
throw KeyLogForkException("a fork was already detected on this relay")
}
enforceKeyLog(bundle, userId, fnid, head)
}
/**
* [servedHead] is passed in rather than fetched again, because the caller
* has already had to ask for one to learn whether this relay keeps a log —
* and two reads could straddle an append, leaving an inclusion proof
* checked against a root this device never pinned.
*/
fun enforceKeyLog(
bundle: PeerBundle, userId: Long, fnid: String? = pinnedFnid(userId),
servedHead: Head? = null,
) {
val head = servedHead ?: head()
val pin = vault.loadKeyLogPin()
if (pin != null) {
@@ -353,24 +418,47 @@ class Trust(
// `head.audience` rather than the host we dialled: the leaf was written
// by whichever relay signed this head, and the qualified subject embeds
// that relay's own name.
val want = Wire.b64(
KeyLog.leafHash(
KeyLog.canonicalLeaf(
kind = "publish",
subject = KeyLog.logSubject(fnid, head.audience, userId),
deviceId = bundle.deviceId,
ikSigB64 = Wire.b64(bundle.identitySigning),
ikDhB64 = Wire.b64(bundle.identityDH),
//
// 🔴 **Both subject forms are accepted, and the reason is a migration
// rather than a weakness.** A leaf names its subject as `fnid:` once
// that account has claimed an address and `local:<audience>/<id>`
// before — and the leaf hash committed to whichever string was true
// when it was written. A contact who registered a device and then
// claimed an address therefore has older leaves under one form and
// newer ones under the other, so a verifier computing a single subject
// would refuse half the network on the day claiming turned on.
//
// This concedes nothing to the relay: either candidate still requires
// it to have appended a leaf binding the exact key it just served, in a
// tree it has already committed to and must prove inclusion against.
// What it gives up is the stronger property of checking the key is
// logged under *that person*, which needs the address pinned in the
// contact record.
val wants = setOf(
KeyLog.logSubject(fnid, head.audience, userId),
KeyLog.logSubject(null, head.audience, userId),
).map { subject ->
Wire.b64(
KeyLog.leafHash(
KeyLog.canonicalLeaf(
kind = "publish",
subject = subject,
deviceId = bundle.deviceId,
ikSigB64 = Wire.b64(bundle.identitySigning),
ikDhB64 = Wire.b64(bundle.identityDH),
)
)
)
)
}
val entries = relay.logDeviceEntries(bundle.deviceId).optJSONArray("entries")
?: throw KeyNotLoggedException(bundle.deviceId, hadEntries = false)
var index = -1
var want = ""
for (i in 0 until entries.length()) {
if (entries.getJSONObject(i).optString("leaf") == want) {
index = entries.getJSONObject(i).getInt("index"); break
val leaf = entries.getJSONObject(i).optString("leaf")
if (leaf in wants) {
index = entries.getJSONObject(i).getInt("index"); want = leaf; break
}
}
if (index < 0) {
@@ -466,7 +554,12 @@ class Trust(
// -- Reading the relay's log ---------------------------------------------
private data class Head(val size: Int, val root: String, val audience: String)
/**
* A verified log head. Public because [enforceKeyLog] takes one that its
* caller has already fetched — the head is part of that contract rather
* than an internal detail.
*/
data class Head(val size: Int, val root: String, val audience: String)
private fun head(): Head = relay.logHead().let {
val head = Head(it.getInt("size"), it.getString("root"), it.optString("audience"))
@@ -0,0 +1,192 @@
package io.flamenet.client
import io.flamenet.e2e.DeviceIdentity
import io.flamenet.e2e.PeerBundle
import io.flamenet.e2e.Vault
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
import javax.crypto.KeyGenerator
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue
/**
* §19 enforcement with no setting to find first.
*
* 🔴 **This used to be opt-in and off, so the protection shipped and almost
* nobody had it.** The argument for a switch — a relay predating the log has
* none, and refusing every peer there is worse than the attack — was an
* argument for skipping relays that *cannot* answer, never for skipping the
* ones that can. These tests pin that distinction, because it is the whole
* feature: the relay decides which case it is in, and it must not be able to
* choose the lenient one after having already chosen the strict one.
*/
class AutomaticKeyLogTest {
private class MapStorage : Vault.Storage {
val records = LinkedHashMap<String, ByteArray>()
override fun read(name: String) = records[name]
override fun write(name: String, bytes: ByteArray) { records[name] = bytes }
override fun delete(name: String) { records.remove(name) }
override fun names() = records.keys.toList()
}
private lateinit var server: MockWebServer
private lateinit var vault: Vault
private lateinit var identity: DeviceIdentity
@BeforeTest fun start() {
server = MockWebServer().also { it.start() }
vault = Vault(MapStorage(),
KeyGenerator.getInstance("AES").apply { init(256) }.generateKey())
identity = DeviceIdentity.create()
}
@AfterTest fun stop() = server.shutdown()
private fun trust() = Trust(identity, vault, RelayClient(server.url("/").toString(), "a", "b"))
private fun json(body: String) = MockResponse()
.setResponseCode(200).setHeader("Content-Type", "application/json").setBody(body)
/** A peer whose keys are real, so nothing here passes on a malformed bundle. */
private fun peer(): PeerBundle {
val device = DeviceIdentity.create()
return PeerBundle.fromWire(device.registrationWire())
}
/**
* A relay's log routes, answered by path.
*
* [head] null means the route is absent — a deployment from before §19.
* ⚠️ An unsigned head on purpose: `verifyHeadSignature` is silent when the
* field is empty, which is the documented behaviour for a relay that
* publishes no issuer key, and it keeps these tests about the presence
* question rather than about signing.
*/
private fun relayServing(head: String?, entries: String = """{"entries":[]}""") {
server.dispatcher = object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
val path = request.path.orEmpty()
return when {
"/e2e/log/head" in path ->
head?.let(::json) ?: MockResponse().setResponseCode(404)
"/e2e/log/device/" in path -> json(entries)
else -> MockResponse().setResponseCode(404)
}
}
}
}
private val aHead = """{"size":4,"root":"${Wire.b64(ByteArray(32) { 3 })}",""" +
""""audience":"relay.example.com"}"""
// MARK: - A relay that keeps no log
/**
* The case the old switch existed for. A relay from before §19 answers
* nothing, and refusing every contact on it would push people to something
* with no ratchet at all — a far worse outcome than the substitution being
* guarded against.
*/
@Test
fun `a relay with no log at all does not block the send`() {
relayServing(head = null)
trust().enforceKeyLogIfServed(peer(), userId = 7)
}
/** Somebody who knows their relay publishes one can still close the gap. */
@Test
fun `strict mode refuses a relay with no log`() {
relayServing(head = null)
val trust = trust().also { it.requireKeyLog = true }
assertFailsWith<KeyNotLoggedException> { trust.enforceKeyLogIfServed(peer(), userId = 7) }
}
// MARK: - A relay that keeps one
/**
* 🔴 **The behaviour change.** Before this, a relay serving a log and a key
* that is not in it was accepted without a murmur unless the user had found
* a switch in Privacy settings. Now the absent leaf is the answer.
*/
@Test
fun `a key that is not in the log is refused, with no setting turned on`() {
relayServing(head = aHead)
val trust = trust()
assertEquals(false, trust.requireKeyLog, "this must hold with the switch OFF")
assertFailsWith<KeyNotLoggedException> { trust.enforceKeyLogIfServed(peer(), userId = 7) }
}
/**
* ⚠️ Entries that exist but do not match are still a refusal, and the flag
* says which case it was — "we could not compute your subject" and "this
* key is not published" call for different things being said to the user.
*/
@Test
fun `entries that match nothing are refused and say so`() {
relayServing(head = aHead,
entries = """{"entries":[{"index":0,"leaf":"${Wire.b64(ByteArray(32))}"}]}""")
val failure = assertFailsWith<KeyNotLoggedException> {
trust().enforceKeyLogIfServed(peer(), userId = 7)
}
assertTrue(failure.hadEntries, "the device is in the log, this key is not")
}
// MARK: - The downgrade
/**
* 🔴 **The attack automatic enforcement would otherwise invite.** Skipping
* relays that keep no log means a relay can opt out of being checked — so
* publish a log until clients are talking, then stop answering, and every
* client silently goes back to trusting whatever key it is handed. Logs do
* not disappear; a relay that answered before and 404s now is not a
* deployment without transparency, it is one that changed its mind.
*/
@Test
fun `a relay that served a log and then stops is a fork, not an absence`() {
relayServing(head = aHead)
val trust = trust()
// The first attempt fails on the absent leaf, and — crucially — it also
// records that this relay answered.
assertFailsWith<KeyNotLoggedException> { trust.enforceKeyLogIfServed(peer(), userId = 7) }
assertTrue(vault.keyLogServed())
relayServing(head = null)
assertFailsWith<KeyLogForkException> {
trust().enforceKeyLogIfServed(peer(), userId = 7)
}
}
/**
* ⚠️ **A bad day is not an absence.** A timeout, a 500 or a 429 must not
* read as "this relay keeps no log", or enforcement becomes skippable by
* anybody able to make one request fail — including the relay itself, which
* would only have to be unreliable rather than dishonest.
*/
@Test
fun `a server error is not treated as a relay without a log`() {
server.dispatcher = object : Dispatcher() {
override fun dispatch(request: RecordedRequest) = MockResponse().setResponseCode(500)
}
assertFailsWith<RelayException> { trust().enforceKeyLogIfServed(peer(), userId = 7) }
}
/**
* The pin is written by the gossip path too — a head a *contact* sent — so
* inferring "this relay serves a log" from it would make a gossiped head
* start demanding a route the relay may never have had, and lock the user
* out of a deployment that was never dishonest.
*/
@Test
fun `a pin arriving from gossip does not make a logless relay look forked`() {
vault.saveKeyLogPin(Vault.KeyLogPin(9, Wire.b64(ByteArray(32) { 5 })))
relayServing(head = null)
trust().enforceKeyLogIfServed(peer(), userId = 7)
}
}
@@ -0,0 +1,253 @@
package io.flamenet.client
import io.flamenet.e2e.ContactCard
import io.flamenet.e2e.DeviceIdentity
import io.flamenet.e2e.E2ESession
import io.flamenet.e2e.PeerBundle
import io.flamenet.e2e.SealedSender
import io.flamenet.e2e.Vault
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.json.JSONObject
import javax.crypto.KeyGenerator
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* Messages that crossed a relay boundary.
*
* 🔴 **The relay records no sender for one of these**, deliberately: it was not
* told who wrote it and must not invent an attribution. So `from_user` arrives
* as 0 and the only thing naming the sender is the address sealed inside the
* blob. Every assertion here is about what happens to a message with no
* trustworthy sender on the transport — because the failure mode is not a
* crash, it is every federated message from everybody silently piling into one
* nameless thread, or worse, into a local contact's.
*
* Real ciphertext throughout: a real peer identity, a real ratchet, a real
* seal. Only the relay's HTTP is a mock.
*/
class FederatedReceiveTest {
private class MapStorage : Vault.Storage {
val records = LinkedHashMap<String, ByteArray>()
override fun read(name: String) = records[name]
override fun write(name: String, bytes: ByteArray) { records[name] = bytes }
override fun delete(name: String) { records.remove(name) }
override fun names() = records.keys.toList()
}
private lateinit var server: MockWebServer
private lateinit var vault: Vault
private lateinit var me: DeviceIdentity
@BeforeTest fun start() {
server = MockWebServer().also { it.start() }
vault = Vault(MapStorage(),
KeyGenerator.getInstance("AES").apply { init(256) }.generateKey())
me = DeviceIdentity.create()
}
@AfterTest fun stop() = server.shutdown()
private fun conversations() =
Conversations(me, vault, RelayClient(server.url("/").toString(), "a", "b"))
private fun json(body: String) = MockResponse()
.setResponseCode(200).setHeader("Content-Type", "application/json").setBody(body)
private fun myBundle(): PeerBundle {
val opk = me.generateOneTimePreKeys(1).first()
return PeerBundle.fromWire(me.registrationWire().put("opk",
JSONObject().put("id", opk.id).put("pub", Wire.b64(opk.publicKey))))
}
/**
* One envelope as a federated deposit looks on our inbox: no sender at all.
*
* ⚠️ `from_user: 0` and an empty `from_device` are not laziness in the
* fixture — they are exactly what `federationDeposit` writes, and a test
* that filled them in would be testing the local path with extra steps.
*/
private fun deposited(id: Long, payload: String) =
json("""{"messages":[{"id":$id,"from_user":0,"from_device":"","payload":"$payload"}]}""")
/** A peer on another relay, writing to us for the first time. */
private fun remotePeer(address: String, text: String): Pair<DeviceIdentity, String> {
val peer = DeviceIdentity.create()
val session = E2ESession.initiate(peer, myBundle())
val blob = SealedSender.seal(
envelope = session.encrypt(text),
fromUser = 12, // their row number, meaningless here
fromDevice = peer.deviceId,
toDevice = me.deviceId,
recipientIKdh = me.identityDHPublic,
fromAddress = address,
)
return peer to blob.toPayload()
}
private val theirAddress =
"fnid:0123456789abcdefghjkmnpqrstvwxyz0123456789abcdefghjk@peer.example.com"
// MARK: - Attribution
@Test
fun `a federated message is attributed to its address, not to a user id`() {
val (peer, payload) = remotePeer(theirAddress, "hello from over there")
server.enqueue(deposited(1, payload))
val received = conversations().poll().single()
assertEquals("hello from over there", received.text)
assertEquals(theirAddress, received.fromAddress)
assertEquals(peer.deviceId, received.fromDevice)
// 🔴 The point of the whole test. Their `from_user` of 12 is a row
// number on their relay; adopting it here would file this under
// whoever user 12 happens to be on ours.
assertEquals(0L, received.fromUser)
}
/**
* 🔴 The claim inside a seal is only a claim: anyone holding this device's
* published `IK_dh` can write any address they like there. The ratchet
* proves the DEVICE, so the two are bound first-use-wins — and a device
* that later claims somebody else's address keeps the one it was seen with.
*
* Without this, an established thread can be renamed out from under the
* person reading it, which is the whole value of a stable address undone.
*/
@Test
fun `a device that changes its claimed address keeps the first one`() {
val peer = DeviceIdentity.create()
val session = E2ESession.initiate(peer, myBundle())
fun sealed(address: String, text: String) = SealedSender.seal(
envelope = session.encrypt(text),
fromUser = 12, fromDevice = peer.deviceId,
toDevice = me.deviceId, recipientIKdh = me.identityDHPublic,
fromAddress = address,
).toPayload()
val conversations = conversations()
server.enqueue(deposited(1, sealed(theirAddress, "first")))
assertEquals(theirAddress, conversations.poll().single().fromAddress)
val impostor = "fnid:zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz@peer.example.com"
server.enqueue(deposited(2, sealed(impostor, "second")))
val second = conversations.poll().single()
assertEquals("second", second.text, "the message is genuine and still delivered")
assertEquals(theirAddress, second.fromAddress,
"a device keeps the address it was first bound to")
}
/** An ordinary local message is untouched by any of this. */
@Test
fun `a local message still has no address and keeps its user id`() {
val peer = DeviceIdentity.create()
val session = E2ESession.initiate(peer, myBundle())
server.enqueue(json("""{"messages":[{"id":1,"from_user":7,""" +
""""from_device":"${peer.deviceId}","payload":"${
session.encrypt("ordinary").toPayload()}"}]}"""))
val received = conversations().poll().single()
assertNull(received.fromAddress)
assertEquals(7L, received.fromUser)
}
// MARK: - The address list
@Test
fun `an address that has written to us becomes reachable again`() {
val (_, payload) = remotePeer(theirAddress, "hello")
server.enqueue(deposited(1, payload))
val conversations = conversations()
assertTrue(conversations.remoteAddresses().isEmpty(), "nothing before the first message")
conversations.poll()
// 🔴 Without this the conversation is write-only: the sender is in no
// roster and never can be, so with no row anywhere the only way back
// into the thread is retyping 52 characters of base32.
assertEquals(listOf(theirAddress), conversations.remoteAddresses())
}
/** Two people on other relays are two threads, not one. */
@Test
fun `two remote senders do not share a thread`() {
val other = "fnid:11111111111111111111111111111111111111111111111111@third.example.com"
val (_, first) = remotePeer(theirAddress, "from the first")
val (_, second) = remotePeer(other, "from the second")
val conversations = conversations()
server.enqueue(deposited(1, first))
server.enqueue(deposited(2, second))
val a = conversations.poll().single()
val b = conversations.poll().single()
assertNotEquals(a.fromAddress, b.fromAddress)
assertEquals(setOf(theirAddress, other), conversations.remoteAddresses().toSet())
}
// MARK: - What an address is allowed to be
/**
* ⚠️ A relay is a hostname, never a URL. An address is parsed from a seal
* written by somebody nobody authenticated, so accepting `https://x/y` here
* would let a stranger point this client at a path on a host of their
* choosing — and would do it from inside a message.
*/
@Test
fun `an address carrying a URL is not an address`() {
assertNull(ContactCard.decode("fnid:abc@https://evil.example.com/steal"))
assertNull(ContactCard.decode("fnid:abc@evil.example.com:8443"))
assertNull(ContactCard.decode("fnid:abc@evil.example.com/path"))
assertNull(ContactCard.decode("fnid:abc@localhost"), "a relay is a hostname with a dot")
assertNull(ContactCard.decode("no-at-sign.example.com"))
assertNull(ContactCard.decode("fnid:abc@a@b.example.com"))
}
/**
* Two spellings of one address would open two threads for one person, so
* everything that files a message goes through the same normaliser.
*/
@Test
fun `an address is canonicalised the same way wherever it comes from`() {
val typed = ContactCard.canonical(" FLAMENET:fnid:abc@Peer.Example.COM ")
assertEquals("fnid:abc@peer.example.com", typed)
assertEquals(typed, ContactCard.canonical("fnid:abc@peer.example.com"))
}
/**
* A federated message cannot write control state keyed by user id.
*
* 🔴 `consumeControl(0L, ...)` would poison user 0's delivery key and
* avatar — and user 0 is the id every unattributed message carries, so one
* stranger on another relay could break sealing for everybody.
*/
@Test
fun `a remote sender cannot set a local delivery key`() {
val peer = DeviceIdentity.create()
val session = E2ESession.initiate(peer, myBundle())
val control = SealedSender.deliveryKeyEnvelope(ByteArray(32) { 9 })
val blob = SealedSender.seal(
envelope = session.encrypt(control),
fromUser = 0, fromDevice = peer.deviceId,
toDevice = me.deviceId, recipientIKdh = me.identityDHPublic,
fromAddress = theirAddress,
)
server.enqueue(deposited(1, blob.toPayload()))
conversations().poll()
assertNull(vault.loadDelivery()?.peers?.get(0L),
"a federated sender must not write into the local keyspace")
}
}
@@ -66,6 +66,32 @@ class GossipHeadSentTest {
.setHeader("Content-Type", "application/json")
.setBody(body)
/**
* A relay that keeps no transparency log.
*
* ⚠️ Not incidental setup. Sending now asks for a head on its own, because
* key-log checking is automatic on any relay that serves one — so a queue
* of bodies matched purely by ORDER would hand the head request the device
* listing and fail on a parse error miles from the cause. Answering by path
* says what this fixture is: a relay from before §19, where gossip is the
* only part of transparency in play.
*/
private fun noKeyLog() {
server.dispatcher = object : okhttp3.mockwebserver.Dispatcher() {
override fun dispatch(request: okhttp3.mockwebserver.RecordedRequest): MockResponse {
val path = request.path.orEmpty()
return when {
// `contains`, not `startsWith`: every route is under the
// REST mount, so the recorded path begins with that prefix
// rather than with `/e2e`.
"/e2e/log/" in path -> MockResponse().setResponseCode(404)
path.contains("/e2e/messages") -> json("""{"ok":true}""")
else -> json("""{"devices":[${bobWire()}]}""")
}
}
}
}
/** Alice, with a head already pinned — nothing is gossiped without one. */
private fun alice(pinned: Boolean): Conversations {
val identity = DeviceIdentity.create()
@@ -77,12 +103,7 @@ class GossipHeadSentTest {
return Conversations(identity, vault, relay)
}
/** The device lookup, then the key fetch, then the accepted send. */
private fun enqueueSendExchange() {
server.enqueue(json("""{"devices":[${bobWire()}]}"""))
server.enqueue(json("""{"devices":[${bobWire()}]}"""))
server.enqueue(json("""{"ok":true}"""))
}
/** Every payload POSTed to `/e2e/messages`, as the relay would see them. */
private fun postedPayloads(): List<String> {
@@ -102,7 +123,7 @@ class GossipHeadSentTest {
@Test
fun `a send carries our log head to the contact`() {
enqueueSendExchange()
noKeyLog()
val sent = alice(pinned = true).send(toUserId = 42, text = "an ordinary message")
assertTrue(sent.failed.isEmpty(), "the send itself should succeed: ${sent.failed}")
@@ -121,7 +142,7 @@ class GossipHeadSentTest {
*/
@Test
fun `the head is encrypted, never posted in the clear`() {
enqueueSendExchange()
noKeyLog()
alice(pinned = true).send(toUserId = 42, text = "an ordinary message")
val payloads = postedPayloads()
@@ -140,8 +161,7 @@ class GossipHeadSentTest {
/** Once per contact per session — not attached to every message. */
@Test
fun `the head is not repeated on later messages`() {
enqueueSendExchange()
enqueueSendExchange()
noKeyLog()
val conversations = alice(pinned = true)
conversations.send(toUserId = 42, text = "first")
conversations.send(toUserId = 42, text = "second")
@@ -153,7 +173,7 @@ class GossipHeadSentTest {
/** Nothing to gossip before a head has ever been verified and pinned. */
@Test
fun `a client with no pinned head sends only the message`() {
enqueueSendExchange()
noKeyLog()
alice(pinned = false).send(toUserId = 42, text = "an ordinary message")
assertEquals(1, postedPayloads().size,
@@ -0,0 +1,106 @@
package io.flamenet.e2e
/**
* Everything somebody needs to reach you on another relay.
*
* `flamenet:<fnid>@<relay>` — an account address and the relay that homes it.
*
* ⚠️ **The delivery key is deliberately not part of this**, and the iOS version
* of this file carried one on a wrong premise worth recording. Three things
* were conflated: sealing an envelope uses the recipient DEVICE's `ik_dh`,
* which the remote directory returns; the delivery key is a separate
* anti-abuse capability the LOCAL relay checks before accepting unattributed
* mail; and the federated send carries no delivery key at all. Putting it in
* the address published a secret-shaped thing for nothing and doubled the
* length of every card.
*
* 🔴 What is true, and drives the send path: a relay that accepts a federated
* envelope stores it with **no sender** — it was not told who sent it and must
* not invent an attribution — so a message crossing a boundary must be sealed
* or it arrives unattributable and is dropped. Sealing is optional locally and
* mandatory here.
*/
data class ContactCard(
/** The account address, derived from the passphrase. */
val fnid: String,
/**
* The relay that homes it. Not authoritative — homes can change — but it is
* where to look first.
*/
val relay: String,
/**
* ⚠️ Kept only so an older card still parses. Nothing reads it and nothing
* should start to: see the note above.
*/
val deliveryKey: String? = null,
) {
/**
* `fnid@relay` — the address alone, no scheme and no query.
*
* 🔴 **The key every remote conversation is filed under**, on both sides:
* what a federated envelope seals as its return address, and what an
* inbound one is threaded by. [encoded] is for a human to scan or paste and
* may carry more; this is the identity and has to stay byte-stable, so it
* is built from the already-normalised fields rather than from input text.
*/
val address: String get() = "$fnid@$relay"
/**
* `flamenet:<fnid>@<relay>` — a URL scheme rather than bare text, so a QR
* reader hands it straight to the app and a card pasted into a chat is
* obviously an address rather than a word somebody typed.
*/
val encoded: String get() = "flamenet:$address"
companion object {
/**
* The stable form of an address string, or null if it is not one.
*
* ⚠️ Goes through [decode] rather than lowercasing in place, because
* the same refusals — no `/`, no `:`, a relay shaped like a hostname —
* have to apply to an address arriving inside a sealed envelope, which
* came from a peer nobody authenticated. Two spellings of one address
* would otherwise open two threads for one person.
*/
fun canonical(address: String): String? = decode(address)?.address
/**
* Parse a card, from a QR scan or a paste.
*
* ⚠️ Tolerant of a bare `fnid@relay` with no scheme, because that is
* what somebody retypes off a screen. Everything else is refused rather
* than guessed at — an address that is nearly right addresses somebody
* else.
*/
fun decode(text: String): ContactCard? {
var s = text.trim()
if (s.lowercase().startsWith("flamenet:")) s = s.substring("flamenet:".length)
var key: String? = null
val q = s.indexOf('?')
if (q >= 0) {
val query = s.substring(q + 1)
s = s.substring(0, q)
for (pair in query.split("&")) {
if (pair.startsWith("k=")) key = pair.substring(2).ifEmpty { null }
}
}
val at = s.indexOf('@')
if (at < 0) return null
val fnid = s.substring(0, at).trim()
val relay = s.substring(at + 1).trim().lowercase()
if (fnid.isEmpty() || relay.isEmpty() || '.' !in relay) return null
// A second `@` means this is not one address. Refused rather than
// split on the first, which would silently address the wrong host.
if ('@' in relay) return null
// ⚠️ A relay is a hostname, never a URL. Accepting "https://x/y"
// here would let a card point the client at a path on some other
// host of the card writer's choosing.
if ('/' in relay || ':' in relay) return null
return ContactCard(fnid, relay, key)
}
}
}
@@ -62,4 +62,43 @@ object FNID {
*/
fun claimCanonical(audience: String, userId: Long, accountKeyB64: String): ByteArray =
"fnacct-v1|$audience|$userId|$accountKeyB64".toByteArray(Charsets.UTF_8)
/**
* The 32 raw bytes behind an `fnid:` address, or null if it is not one.
*
* The address *is* the public key, which is what lets a sender check a
* home record without asking anybody whether the record is true. Decoding
* is therefore not a convenience — it is the whole verification path.
*/
fun decode(address: String): ByteArray? {
val body = address.removePrefix("fnid:")
if (body.length != ENCODED_LENGTH || body == address) return null
var buffer = 0
var bits = 0
val out = ByteArray(32)
var written = 0
for (c in body) {
val v = ALPHABET.indexOf(c)
if (v < 0) return null
buffer = (buffer shl 5) or v
bits += 5
if (bits >= 8) {
bits -= 8
if (written == out.size) return null
out[written++] = ((buffer shr bits) and 0xff).toByte()
}
}
return if (written == out.size) out else null
}
/**
* What a client signs to say which relays hold its mailboxes.
*
* The serial is inside the signature, which is what stops the rollback a
* signature alone permits: an old list is genuinely signed, so without a
* counter a relay could serve one from before a second home was added and
* quietly become the only mailbox again.
*/
fun homesCanonical(fnid: String, serial: Int, relays: List<String>): ByteArray =
"fnhomes-v1|$fnid|$serial|${relays.joinToString(",")}".toByteArray(Charsets.UTF_8)
}
@@ -61,6 +61,21 @@ object SealedSender {
data class Opened(
val claimedFromUser: Long,
val claimedFromDevice: String,
/**
* The sender's `fnid@relay`, when the message crossed a relay boundary.
*
* 🔴 **Without this a federated message is unanswerable.** The
* receiving relay records no sender for an envelope a peer deposited,
* and the sender's `from_user` is a row number on a machine we have
* never heard of — so the text decrypts, appears, and has nowhere to
* reply to. Null for an ordinary local message, where the relay's own
* routing already says who wrote it.
*
* `claimed`, like the two fields above it, and for the same reason: it
* came out of the seal, so it is what the sender asserted, not what
* anybody verified.
*/
val claimedFromAddress: String? = null,
val envelope: Envelope,
)
@@ -91,6 +106,7 @@ object SealedSender {
fromDevice: String,
toDevice: String,
recipientIKdh: ByteArray,
fromAddress: String? = null,
): Blob {
val eph = X25519.generate()
val ephPub = X25519.rawFromPublicKey(eph.public)
@@ -99,6 +115,7 @@ object SealedSender {
val inner = JSONObject()
.put("from_user", fromUser)
.put("from_device", fromDevice)
.also { if (fromAddress != null) it.put("from_addr", fromAddress) }
.put("envelope", envelope.toJson())
.toString()
.toByteArray(Charsets.UTF_8)
@@ -123,6 +140,12 @@ object SealedSender {
return Opened(
claimedFromUser = inner.optLong("from_user"),
claimedFromDevice = inner.optString("from_device"),
// ⚠️ Omitted entirely for a local seal rather than written as null,
// matching iOS — a build that predates the field must ignore one
// that carries it, and one that has it must accept a blob without.
// The seal version deliberately stays at 1: nothing about how the
// blob opens changed.
claimedFromAddress = inner.optString("from_addr").ifEmpty { null },
envelope = Envelope.fromJson(inner.getJSONObject("envelope")),
)
}
@@ -55,6 +55,20 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
private const val HISTORY_PREFIX = "history/"
private const val TRUST_PREFIX = "trust/"
private const val KEYLOG_PIN = "keylog-pin"
private const val REMOTE_ADDRESS_PREFIX = "remote-addr-"
private const val REMOTE_PIN_PREFIX = "remote-pin-"
private const val REMOTE_ADDRESSES = "remote-addresses"
/**
* A ceiling on remembered addresses.
*
* ⚠️ The key is chosen by whoever writes to us, so an unbounded list is
* storage anybody on the federation can grow. Oldest goes first, which
* costs at most a thread heading — the messages themselves are keyed by
* the binding, not by this list.
*/
private const val MAX_REMOTE_ADDRESSES = 500
private const val KEYLOG_SERVED = "keylog-served"
private const val DELIVERY_KEY = "delivery-key"
private const val STORIES = "stories"
private const val AVATARS = "avatars"
@@ -75,6 +89,17 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
fun threadForUser(userId: Long) = "user:$userId"
fun threadForGroup(groupId: String) = "group:$groupId"
/**
* A conversation with somebody on another relay.
*
* ⚠️ A THIRD prefix rather than reusing `user:`. A remote peer's user
* id belongs to their relay, so folding them into the local keyspace
* would collide two unrelated people the moment two relays happened to
* assign the same row number — silently, into a thread already showing
* somebody else's name.
*/
fun threadForAddress(address: String) = "addr:$address"
private val random = SecureRandom()
}
@@ -501,6 +526,27 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
.put("size", pin.size)
.put("root", pin.root))
/**
* Whether this relay has ever answered the head route.
*
* 🔴 **This is what stops a relay switching transparency off.** Key-log
* checking is automatic wherever a relay serves a log, which means a relay
* that serves none is skipped — so without a memory, the way out is one
* line of configuration: publish a log until clients are talking, then stop
* answering, and every client silently goes back to trusting whatever key
* it is handed.
*
* Recorded separately from [loadKeyLogPin] rather than inferred from it,
* because the two facts are different. A pin can arrive from anywhere — the
* gossip path writes one from a head a *contact* sent — while this says
* only that this device asked this relay for a head and got one. Inferring
* from the pin would make a gossiped head start demanding a log route the
* relay may never have had.
*/
fun keyLogServed(): Boolean = readJson(KEYLOG_SERVED) != null
fun rememberKeyLogServed() = writeJson(KEYLOG_SERVED, JSONObject().put("seen", true))
// -- Inbox watermark -----------------------------------------------------
/**
@@ -752,6 +798,71 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
/** Forget a binding — used when an identity change is accepted. */
fun clearDeviceOwner(deviceId: String) = storage.delete(DEVICE_OWNER_PREFIX + deviceId)
// -- The address keyspace (§20.5) ----------------------------------------
//
// A federated peer has no user id here. Their relay assigned them one and
// it means nothing on ours — user 12 there and user 12 here are unrelated
// people — so everything below is keyed by `fnid@relay` instead. Kept apart
// from the local keyspace on purpose: collapsing the two would let a claim
// about an address decide who a LOCAL user id belongs to, which is the one
// crossing a federation boundary must never be able to make.
/**
* Bind a device to the address it first claimed, first use wins.
*
* ⚠️ Call only once the inner envelope has decrypted. The address is a
* claim from inside the seal and anyone holding this device's published
* `IK_dh` can write what they like there; what the ratchet proves is the
* DEVICE. Binding the two means a device that later claims a different
* address keeps the one it was first seen with, so an established thread
* cannot be renamed out from under the person reading it.
*
* Returns the address in force, which may not be the one claimed.
*/
fun bindRemoteAddress(address: String, deviceId: String): String {
remoteAddressFor(deviceId)?.let { return it }
if (address.isEmpty()) return address
writeJson(REMOTE_ADDRESS_PREFIX + deviceId, JSONObject().put("addr", address))
return address
}
fun remoteAddressFor(deviceId: String): String? =
readJson(REMOTE_ADDRESS_PREFIX + deviceId)?.optString("addr")?.ifEmpty { null }
/**
* The identity keys pinned for an address, trust on first use.
*
* The same rule as the local pin and in the other keyspace: every key
* offered must already be known, because a key never seen before is either
* a new device of theirs or one their relay minted, and nothing on the wire
* distinguishes those.
*/
fun pinnedAddressIdentities(address: String): List<ByteArray> {
val stored = readJson(REMOTE_PIN_PREFIX + address) ?: return emptyList()
val keys = stored.optJSONArray("keys") ?: return emptyList()
return (0 until keys.length()).mapNotNull {
runCatching { Bytes.unb64(keys.getString(it)) }.getOrNull()
}
}
fun pinAddressIdentities(address: String, keys: List<ByteArray>) {
val array = org.json.JSONArray()
keys.forEach { array.put(Bytes.b64(it)) }
writeJson(REMOTE_PIN_PREFIX + address, JSONObject().put("keys", array))
}
/** Addresses this device has exchanged messages with, most recent last. */
fun remoteAddresses(): List<String> =
readJson(REMOTE_ADDRESSES)?.optJSONArray("addrs")?.let { a ->
(0 until a.length()).map { a.getString(it) }
} ?: emptyList()
fun rememberRemoteAddress(address: String) {
val current = remoteAddresses().filter { it != address } + address
writeJson(REMOTE_ADDRESSES, JSONObject().put(
"addrs", org.json.JSONArray(current.takeLast(MAX_REMOTE_ADDRESSES))))
}
// -- Contact list (P1) ---------------------------------------------------
/**