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:
Paul Hitt
2026-08-30 17:29:53 -04:00
co-authored by Claude Opus 4.8
parent 366f5cbeab
commit 088fd5f5a7
13 changed files with 1189 additions and 42 deletions
@@ -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)
}