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
@@ -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) ---------------------------------------------------
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user