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
@@ -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) ---------------------------------------------------
/**