Key-log state is per relay, and two bugs the new tests found
Writing tests for the multi-mailbox and key-log work turned up three things, two of them in code committed earlier today. **The "this relay serves a log" flag was global.** So was the head pin. Signing in to a second relay that keeps no log would have been read as the FIRST one going quiet — a fork warning against an honest deployment, and every contact on it unreachable. The pin was worse: a second relay's head checked against the first relay's root cannot reconcile, so the same outcome arrived by a different route. Both are now keyed by relay host. Pin reads fall back to the old global entry, so a phone already holding one keeps it rather than starting trust-on-first-use again. **A dead home relay hid every other mailbox.** `poll` read the signed-in relay first and let its failure propagate, which is precisely the situation several mailboxes exist to survive, defeated by an unguarded call. But swallowing it outright broke a property that was already tested and matters: a failed poll must not be reported as an empty inbox, because "no new messages" and "could not reach the server" are different things the app says different words for. Both hold now — every mailbox is read, and the failure is raised only if NOTHING could be read. A home that is down while a backup answers is a partial success whose mail is returned rather than thrown away with the error. **A relay is named in the record and dialled by URL, and those are two questions.** The hostname is inside the signature so it can carry no scheme and no port; how this device reaches that host is separate and now stored beside it. That is the production shape as well as what lets these paths be tested at all. New: 12 tests for the address and home record (including that a relay serving a shortened or lengthened list is caught by arithmetic), 7 for multi-mailbox reading, 10 for sending to an address, 10 for adding and dropping a mailbox, 5 for the control flag. A fixture that let a mock relay forget a record it had stored is fixed too — it made every publish after it reuse serial 1, which a real relay refuses as a rollback. Verified against a live relay: 243 client tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
366f5cbeab
commit
088fd5f5a7
@@ -796,19 +796,39 @@ class Conversations(
|
||||
// hour) and best-effort — it only acts on a proven fork.
|
||||
trust.verifyLogConsistencyIfDue()
|
||||
val out = mutableListOf<Received>()
|
||||
readInbox(relay, home = null, limit = limit, into = out)
|
||||
// 🔴 **Then every other mailbox this account keeps (§20.4).** A home
|
||||
// record tells senders to deliver a copy everywhere and the relay fans
|
||||
// out — but a copy sitting in a mailbox nothing reads is storage, not
|
||||
// redundancy. This is the half that makes losing a relay survivable in
|
||||
// practice rather than in principle.
|
||||
|
||||
// 🔴 **Two properties that pull against each other, and both hold.**
|
||||
//
|
||||
// A failed poll must not be reported as an empty inbox — "no new
|
||||
// messages" and "could not reach the server" are different things and
|
||||
// the app says different words for them. But a home relay that is down
|
||||
// must not take every OTHER mailbox down with it, which is precisely
|
||||
// the situation several mailboxes exist to survive.
|
||||
//
|
||||
// So every mailbox is read, and the failure is only raised if NOTHING
|
||||
// could be read. A home relay that is down while a backup answers is a
|
||||
// partial success the caller can act on: its mail is returned, not
|
||||
// thrown away with the error.
|
||||
val reached = mutableListOf<Boolean>()
|
||||
var firstFailure: Throwable? = null
|
||||
|
||||
runCatching { readInbox(relay, home = null, limit = limit, into = out) }
|
||||
.onSuccess { reached += true }
|
||||
.onFailure { firstFailure = it }
|
||||
|
||||
// ⚠️ After the signed-in relay and never instead of it: a backup home
|
||||
// that is slow or down must not delay the mailbox nearly all mail
|
||||
// actually arrives at.
|
||||
for (home in extraHomes()) {
|
||||
runCatching { readInbox(home.second, home.first, limit, out) }
|
||||
.onSuccess { reached += true }
|
||||
.onFailure { if (firstFailure == null) firstFailure = it }
|
||||
}
|
||||
|
||||
// ⚠️ `reached`, not `out`. An inbox that answered with nothing in it is
|
||||
// a successful poll, and treating an empty result as a failure would
|
||||
// report every quiet minute as an outage.
|
||||
if (reached.isEmpty()) firstFailure?.let { throw it }
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -822,7 +842,7 @@ class Conversations(
|
||||
private fun extraHomes(): List<Pair<String, RelayClient>> =
|
||||
vault.extraHomes().map { home ->
|
||||
home.relay to RelayClient(
|
||||
"https://${home.relay}", home.username, home.appPassword
|
||||
home.baseUrl, home.username, home.appPassword
|
||||
).also { it.useRelayAuth(identity) }
|
||||
}
|
||||
|
||||
|
||||
@@ -79,6 +79,18 @@ class MailboxHoming(
|
||||
accountKeySeed: ByteArray,
|
||||
fnid: String,
|
||||
existingHomes: List<String>,
|
||||
/**
|
||||
* How to reach [typed], which is a separate question from what it is
|
||||
* called.
|
||||
*
|
||||
* ⚠️ The signed record names a **hostname** — it is inside the
|
||||
* signature, so it can carry no scheme and no port — while this is how
|
||||
* this device dials that host. `https://<host>` is the only correct
|
||||
* answer in production and the default; naming the two separately is
|
||||
* what lets a self-hosted or test deployment reachable some other way
|
||||
* work without the record lying about its own name.
|
||||
*/
|
||||
dialAs: String? = null,
|
||||
): List<String> {
|
||||
val host = hostname(typed) ?: throw Refused(
|
||||
"That does not look like a relay's address. It is a hostname, like " +
|
||||
@@ -87,7 +99,7 @@ class MailboxHoming(
|
||||
throw Refused("That relay already holds a mailbox for you.")
|
||||
}
|
||||
|
||||
val base = "https://$host"
|
||||
val base = dialAs ?: "https://$host"
|
||||
val account = try {
|
||||
RelayAccounts(base).signIn(handle, passphrase)
|
||||
} catch (e: Exception) {
|
||||
@@ -131,7 +143,7 @@ class MailboxHoming(
|
||||
clients[primaryRelay] = primary
|
||||
for (existing in vault.extraHomes()) {
|
||||
clients[existing.relay] = RelayClient(
|
||||
"https://${existing.relay}", existing.username, existing.appPassword
|
||||
existing.baseUrl, existing.username, existing.appPassword
|
||||
).also { it.useRelayAuth(identity) }
|
||||
}
|
||||
clients[host] = client
|
||||
@@ -146,7 +158,8 @@ class MailboxHoming(
|
||||
failures[host]?.let { throw Refused("$host refused the mailbox: ${it.message}") }
|
||||
|
||||
vault.saveExtraHomes(
|
||||
vault.extraHomes() + Vault.ExtraHome(host, account.username, account.appPassword))
|
||||
vault.extraHomes() +
|
||||
Vault.ExtraHome(host, account.username, account.appPassword, base))
|
||||
return homes
|
||||
}
|
||||
|
||||
@@ -183,7 +196,7 @@ class MailboxHoming(
|
||||
for (existing in vault.extraHomes()) {
|
||||
if (existing.relay == relay) continue
|
||||
clients[existing.relay] = RelayClient(
|
||||
"https://${existing.relay}", existing.username, existing.appPassword
|
||||
existing.baseUrl, existing.username, existing.appPassword
|
||||
).also { it.useRelayAuth(identity) }
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,16 @@ class RelayClient(
|
||||
|
||||
private val base: HttpUrl = baseUrl.trimEnd('/').toHttpUrl()
|
||||
|
||||
/**
|
||||
* The relay this client talks to, for anything that has to remember a fact
|
||||
* about a specific one — §19's "has this relay ever served a log".
|
||||
*
|
||||
* ⚠️ The host, not the URL. Handing out the base would let a caller build a
|
||||
* second client for the same relay with different credentials, which is the
|
||||
* one thing this type exists to keep in one place.
|
||||
*/
|
||||
val host: String get() = base.host
|
||||
|
||||
/**
|
||||
* Spaces are stripped because the account service prints application
|
||||
* passwords in readable groups, and users paste what they were shown.
|
||||
|
||||
@@ -366,13 +366,13 @@ class Trust(
|
||||
bundle: PeerBundle, userId: Long, fnid: String? = pinnedFnid(userId)
|
||||
) {
|
||||
val head = try {
|
||||
head().also { vault.rememberKeyLogServed() }
|
||||
head().also { vault.rememberKeyLogServed(relay.host) }
|
||||
} 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()) {
|
||||
if (vault.keyLogServed(relay.host)) {
|
||||
throw KeyLogForkException("this relay served a log before and now serves none")
|
||||
}
|
||||
if (requireKeyLog) throw KeyNotLoggedException(bundle.deviceId, hadEntries = false)
|
||||
@@ -396,7 +396,7 @@ class Trust(
|
||||
) {
|
||||
val head = servedHead ?: head()
|
||||
|
||||
val pin = vault.loadKeyLogPin()
|
||||
val pin = vault.loadKeyLogPin(relay.host)
|
||||
if (pin != null) {
|
||||
// A log that shrank is not a log.
|
||||
if (pin.size > head.size) {
|
||||
@@ -478,7 +478,7 @@ class Trust(
|
||||
// A proof that does not check out IS evidence, not a naming problem.
|
||||
if (!included) throw KeyNotLoggedException(bundle.deviceId, hadEntries = false)
|
||||
|
||||
vault.saveKeyLogPin(Vault.KeyLogPin(head.size, head.root))
|
||||
vault.saveKeyLogPin(Vault.KeyLogPin(head.size, head.root), relay.host)
|
||||
}
|
||||
|
||||
// -- Gossip (§19.4) ------------------------------------------------------
|
||||
@@ -492,13 +492,13 @@ class Trust(
|
||||
* reconciling them needs a proof that cannot exist.
|
||||
*/
|
||||
fun headPayload(): String? {
|
||||
val pin = vault.loadKeyLogPin() ?: return null
|
||||
val pin = vault.loadKeyLogPin(relay.host) ?: return null
|
||||
return """{"fnc":1,"kind":"kt_head","size":${pin.size},"root":"${pin.root}"}"""
|
||||
}
|
||||
|
||||
/** Compare a peer's view of the log against ours. */
|
||||
fun gossip(size: Int, root: String) {
|
||||
val pin = vault.loadKeyLogPin() ?: return
|
||||
val pin = vault.loadKeyLogPin(relay.host) ?: return
|
||||
if (size == pin.size) {
|
||||
if (root != pin.root) forkDetected = true
|
||||
return
|
||||
@@ -537,7 +537,7 @@ class Trust(
|
||||
* poll loop; it self-throttles.
|
||||
*/
|
||||
fun verifyLogConsistencyIfDue(now: Long = System.currentTimeMillis()) {
|
||||
val pin = vault.loadKeyLogPin() ?: return // nothing pinned to extend
|
||||
val pin = vault.loadKeyLogPin(relay.host) ?: return // nothing pinned to extend
|
||||
if (now - lastLogCheck < logCheckIntervalMs) return
|
||||
val head = try { head() } catch (_: Exception) { return } // network, not evidence
|
||||
val ok = try {
|
||||
@@ -548,7 +548,7 @@ class Trust(
|
||||
}
|
||||
} catch (_: Exception) { return }
|
||||
if (!ok) { forkDetected = true; return }
|
||||
vault.saveKeyLogPin(Vault.KeyLogPin(head.size, head.root))
|
||||
vault.saveKeyLogPin(Vault.KeyLogPin(head.size, head.root), relay.host)
|
||||
lastLogCheck = now
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
package io.flamenet.client
|
||||
|
||||
import io.flamenet.e2e.Ed25519
|
||||
import io.flamenet.e2e.FNID
|
||||
import okhttp3.mockwebserver.Dispatcher
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import okhttp3.mockwebserver.RecordedRequest
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.security.SecureRandom
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* An address nobody issued, and the record that says where mail for it waits.
|
||||
*
|
||||
* 🔴 **The relay must not be able to lie about either.** The address is the
|
||||
* public half of a key derived from the passphrase, so claiming it proves
|
||||
* possession and nothing else — a relay confirms, it does not vouch. The home
|
||||
* record is signed by that same key, so a relay serving a truncated list is
|
||||
* caught by arithmetic rather than by trust. These tests are about the places
|
||||
* that could quietly stop being true.
|
||||
*/
|
||||
class AccountIdentityTest {
|
||||
|
||||
private lateinit var server: MockWebServer
|
||||
|
||||
@BeforeTest fun start() { server = MockWebServer().also { it.start() } }
|
||||
@AfterTest fun stop() { server.shutdown() }
|
||||
|
||||
private fun json(body: String) = MockResponse()
|
||||
.setResponseCode(200).setHeader("Content-Type", "application/json").setBody(body)
|
||||
|
||||
private fun seed(): ByteArray = ByteArray(32).also { SecureRandom().nextBytes(it) }
|
||||
|
||||
private fun relay() = RelayClient(server.url("/").toString(), "a", "b")
|
||||
|
||||
// MARK: - Claiming
|
||||
|
||||
/**
|
||||
* 🔴 **The audience is not the caller's to guess.** It is the string the
|
||||
* relay checks the signature against, so a caller inferring 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.
|
||||
*/
|
||||
fun claimingSignsWhatTheRelaySaysItsNameIs() {}
|
||||
|
||||
@Test
|
||||
fun `a claim is signed over the audience the relay reports`() {
|
||||
val seed = seed()
|
||||
val key = Ed25519.fromSeed(seed)
|
||||
val expected = FNID.address(key.publicKey)
|
||||
var seenBody: JSONObject? = null
|
||||
|
||||
server.dispatcher = object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest): MockResponse = when {
|
||||
".well-known/flamenet/relay" in request.path.orEmpty() ->
|
||||
json("""{"audience":"relay.example.com","issuerPublicKey":""}""")
|
||||
"/e2e/account/identity" in request.path.orEmpty() -> {
|
||||
seenBody = JSONObject(request.body.readUtf8())
|
||||
json("""{"ok":true,"fnid":"$expected","created":true}""")
|
||||
}
|
||||
else -> MockResponse().setResponseCode(404)
|
||||
}
|
||||
}
|
||||
|
||||
assertEquals(expected, AccountIdentity.claim(relay(), seed, userId = 42))
|
||||
|
||||
val body = seenBody!!
|
||||
val signature = Wire.unb64(body.getString("sig"))
|
||||
assertTrue(
|
||||
Ed25519.verify(key.publicKey, signature,
|
||||
FNID.claimCanonical("relay.example.com", 42, body.getString("account_key"))),
|
||||
"the claim must verify against the audience the relay named",
|
||||
)
|
||||
// ⚠️ And not against a different one. A claim captured at one relay must
|
||||
// not be replayable at another — the same account key is MEANT to be
|
||||
// registered in several places, but each registration has to be an act
|
||||
// the holder took there.
|
||||
assertFalse(
|
||||
Ed25519.verify(key.publicKey, signature,
|
||||
FNID.claimCanonical("other.example.com", 42, body.getString("account_key"))))
|
||||
}
|
||||
|
||||
/** The address is derived, never taken from the relay's reply. */
|
||||
@Test
|
||||
fun `the address is a function of the seed alone`() {
|
||||
val seed = seed()
|
||||
assertEquals(AccountIdentity.address(seed), AccountIdentity.address(seed))
|
||||
assertEquals(FNID.ENCODED_LENGTH + "fnid:".length, AccountIdentity.address(seed).length)
|
||||
org.junit.Assert.assertNotEquals(AccountIdentity.address(seed), AccountIdentity.address(seed()))
|
||||
}
|
||||
|
||||
// MARK: - Home records
|
||||
|
||||
private fun signedRecord(seed: ByteArray, serial: Int, relays: List<String>): JSONObject {
|
||||
val fnid = AccountIdentity.address(seed)
|
||||
val sig = Wire.b64(Ed25519.sign(seed, FNID.homesCanonical(fnid, serial, relays)))
|
||||
return JSONObject()
|
||||
.put("fnid", fnid).put("serial", serial)
|
||||
.put("relays", JSONArray(relays)).put("sig", sig)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a record signed by the account verifies`() {
|
||||
val seed = seed()
|
||||
val record = signedRecord(seed, 3, listOf("a.example.com", "b.example.com"))
|
||||
assertEquals(listOf("a.example.com", "b.example.com"),
|
||||
AccountIdentity.verifiedRelays(record))
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 **A relay serving a truncated list is the exact failure multi-homing
|
||||
* exists to survive.** Drop a home from the record and it stops being
|
||||
* signed — the sender then delivers everywhere the real record names,
|
||||
* because it checks rather than trusts.
|
||||
*/
|
||||
@Test
|
||||
fun `a shortened list does not verify`() {
|
||||
val seed = seed()
|
||||
val record = signedRecord(seed, 3, listOf("a.example.com", "b.example.com"))
|
||||
record.put("relays", JSONArray(listOf("a.example.com")))
|
||||
assertNull(AccountIdentity.verifiedRelays(record),
|
||||
"a relay removed itself from the competition and was believed")
|
||||
}
|
||||
|
||||
/** And neither does an added one, which is the same attack pointed outward. */
|
||||
@Test
|
||||
fun `a relay cannot add itself to somebody's record`() {
|
||||
val seed = seed()
|
||||
val record = signedRecord(seed, 3, listOf("a.example.com"))
|
||||
record.put("relays", JSONArray(listOf("a.example.com", "attacker.example.com")))
|
||||
assertNull(AccountIdentity.verifiedRelays(record))
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ 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.
|
||||
*/
|
||||
@Test
|
||||
fun `the serial is bound into the signature`() {
|
||||
val seed = seed()
|
||||
val record = signedRecord(seed, 7, listOf("a.example.com"))
|
||||
record.put("serial", 2)
|
||||
assertNull(AccountIdentity.verifiedRelays(record))
|
||||
}
|
||||
|
||||
/** A record for somebody else's address is not this account's record. */
|
||||
@Test
|
||||
fun `a record signed by a different key does not verify`() {
|
||||
val record = signedRecord(seed(), 1, listOf("a.example.com"))
|
||||
record.put("fnid", AccountIdentity.address(seed()))
|
||||
assertNull(AccountIdentity.verifiedRelays(record))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a malformed record is refused rather than guessed at`() {
|
||||
assertNull(AccountIdentity.verifiedRelays(JSONObject()))
|
||||
assertNull(AccountIdentity.verifiedRelays(
|
||||
JSONObject().put("fnid", "not-an-address").put("serial", 1)
|
||||
.put("relays", JSONArray(listOf("a.example.com"))).put("sig", "!!")))
|
||||
}
|
||||
|
||||
// MARK: - Serial numbers
|
||||
|
||||
/**
|
||||
* ⚠️ Read from what is already published, not from local state. A device
|
||||
* 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.
|
||||
*/
|
||||
@Test
|
||||
fun `the next serial is one past the highest any home admits to`() {
|
||||
val seed = seed()
|
||||
val fnid = AccountIdentity.address(seed)
|
||||
server.dispatcher = object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest) =
|
||||
json(signedRecord(seed, 11, listOf("a.example.com")).toString())
|
||||
}
|
||||
assertEquals(12, AccountIdentity.nextSerial(fnid, listOf(relay())))
|
||||
}
|
||||
|
||||
/** A home that answers nothing does not drag the serial backwards. */
|
||||
@Test
|
||||
fun `a home with no record contributes nothing`() {
|
||||
server.dispatcher = object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest) = MockResponse().setResponseCode(404)
|
||||
}
|
||||
assertEquals(1, AccountIdentity.nextSerial(AccountIdentity.address(seed()),
|
||||
listOf(relay())))
|
||||
}
|
||||
|
||||
// MARK: - Publishing
|
||||
|
||||
/**
|
||||
* Every listed relay is told, not only the one being added. A home that
|
||||
* does not know it is one still accepts mail, but a sender asking IT for
|
||||
* the record gets a stale list and skips the others — so the redundancy
|
||||
* would quietly depend on which relay the sender happened to ask.
|
||||
*/
|
||||
@Test
|
||||
fun `publishing reaches every home and reports the ones that refuse`() {
|
||||
val seed = seed()
|
||||
val fnid = AccountIdentity.address(seed)
|
||||
|
||||
val good = MockWebServer().also { it.start() }
|
||||
good.dispatcher = object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest) = json("""{"ok":true}""")
|
||||
}
|
||||
val bad = MockWebServer().also { it.start() }
|
||||
bad.dispatcher = object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest) =
|
||||
MockResponse().setResponseCode(500).setBody("{}")
|
||||
}
|
||||
|
||||
val failures = AccountIdentity.publishHomes(
|
||||
seed, fnid, serial = 4,
|
||||
homes = mapOf(
|
||||
"good.example.com" to RelayClient(good.url("/").toString(), "a", "b"),
|
||||
"bad.example.com" to RelayClient(bad.url("/").toString(), "a", "b"),
|
||||
))
|
||||
|
||||
assertEquals(setOf("bad.example.com"), failures.keys,
|
||||
"a partial success has to say WHICH copies landed")
|
||||
good.shutdown(); bad.shutdown()
|
||||
}
|
||||
|
||||
/** What goes out is signed over the list as sent, in order. */
|
||||
@Test
|
||||
fun `the published record is signed over the relays it names`() {
|
||||
val seed = seed()
|
||||
val fnid = AccountIdentity.address(seed)
|
||||
var body: JSONObject? = null
|
||||
server.dispatcher = object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
body = JSONObject(request.body.readUtf8())
|
||||
return json("""{"ok":true}""")
|
||||
}
|
||||
}
|
||||
|
||||
AccountIdentity.publishHomes(seed, fnid, serial = 2,
|
||||
homes = mapOf("only.example.com" to relay()))
|
||||
|
||||
val sent = body!!
|
||||
val relays = sent.getJSONArray("relays").let { a ->
|
||||
(0 until a.length()).map { a.getString(it) }
|
||||
}
|
||||
assertEquals(listOf("only.example.com"), relays)
|
||||
assertTrue(AccountIdentity.verifyHomes(fnid, sent.getInt("serial"), relays,
|
||||
sent.getString("sig")))
|
||||
}
|
||||
}
|
||||
@@ -155,7 +155,7 @@ class AutomaticKeyLogTest {
|
||||
// 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())
|
||||
assertTrue(vault.keyLogServed(server.url("/").host))
|
||||
|
||||
relayServing(head = null)
|
||||
assertFailsWith<KeyLogForkException> {
|
||||
@@ -177,6 +177,23 @@ class AutomaticKeyLogTest {
|
||||
assertFailsWith<RelayException> { trust().enforceKeyLogIfServed(peer(), userId = 7) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 **The flag is per relay, and the first version was not.** A single
|
||||
* global one meant that signing in to a second relay which keeps no log
|
||||
* read as the FIRST going quiet — so an honest deployment without
|
||||
* transparency became a fork warning, and every contact on it became
|
||||
* unreachable.
|
||||
*/
|
||||
@Test
|
||||
fun `the served flag is remembered per relay`() {
|
||||
relayServing(head = aHead)
|
||||
assertFailsWith<KeyNotLoggedException> { trust().enforceKeyLogIfServed(peer(), userId = 7) }
|
||||
|
||||
assertTrue(vault.keyLogServed(server.url("/").host))
|
||||
assertEquals(false, vault.keyLogServed("somewhere.else"),
|
||||
"one relay answering must say nothing about another")
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
@@ -73,7 +73,7 @@ class LiveKeyLogTest {
|
||||
val trust = alice.trust()
|
||||
trust.enforceKeyLog(bundle, bob.userId)
|
||||
|
||||
val pin = alice.vault.loadKeyLogPin()
|
||||
val pin = alice.vault.loadKeyLogPin(alice.relay.host)
|
||||
assertNotNull(pin, "a successful check must pin the head it verified against")
|
||||
assertTrue(pin.size > 0)
|
||||
}
|
||||
@@ -125,7 +125,7 @@ class LiveKeyLogTest {
|
||||
val trust = alice.trust()
|
||||
val first = alice.relay.fetchKeys(bob.userId).single { it.deviceId == bob.identity.deviceId }
|
||||
trust.enforceKeyLog(first, bob.userId)
|
||||
val pinned = alice.vault.loadKeyLogPin()!!
|
||||
val pinned = alice.vault.loadKeyLogPin(alice.relay.host)!!
|
||||
|
||||
// Grow the log by publishing another device, then check again. The
|
||||
// second call must produce and verify a real consistency proof from the
|
||||
@@ -135,7 +135,7 @@ class LiveKeyLogTest {
|
||||
.single { it.deviceId == second.identity.deviceId }
|
||||
trust.enforceKeyLog(third, second.userId)
|
||||
|
||||
val after = alice.vault.loadKeyLogPin()!!
|
||||
val after = alice.vault.loadKeyLogPin(alice.relay.host)!!
|
||||
assertTrue(after.size > pinned.size, "the log should have grown between the two checks")
|
||||
}
|
||||
|
||||
@@ -153,7 +153,7 @@ class LiveKeyLogTest {
|
||||
|
||||
val head = alice.relay.logHead()
|
||||
alice.vault.saveKeyLogPin(
|
||||
Vault.KeyLogPin(head.getInt("size"), Wire.b64(ByteArray(32) { 0x5A }))
|
||||
relay = alice.relay.host, pin = Vault.KeyLogPin(head.getInt("size"), Wire.b64(ByteArray(32) { 0x5A }))
|
||||
)
|
||||
|
||||
val bundle = alice.relay.fetchKeys(bob.userId).single { it.deviceId == bob.identity.deviceId }
|
||||
@@ -271,7 +271,7 @@ class LiveKeyLogTest {
|
||||
// Then pin a root the relay cannot reconcile, and watch the same send
|
||||
// path refuse rather than encrypting to a key it cannot vouch for.
|
||||
alice.vault.saveKeyLogPin(
|
||||
Vault.KeyLogPin(alice.relay.logHead().getInt("size"), Wire.b64(ByteArray(32) { 0x11 }))
|
||||
relay = alice.relay.host, pin = Vault.KeyLogPin(alice.relay.logHead().getInt("size"), Wire.b64(ByteArray(32) { 0x11 }))
|
||||
)
|
||||
assertFailsWith<KeyLogForkException> {
|
||||
conversations.send(bob.userId, "this one must not go out")
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
package io.flamenet.client
|
||||
|
||||
import io.flamenet.e2e.DeviceIdentity
|
||||
import io.flamenet.e2e.Ed25519
|
||||
import io.flamenet.e2e.FNID
|
||||
import io.flamenet.e2e.Vault
|
||||
import okhttp3.mockwebserver.Dispatcher
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import okhttp3.mockwebserver.RecordedRequest
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.security.SecureRandom
|
||||
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.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Adding and dropping a mailbox (spec §20.4).
|
||||
*
|
||||
* 🔴 **Your address survives a relay disappearing; your mailbox does not.** So
|
||||
* the order of operations here is the whole substance: nothing is written down
|
||||
* locally until the relay has actually accepted a registration, and the record
|
||||
* is published everywhere rather than only at the relay being added. A home
|
||||
* that does not know it is one still accepts mail, but a sender asking IT for
|
||||
* the record gets a stale list and skips the others — the redundancy would
|
||||
* quietly depend on which relay the sender happened to ask.
|
||||
*/
|
||||
class MailboxHomingTest {
|
||||
|
||||
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 home: MockWebServer
|
||||
private lateinit var newHome: MockWebServer
|
||||
private lateinit var vault: Vault
|
||||
private lateinit var identity: DeviceIdentity
|
||||
|
||||
private val seed = ByteArray(32).also { SecureRandom().nextBytes(it) }
|
||||
private val fnid get() = AccountIdentity.address(seed)
|
||||
|
||||
/** Records each relay was asked to store, so "was it told" is checkable. */
|
||||
private val published = LinkedHashMap<String, MutableList<JSONObject>>()
|
||||
|
||||
/**
|
||||
* What each relay would serve back if asked.
|
||||
*
|
||||
* ⚠️ Separate from [published], which a test may clear to check who was
|
||||
* told THIS time. A relay does not forget a record because the test stopped
|
||||
* watching, and `nextSerial` reads exactly this — so sharing one map made
|
||||
* every publish after a clear reuse serial 1, which a real relay would
|
||||
* refuse as a rollback.
|
||||
*/
|
||||
private val served = LinkedHashMap<String, JSONObject>()
|
||||
private var newHomeRejectsSignIn = false
|
||||
private var newHomeRejectsRecord = false
|
||||
|
||||
@BeforeTest fun start() {
|
||||
identity = DeviceIdentity.create()
|
||||
vault = Vault(MapStorage(),
|
||||
KeyGenerator.getInstance("AES").apply { init(256) }.generateKey())
|
||||
vault.saveIdentity(identity)
|
||||
home = MockWebServer().also { it.dispatcher = relay("home"); it.start() }
|
||||
newHome = MockWebServer().also { it.dispatcher = relay("new"); it.start() }
|
||||
}
|
||||
|
||||
@AfterTest fun stop() { home.shutdown(); newHome.shutdown() }
|
||||
|
||||
private fun json(body: String) = MockResponse()
|
||||
.setResponseCode(200).setHeader("Content-Type", "application/json").setBody(body)
|
||||
|
||||
private fun relay(tag: String) = object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
val path = request.path.orEmpty()
|
||||
return when {
|
||||
"account/signin" in path -> {
|
||||
if (tag == "new" && newHomeRejectsSignIn) MockResponse().setResponseCode(401)
|
||||
else json("""{"user_id":9,"username":"me","display_name":"Me","app_password":"pw"}""")
|
||||
}
|
||||
".well-known/flamenet/relay" in path ->
|
||||
json("""{"audience":"$tag.example.com","issuerPublicKey":""}""")
|
||||
"/e2e/devices" in path -> json("""{"ok":true,"device_id":"d","opk_remaining":0}""")
|
||||
"/e2e/account/identity" in path ->
|
||||
json("""{"ok":true,"fnid":"$fnid","created":true}""")
|
||||
"/e2e/account/homes" in path && request.method == "POST" -> {
|
||||
if (tag == "new" && newHomeRejectsRecord) {
|
||||
MockResponse().setResponseCode(400).setBody("{}")
|
||||
} else {
|
||||
val record = JSONObject(request.body.readUtf8())
|
||||
published.getOrPut(tag) { mutableListOf() }.add(record)
|
||||
served[tag] = record
|
||||
json("""{"ok":true}""")
|
||||
}
|
||||
}
|
||||
// 🔴 Serves back what it was told. `nextSerial` reads the
|
||||
// published records to decide the next one, so a relay that
|
||||
// stores a record and then 404s it would have every publish
|
||||
// reuse serial 1 — and the second would be refused as a
|
||||
// rollback by the very check that stops an attacker replaying
|
||||
// an old list. A mock that only accepts writes hides that.
|
||||
"/e2e/account/homes/" in path ->
|
||||
served[tag]?.let {
|
||||
json(JSONObject()
|
||||
.put("fnid", fnid)
|
||||
.put("serial", it.getInt("serial"))
|
||||
.put("relays", it.getJSONArray("relays"))
|
||||
.put("sig", it.getString("sig")).toString())
|
||||
} ?: MockResponse().setResponseCode(404)
|
||||
else -> MockResponse().setResponseCode(404)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun homing() = MailboxHoming(
|
||||
vault, identity, "home.example.com",
|
||||
RelayClient(home.url("/").toString(), "me", "pw"))
|
||||
|
||||
/**
|
||||
* ⚠️ The record names `new.example.com`; the URL this device dials is the
|
||||
* mock server. That split is the production shape too — the hostname is
|
||||
* inside the signature and cannot carry a port.
|
||||
*/
|
||||
private fun add(existing: List<String> = emptyList()) =
|
||||
homing().add("new.example.com", handle = "me", passphrase = "pass",
|
||||
accountKeySeed = seed, fnid = fnid, existingHomes = existing,
|
||||
dialAs = newHome.url("/").toString())
|
||||
|
||||
// MARK: - What a relay's name is allowed to be
|
||||
|
||||
/**
|
||||
* ⚠️ A relay is a hostname, never a URL. This is parsed from something a
|
||||
* person typed or pasted, and accepting `https://x/y` would let it point
|
||||
* the device at a path on a host of somebody else's choosing — and then
|
||||
* send that host a signed record naming it as a mailbox.
|
||||
*/
|
||||
@Test
|
||||
fun `only a hostname is accepted as a relay`() {
|
||||
assertEquals("relay.example.com", MailboxHoming.hostname(" Relay.Example.COM "))
|
||||
for (bad in listOf("https://relay.example.com", "relay.example.com/path",
|
||||
"relay.example.com:8443", "me@relay.example.com",
|
||||
"localhost", "", "relay example com")) {
|
||||
assertNull(MailboxHoming.hostname(bad), "accepted $bad")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a relay already holding mail is not added twice`() {
|
||||
assertFailsWith<MailboxHoming.Refused> {
|
||||
homing().add("home.example.com", "me", "pass", seed, fnid, emptyList())
|
||||
}
|
||||
assertFailsWith<MailboxHoming.Refused> {
|
||||
homing().add("new.example.com", "me", "pass", seed, fnid, listOf("new.example.com"))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Nothing is written down until the relay agrees
|
||||
|
||||
/**
|
||||
* 🔴 A half-added home — stored locally, unknown to the relay — would be
|
||||
* polled forever and would show on screen as a mailbox that never has
|
||||
* anything in it.
|
||||
*/
|
||||
@Test
|
||||
fun `a relay that refuses the sign-in leaves nothing behind`() {
|
||||
newHomeRejectsSignIn = true
|
||||
assertFailsWith<MailboxHoming.Refused> { add() }
|
||||
assertTrue(vault.extraHomes().isEmpty(), "a refused relay was stored anyway")
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 **The NEW relay accepting is what decides.** A record every old home
|
||||
* took but the new one refused describes a mailbox that does not exist, and
|
||||
* a sender told to deliver there gets nothing.
|
||||
*/
|
||||
@Test
|
||||
fun `a relay that refuses the record is not remembered as a mailbox`() {
|
||||
newHomeRejectsRecord = true
|
||||
assertFailsWith<MailboxHoming.Refused> { add() }
|
||||
assertTrue(vault.extraHomes().isEmpty())
|
||||
}
|
||||
|
||||
// MARK: - The record goes everywhere
|
||||
|
||||
@Test
|
||||
fun `adding a mailbox tells both relays, and each gets the same list`() {
|
||||
val homes = add()
|
||||
|
||||
assertEquals(listOf("home.example.com", "new.example.com"), homes)
|
||||
assertEquals(setOf("home", "new"), published.keys,
|
||||
"a home that was not told serves a stale list to senders")
|
||||
|
||||
for ((tag, records) in published) {
|
||||
val record = records.single()
|
||||
val relays = record.getJSONArray("relays").let { a ->
|
||||
(0 until a.length()).map { a.getString(it) }
|
||||
}
|
||||
assertEquals(homes, relays, "$tag was told a different list")
|
||||
assertTrue(
|
||||
AccountIdentity.verifyHomes(fnid, record.getInt("serial"), relays,
|
||||
record.getString("sig")),
|
||||
"$tag was sent a record that does not verify",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** And the credential for the new mailbox is kept, or it cannot be read. */
|
||||
@Test
|
||||
fun `the new mailbox is remembered with a credential of its own`() {
|
||||
add()
|
||||
val stored = vault.extraHomes().single()
|
||||
assertEquals("new.example.com", stored.relay)
|
||||
assertEquals("pw", stored.appPassword)
|
||||
assertEquals(listOf("new.example.com"), homing().known())
|
||||
}
|
||||
|
||||
// MARK: - Dropping one
|
||||
|
||||
/**
|
||||
* ⚠️ **The shorter list is published first and the credential goes only if
|
||||
* that worked.** Forgetting locally while senders are still told to deliver
|
||||
* there leaves mail arriving at a mailbox this device no longer reads —
|
||||
* which, from the sender's side, is indistinguishable from delivery.
|
||||
*/
|
||||
@Test
|
||||
fun `removing a mailbox publishes the shorter list and then forgets it`() {
|
||||
add()
|
||||
published.clear()
|
||||
|
||||
homing().remove("new.example.com", seed, fnid,
|
||||
listOf("home.example.com", "new.example.com"))
|
||||
|
||||
assertTrue(vault.extraHomes().isEmpty(), "the credential outlived the mailbox")
|
||||
// 🔴 The relay being dropped is NOT asked to accept the new record. It
|
||||
// is the one party with a reason to refuse, and it is not in the list
|
||||
// any more so it would refuse on those grounds regardless.
|
||||
assertEquals(setOf("home"), published.keys)
|
||||
val relays = published["home"]!!.single().getJSONArray("relays")
|
||||
assertEquals(1, relays.length())
|
||||
assertEquals("home.example.com", relays.getString(0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the relay you are signed in to cannot be removed`() {
|
||||
assertFailsWith<MailboxHoming.Refused> {
|
||||
homing().remove("home.example.com", seed, fnid, listOf("home.example.com"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an account cannot be left with no mailbox at all`() {
|
||||
assertFailsWith<MailboxHoming.Refused> {
|
||||
homing().remove("new.example.com", seed, fnid, listOf("new.example.com"))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ The serial has to move forward or the relay refuses the record as a
|
||||
* rollback — by the very check that stops an attacker replaying an old,
|
||||
* genuinely signed list that happens to name only itself.
|
||||
*/
|
||||
@Test
|
||||
fun `each published record carries a serial above the last`() {
|
||||
add()
|
||||
val first = published.values.first().single().getInt("serial")
|
||||
published.clear()
|
||||
homing().remove("new.example.com", seed, fnid,
|
||||
listOf("home.example.com", "new.example.com"))
|
||||
val second = published["home"]!!.single().getInt("serial")
|
||||
assertTrue(second > first, "serial went $first -> $second")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
package io.flamenet.client
|
||||
|
||||
import io.flamenet.e2e.DeviceIdentity
|
||||
import io.flamenet.e2e.E2ESession
|
||||
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 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.assertTrue
|
||||
|
||||
/**
|
||||
* Reading mail from more than one relay (spec §20.4).
|
||||
*
|
||||
* 🔴 **Publishing a home record is only half of it.** The record tells senders
|
||||
* to deliver a copy everywhere and the relay fans out — but a copy sitting in a
|
||||
* mailbox nothing reads is storage, not redundancy. These tests are about the
|
||||
* half that reads, and about the three ways it goes wrong quietly: a copy read
|
||||
* twice, one relay's cursor applied to another's ids, and a backup mailbox's
|
||||
* bad day taking the signed-in relay down with it.
|
||||
*
|
||||
* Real ciphertext throughout: a real peer, a real ratchet, real envelopes. Only
|
||||
* the HTTP is a mock.
|
||||
*/
|
||||
class MultiMailboxPollTest {
|
||||
|
||||
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 home: MockWebServer
|
||||
private lateinit var backup: MockWebServer
|
||||
private lateinit var vault: Vault
|
||||
private lateinit var me: DeviceIdentity
|
||||
|
||||
/** What each mailbox is holding: id to payload, plus an envelope id. */
|
||||
private val homeQueue = mutableListOf<Triple<Long, String, String>>()
|
||||
private val backupQueue = mutableListOf<Triple<Long, String, String>>()
|
||||
/** Set to make the backup answer 410 — a device revoked at a BACKUP home. */
|
||||
private var backupGone = false
|
||||
private var homeDown = false
|
||||
|
||||
/** The host the record names, distinct from the URL this device dials. */
|
||||
private val backupHost get() = "backup.example.com"
|
||||
|
||||
@BeforeTest fun start() {
|
||||
home = MockWebServer().also { it.dispatcher = queueOf({ homeQueue }, { homeDown }); it.start() }
|
||||
backup = MockWebServer().also { it.dispatcher = queueOf({ backupQueue }, { backupGone }); it.start() }
|
||||
vault = Vault(MapStorage(),
|
||||
KeyGenerator.getInstance("AES").apply { init(256) }.generateKey())
|
||||
me = DeviceIdentity.create()
|
||||
}
|
||||
|
||||
@AfterTest fun stop() { home.shutdown(); backup.shutdown() }
|
||||
|
||||
/**
|
||||
* A relay that serves one mailbox.
|
||||
*
|
||||
* ⚠️ 404s the transparency-log routes: these fixtures are pre-§19
|
||||
* deployments, so the tests are about mailboxes and nothing else.
|
||||
*/
|
||||
private fun queueOf(
|
||||
rows: () -> List<Triple<Long, String, String>>,
|
||||
gone: () -> Boolean,
|
||||
) = object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
val path = request.path.orEmpty()
|
||||
if ("/e2e/log/" in path) return MockResponse().setResponseCode(404)
|
||||
if ("/e2e/messages" in path) {
|
||||
if (gone()) return MockResponse().setResponseCode(410)
|
||||
val after = Regex("after=(\\d+)").find(path)?.groupValues?.get(1)?.toLong() ?: 0
|
||||
val body = rows().filter { it.first > after }.joinToString(",") { (id, payload, env) ->
|
||||
"""{"id":$id,"from_user":7,"from_device":"","payload":"$payload","envelope_id":"$env"}"""
|
||||
}
|
||||
return json("""{"messages":[$body],"cursor":0}""")
|
||||
}
|
||||
return MockResponse().setResponseCode(404)
|
||||
}
|
||||
}
|
||||
|
||||
private fun json(body: String) = MockResponse()
|
||||
.setResponseCode(200).setHeader("Content-Type", "application/json").setBody(body)
|
||||
|
||||
private fun conversations(): Conversations {
|
||||
// ⚠️ Written into the vault rather than passed in: the client rebuilds
|
||||
// its list of homes from the vault on every poll, which is what lets a
|
||||
// mailbox added or dropped mid-session take effect without a relaunch.
|
||||
vault.saveExtraHomes(listOf(Vault.ExtraHome(
|
||||
relay = backupHost, username = "me", appPassword = "pw",
|
||||
// ⚠️ The record names a hostname and cannot carry a port; how this
|
||||
// device DIALS that host is a separate field, which is what lets a
|
||||
// test — or a self-hoster on a nonstandard port — work without the
|
||||
// record lying about its own name.
|
||||
baseUrl = backup.url("/").toString())))
|
||||
return Conversations(me, vault, RelayClient(home.url("/").toString(), "me", "pw"))
|
||||
}
|
||||
|
||||
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 real envelope from a peer, ready to be put in a mailbox. */
|
||||
private fun envelope(session: E2ESession, text: String) = session.encrypt(text).toPayload()
|
||||
|
||||
// MARK: - Reading
|
||||
|
||||
/**
|
||||
* 🔴 The whole point. A relay this device is not signed in to is holding
|
||||
* mail, and without this the message is simply never read.
|
||||
*/
|
||||
@Test
|
||||
fun `a message waiting at another relay is read`() {
|
||||
val peer = DeviceIdentity.create()
|
||||
val session = E2ESession.initiate(peer, myBundle())
|
||||
backupQueue += Triple(900L, envelope(session, "waiting at the other one"), "env-1")
|
||||
|
||||
val received = conversations().poll()
|
||||
|
||||
assertEquals(1, received.size, "got ${received.map { it.text }}")
|
||||
assertEquals("waiting at the other one", received.single().text)
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ **A copy at every home is the design, so a copy read twice is the
|
||||
* failure.** And it would not present as a duplicate: the ratchet deletes a
|
||||
* message key once used, so the second copy fails to open and is logged as
|
||||
* a dropped envelope — a message that looks lost rather than repeated.
|
||||
*/
|
||||
@Test
|
||||
fun `the same message at two relays is read once`() {
|
||||
val peer = DeviceIdentity.create()
|
||||
val session = E2ESession.initiate(peer, myBundle())
|
||||
val payload = envelope(session, "delivered twice")
|
||||
// The SAME envelope id, because the sender stamps one per message and
|
||||
// the two copies are the same message.
|
||||
homeQueue += Triple(1L, payload, "env-same")
|
||||
backupQueue += Triple(900L, payload, "env-same")
|
||||
|
||||
val received = conversations().poll()
|
||||
|
||||
assertEquals(1, received.count { it.text == "delivered twice" },
|
||||
"one message, however many mailboxes carried it")
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 Envelope ids come from each relay's own database, so id 900 there says
|
||||
* nothing about id 900 here. One shared cursor would let the first
|
||||
* mailbox's progress silently skip the second's mail.
|
||||
*/
|
||||
@Test
|
||||
fun `each mailbox keeps its own cursor`() {
|
||||
val peer = DeviceIdentity.create()
|
||||
val session = E2ESession.initiate(peer, myBundle())
|
||||
backupQueue += Triple(900L, envelope(session, "only at the backup"), "env-a")
|
||||
|
||||
val conversations = conversations()
|
||||
assertEquals(1, conversations.poll().size)
|
||||
|
||||
assertEquals(900L, vault.watermarkFor(backupHost), "the backup's cursor has to move")
|
||||
assertEquals(0L, vault.inboxWatermark,
|
||||
"one relay's ids must not advance another relay's cursor")
|
||||
|
||||
// And a second poll does not re-read what the backup already gave us.
|
||||
assertTrue(conversations.poll().isEmpty(), "a second poll re-read the same mailbox")
|
||||
}
|
||||
|
||||
// MARK: - When something goes wrong
|
||||
|
||||
/**
|
||||
* 🔴 **A 410 from a BACKUP home is that mailbox ending, not this account
|
||||
* ending.** Treating it like the signed-in relay's 410 would purge
|
||||
* somebody's messages because one spare relay dropped a registration.
|
||||
*/
|
||||
@Test
|
||||
fun `a revoked backup mailbox does not stop the signed-in one`() {
|
||||
backupGone = true
|
||||
val peer = DeviceIdentity.create()
|
||||
val session = E2ESession.initiate(peer, myBundle())
|
||||
homeQueue += Triple(1L, envelope(session, "through the front door"), "env-b")
|
||||
|
||||
assertEquals("through the front door", conversations().poll().single().text)
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 **The failure this whole feature exists to survive.** A home relay that
|
||||
* is down must not take every other mailbox down with it — which is exactly
|
||||
* what happens if the poll returns on the first error instead of carrying
|
||||
* on to the others.
|
||||
*/
|
||||
@Test
|
||||
fun `a dead home relay does not hide the backup mailbox`() {
|
||||
homeDown = true
|
||||
val peer = DeviceIdentity.create()
|
||||
val session = E2ESession.initiate(peer, myBundle())
|
||||
backupQueue += Triple(900L, envelope(session, "read me anyway"), "env-c")
|
||||
|
||||
assertEquals("read me anyway", conversations().poll().single().text)
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ **A failed poll is still not an empty inbox.** "No new messages" and
|
||||
* "could not reach anything" are different, and the app says different
|
||||
* words for them — so when nothing at all could be read, the failure is
|
||||
* raised rather than swallowed into an empty list.
|
||||
*/
|
||||
@Test
|
||||
fun `when no mailbox can be read at all the failure is raised`() {
|
||||
homeDown = true
|
||||
backupGone = true
|
||||
var threw = false
|
||||
try { conversations().poll() } catch (_: Exception) { threw = true }
|
||||
assertTrue(threw, "every mailbox failed and it was reported as an empty inbox")
|
||||
}
|
||||
|
||||
/** With no extra homes at all, nothing about the ordinary path changes. */
|
||||
@Test
|
||||
fun `one mailbox behaves exactly as before`() {
|
||||
val peer = DeviceIdentity.create()
|
||||
val session = E2ESession.initiate(peer, myBundle())
|
||||
homeQueue += Triple(1L, envelope(session, "just the one"), "env-d")
|
||||
|
||||
val conversations =
|
||||
Conversations(me, vault, RelayClient(home.url("/").toString(), "me", "pw"))
|
||||
assertEquals("just the one", conversations.poll().single().text)
|
||||
assertEquals(1L, vault.inboxWatermark)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
package io.flamenet.client
|
||||
|
||||
import io.flamenet.e2e.ContactCard
|
||||
import io.flamenet.e2e.DeviceIdentity
|
||||
import io.flamenet.e2e.SealedSender
|
||||
import io.flamenet.e2e.Vault
|
||||
import okhttp3.mockwebserver.Dispatcher
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import okhttp3.mockwebserver.RecordedRequest
|
||||
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.assertFailsWith
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Sending to somebody on another relay (spec §20.5).
|
||||
*
|
||||
* 🔴 **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.
|
||||
* Locally, sealing is an improvement; across a boundary it is the only thing
|
||||
* that makes the message deliverable at all.
|
||||
*
|
||||
* The other half is the return address. Their relay records none, and our user
|
||||
* id means nothing on it, so a message without one decrypts on their phone and
|
||||
* has nowhere to reply to — which reads to the recipient as the app being
|
||||
* broken rather than as a message being incomplete.
|
||||
*/
|
||||
class SendToAddressTest {
|
||||
|
||||
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
|
||||
private lateinit var peer: DeviceIdentity
|
||||
|
||||
/** The last body posted to `/e2e/send/remote`. */
|
||||
private var sentBody: JSONObject? = null
|
||||
private var accepted = 1
|
||||
private var unknown = listOf<String>()
|
||||
private var unreachable = listOf<String>()
|
||||
/** Devices the far relay reports for the address, empty for "nobody there". */
|
||||
private var remoteDevices: () -> String = { "" }
|
||||
|
||||
private val theirCard = ContactCard(
|
||||
"fnid:0123456789abcdefghjkmnpqrstvwxyz0123456789abcdefghjk", "peer.example.com")
|
||||
|
||||
@BeforeTest fun start() {
|
||||
me = DeviceIdentity.create()
|
||||
peer = DeviceIdentity.create()
|
||||
remoteDevices = { peer.registrationWire().toString() }
|
||||
server = MockWebServer().also { it.dispatcher = relay(); it.start() }
|
||||
vault = Vault(MapStorage(),
|
||||
KeyGenerator.getInstance("AES").apply { init(256) }.generateKey())
|
||||
vault.saveIdentity(me)
|
||||
}
|
||||
|
||||
@AfterTest fun stop() = server.shutdown()
|
||||
|
||||
private fun relay() = object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
val path = request.path.orEmpty()
|
||||
return when {
|
||||
"/e2e/log/" in path -> MockResponse().setResponseCode(404)
|
||||
"/e2e/send/remote" in path -> {
|
||||
sentBody = JSONObject(request.body.readUtf8())
|
||||
json(JSONObject()
|
||||
.put("ok", true).put("accepted", accepted)
|
||||
.put("unknown", org.json.JSONArray(unknown))
|
||||
.put("unreachable", org.json.JSONArray(unreachable)).toString())
|
||||
}
|
||||
"/e2e/remote/" in path ->
|
||||
json("""{"devices":[${remoteDevices()}]}""")
|
||||
else -> MockResponse().setResponseCode(404)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun json(body: String) = MockResponse()
|
||||
.setResponseCode(200).setHeader("Content-Type", "application/json").setBody(body)
|
||||
|
||||
private fun conversations(withAddress: Boolean = true): Conversations =
|
||||
Conversations(me, vault, RelayClient(server.url("/").toString(), "me", "pw")).also {
|
||||
it.myUserId = 5
|
||||
if (withAddress) it.myAddress = "fnid:mine@home.example.com"
|
||||
}
|
||||
|
||||
// MARK: - The happy path
|
||||
|
||||
@Test
|
||||
fun `a message to an address goes out sealed and names where to reply`() {
|
||||
val sent = conversations().sendToAddress(theirCard, "across the boundary")
|
||||
|
||||
assertTrue(sent.failed.isEmpty(), "failed: ${sent.failed}")
|
||||
assertTrue(sent.sealed, "a federated envelope that is not sealed is undeliverable")
|
||||
|
||||
val body = sentBody!!
|
||||
assertEquals(theirCard.fnid, body.getString("to_fnid"))
|
||||
assertEquals(theirCard.relay, body.getString("to_relay"))
|
||||
|
||||
val messages = body.getJSONArray("messages")
|
||||
assertEquals(1, messages.length())
|
||||
val payload = messages.getJSONObject(0).getString("payload")
|
||||
assertTrue(SealedSender.isSealed(payload), "the envelope reached the wire unsealed")
|
||||
|
||||
// 🔴 The return address is inside the seal, where the relay cannot read
|
||||
// it and cannot strip it.
|
||||
val opened = SealedSender.open(
|
||||
SealedSender.blobFromPayload(payload), peer.identityDH.private, peer.deviceId)
|
||||
assertEquals("fnid:mine@home.example.com", opened.claimedFromAddress)
|
||||
assertEquals(me.deviceId, opened.claimedFromDevice)
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ One id 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.
|
||||
*/
|
||||
@Test
|
||||
fun `every copy of one message carries the same envelope id`() {
|
||||
val second = DeviceIdentity.create()
|
||||
remoteDevices = {
|
||||
peer.registrationWire().toString() + "," + second.registrationWire().toString()
|
||||
}
|
||||
|
||||
conversations().sendToAddress(theirCard, "to both of their devices")
|
||||
|
||||
val messages = sentBody!!.getJSONArray("messages")
|
||||
assertEquals(2, messages.length(), "both of their devices must be addressed")
|
||||
assertEquals(messages.getJSONObject(0).getString("envelope_id"),
|
||||
messages.getJSONObject(1).getString("envelope_id"))
|
||||
}
|
||||
|
||||
/** The address is remembered, or the conversation is write-only. */
|
||||
@Test
|
||||
fun `a sent-to address becomes reachable again`() {
|
||||
val conversations = conversations()
|
||||
assertTrue(conversations.remoteAddresses().isEmpty())
|
||||
conversations.sendToAddress(theirCard, "hello")
|
||||
assertEquals(listOf(theirCard.address), conversations.remoteAddresses())
|
||||
}
|
||||
|
||||
// MARK: - Refusals
|
||||
|
||||
/**
|
||||
* 🔴 **Refused up front rather than sent into a dead end.** Their relay
|
||||
* records no sender, so without our address the message decrypts on their
|
||||
* phone and has nowhere to reply to.
|
||||
*/
|
||||
@Test
|
||||
fun `a device with no address of its own will not send`() {
|
||||
val sent = conversations(withAddress = false).sendToAddress(theirCard, "hello")
|
||||
assertFalse(sent.reachedSomeone)
|
||||
assertTrue(sent.failed.values.single().contains("address"),
|
||||
"the refusal has to say why: ${sent.failed}")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an address with no devices is a failure, not a silent success`() {
|
||||
remoteDevices = { "" }
|
||||
val sent = conversations().sendToAddress(theirCard, "hello")
|
||||
assertFalse(sent.reachedSomeone)
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 **`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.
|
||||
*/
|
||||
@Test
|
||||
fun `taking no copies is a failed send whatever else the reply says`() {
|
||||
accepted = 0
|
||||
val sent = conversations().sendToAddress(theirCard, "hello")
|
||||
assertFalse(sent.reachedSomeone, "zero accepted was reported as delivered")
|
||||
}
|
||||
|
||||
/** And an address the far relay does not know says exactly that. */
|
||||
@Test
|
||||
fun `an unknown address is named as one`() {
|
||||
accepted = 0
|
||||
unknown = listOf(theirCard.fnid)
|
||||
val sent = conversations().sendToAddress(theirCard, "hello")
|
||||
assertTrue(sent.failed.values.single().contains("does not know"),
|
||||
"got: ${sent.failed}")
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ 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.
|
||||
*/
|
||||
@Test
|
||||
fun `a mailbox that did not take the copy is reported`() {
|
||||
unreachable = listOf("backup.example.com")
|
||||
val sent = conversations().sendToAddress(theirCard, "hello")
|
||||
assertTrue(sent.reachedSomeone, "the message did reach somewhere")
|
||||
assertTrue(sent.failed.values.single().contains("backup.example.com"),
|
||||
"got: ${sent.failed}")
|
||||
}
|
||||
|
||||
// MARK: - Trust in the address keyspace
|
||||
|
||||
/**
|
||||
* 🔴 **A key never seen before is either a new device of theirs or one their
|
||||
* relay minted, and nothing on the wire tells those apart.** The pin hangs
|
||||
* off the address, because a user id on their relay means nothing on ours.
|
||||
*/
|
||||
@Test
|
||||
fun `a changed identity key at an address refuses the send`() {
|
||||
conversations().sendToAddress(theirCard, "first")
|
||||
|
||||
// Their relay now serves a different identity for the same address.
|
||||
val impostor = DeviceIdentity.create()
|
||||
remoteDevices = { impostor.registrationWire().toString() }
|
||||
|
||||
assertFailsWith<IdentityChangedException> {
|
||||
conversations().sendToAddress(theirCard, "second")
|
||||
}
|
||||
}
|
||||
|
||||
/** The same keys a second time are simply the same person. */
|
||||
@Test
|
||||
fun `an unchanged identity sends again without ceremony`() {
|
||||
conversations().sendToAddress(theirCard, "first")
|
||||
assertTrue(conversations().sendToAddress(theirCard, "second").reachedSomeone)
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,16 @@ class TrustLogConsistencyTest {
|
||||
}
|
||||
|
||||
private lateinit var server: MockWebServer
|
||||
|
||||
/**
|
||||
* The relay the object under test is pointed at.
|
||||
*
|
||||
* ⚠️ The key-log pin is stored per relay, because a single pin meant that
|
||||
* signing in to a second relay checked ITS head against the first relay's
|
||||
* root — which cannot reconcile, so an honest relay was reported as forked.
|
||||
* A test reading the pin has to name the same relay that wrote it.
|
||||
*/
|
||||
private val relayHost: String get() = server.url("/").host
|
||||
private lateinit var vault: Vault
|
||||
private lateinit var identity: DeviceIdentity
|
||||
|
||||
@@ -110,8 +120,8 @@ class TrustLogConsistencyTest {
|
||||
t.verifyLogConsistencyIfDue()
|
||||
|
||||
assertFalse(t.forkDetected)
|
||||
assertEquals(8, vault.loadKeyLogPin()!!.size)
|
||||
assertEquals(rootB64(8), vault.loadKeyLogPin()!!.root)
|
||||
assertEquals(8, vault.loadKeyLogPin(relayHost)!!.size)
|
||||
assertEquals(rootB64(8), vault.loadKeyLogPin(relayHost)!!.root)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -129,7 +139,7 @@ class TrustLogConsistencyTest {
|
||||
|
||||
assertTrue(t.forkDetected)
|
||||
// the pin is NOT advanced onto the smaller log
|
||||
assertEquals(8, vault.loadKeyLogPin()!!.size)
|
||||
assertEquals(8, vault.loadKeyLogPin(relayHost)!!.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -166,7 +176,7 @@ class TrustLogConsistencyTest {
|
||||
t.verifyLogConsistencyIfDue()
|
||||
|
||||
assertTrue(t.forkDetected)
|
||||
assertEquals(4, vault.loadKeyLogPin()!!.size)
|
||||
assertEquals(4, vault.loadKeyLogPin(relayHost)!!.size)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -182,7 +192,7 @@ class TrustLogConsistencyTest {
|
||||
t.verifyLogConsistencyIfDue()
|
||||
|
||||
assertFalse(t.forkDetected)
|
||||
assertEquals(4, vault.loadKeyLogPin()!!.size)
|
||||
assertEquals(4, vault.loadKeyLogPin(relayHost)!!.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -34,6 +34,16 @@ class TrustTest {
|
||||
}
|
||||
|
||||
private lateinit var server: MockWebServer
|
||||
|
||||
/**
|
||||
* The relay the object under test is pointed at.
|
||||
*
|
||||
* ⚠️ The key-log pin is stored per relay, because a single pin meant that
|
||||
* signing in to a second relay checked ITS head against the first relay's
|
||||
* root — which cannot reconcile, so an honest relay was reported as forked.
|
||||
* A test reading the pin has to name the same relay that wrote it.
|
||||
*/
|
||||
private val relayHost: String get() = server.url("/").host
|
||||
private lateinit var vault: Vault
|
||||
private lateinit var identity: DeviceIdentity
|
||||
|
||||
@@ -276,7 +286,7 @@ class TrustTest {
|
||||
|
||||
// No fnid argument: it comes from the pin, which is the whole point.
|
||||
t.enforceKeyLog(b, 7)
|
||||
assertEquals(leaf, vault.loadKeyLogPin()?.root)
|
||||
assertEquals(leaf, vault.loadKeyLogPin(relayHost)?.root)
|
||||
}
|
||||
|
||||
// -- Key-log enforcement -------------------------------------------------
|
||||
@@ -331,7 +341,7 @@ class TrustTest {
|
||||
|
||||
trust().enforceKeyLog(b, 7)
|
||||
|
||||
val pin = vault.loadKeyLogPin()
|
||||
val pin = vault.loadKeyLogPin(relayHost)
|
||||
assertEquals(1, pin?.size)
|
||||
assertEquals(leaf, pin?.root)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user