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:
co-authored by
Claude Opus 4.8
parent
0a7c112b8b
commit
91f58956cf
@@ -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) ---------------------------------------------------
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user