`signOut` destroyed the vault unconditionally, so signing back in was a genuinely NEW device: every contact had the old key pinned, saw a safety-number change, and could not message you until they accepted it. For someone stepping away for an afternoon that is a strange price, and it is why signing out and back in kept looking like the app breaking. Two doors now, matching iOS. Plain sign-out ends the session and keeps the device, so coming back is silent. "Sign out and erase this device" is its own row, confirms in place like the delete control beside it, and retires the device on the relay BEFORE destroying the keys that authorise saying so — otherwise the account goes on publishing a mailbox nobody reads, while the relay reports every copy delivered. Also: changing your password erased the phone. `updatePassword` signed out to drop the stale credential, which is right, and took the destructive door to do it, which was not — a password change made you a new device to everyone you had ever spoken to. It takes the plain one now. Account deletion asks for the erasing door explicitly. ⚠️ Keeping the keys does not leave the mailbox reachable: a relay token is minted against the account credential, which both doors clear. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
303 lines
13 KiB
Kotlin
303 lines
13 KiB
Kotlin
package io.flamenet.client
|
|
|
|
import org.junit.jupiter.api.Assumptions.assumeTrue
|
|
import io.flamenet.e2e.DeviceIdentity
|
|
import io.flamenet.e2e.Vault
|
|
import javax.crypto.KeyGenerator
|
|
import kotlin.test.Test
|
|
import kotlin.test.assertEquals
|
|
import kotlin.test.assertFailsWith
|
|
import kotlin.test.assertNotNull
|
|
import kotlin.test.assertTrue
|
|
|
|
/**
|
|
* §19 enforcement against a relay that is actually running.
|
|
*
|
|
* ⚠️ **Opt-in via `FN_LIVE_FLEET`, and pointed at the disposable fleet rather
|
|
* than production.** It creates accounts and publishes keys, which is what a
|
|
* throwaway relay is for and what `relay.flamenet.io` is not.
|
|
*
|
|
* FN_LIVE_FLEET=https://relay1.flamenet.io ./gradlew :client:test
|
|
*
|
|
* The unit tests drive a MockWebServer with responses this repository wrote,
|
|
* which proves the verifier agrees with our idea of the relay. This proves it
|
|
* agrees with the relay — a distinction that has already cost this protocol
|
|
* three silent wire-shape bugs, every one of them invisible to a fake.
|
|
*/
|
|
class LiveKeyLogTest {
|
|
|
|
private val base: String? = System.getenv("FN_LIVE_FLEET")
|
|
|
|
|
|
/**
|
|
* ⚠️ **Skips are announced, not silent.** `return` from an unmet
|
|
* precondition makes a test that did nothing report as PASSED, and a suite
|
|
* of those is indistinguishable from a suite that works. `assumeTrue`
|
|
* reports SKIPPED, which is the truth. `LiveShapesTest` has always done it
|
|
* this way; these files did not, so 7 tests here reported green on every
|
|
* pipeline while running nothing at all.
|
|
*/
|
|
private fun needFnLiveFleet() {
|
|
LiveTarget.refuseProduction(base)
|
|
assumeTrue(base != null, "set FN_LIVE_FLEET to run this against a relay")
|
|
}
|
|
|
|
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()
|
|
}
|
|
|
|
/** A real account on the fleet, with a device registered and keys published. */
|
|
private class Actor(base: String, tag: String) {
|
|
val handle = "kt$tag${System.currentTimeMillis() % 100_000_000}"
|
|
val account = RelayAccounts(base).signUp(
|
|
username = handle,
|
|
email = "$handle@example.invalid",
|
|
password = "a-long-enough-passphrase",
|
|
)
|
|
val identity: DeviceIdentity = DeviceIdentity.create()
|
|
val vault = Vault(
|
|
MapStorage(),
|
|
KeyGenerator.getInstance("AES").apply { init(256) }.generateKey(),
|
|
)
|
|
val relay = RelayClient(base, username = account.username, appPassword = account.appPassword)
|
|
val userId: Long get() = account.userId
|
|
|
|
init {
|
|
relay.useRelayAuth(identity)
|
|
relay.registerDevice(identity, identity.generateOneTimePreKeys(5))
|
|
}
|
|
|
|
fun trust() = Trust(identity, vault, relay)
|
|
}
|
|
|
|
@Test
|
|
fun `a key this relay really published verifies against its real log`() {
|
|
needFnLiveFleet()
|
|
val url = base!!
|
|
val alice = Actor(url, "a")
|
|
val bob = Actor(url, "b")
|
|
|
|
// Alice looks Bob up the way a send does, and enforces §19 on what she
|
|
// was served. Bob genuinely published this key a moment ago, so the
|
|
// relay can prove it — and if this throws, enforcement is refusing
|
|
// honest keys, which is worse than not enforcing at all.
|
|
val bundle = alice.relay.fetchKeys(bob.userId).bundles.single { it.deviceId == bob.identity.deviceId }
|
|
val trust = alice.trust()
|
|
trust.enforceKeyLog(bundle, bob.userId)
|
|
|
|
val pin = alice.vault.loadKeyLogPin(alice.relay.host)
|
|
assertNotNull(pin, "a successful check must pin the head it verified against")
|
|
assertTrue(pin.size > 0)
|
|
}
|
|
|
|
/**
|
|
* 🔴 The check has to refuse something, or it is decorative.
|
|
*
|
|
* A device id that exists with an identity key the relay never logged for
|
|
* it. Nothing in the tree hashes to the leaf this bundle implies, so no
|
|
* proof can be produced — which is exactly the position a relay handing
|
|
* out a substituted key is in.
|
|
*/
|
|
@Test
|
|
fun `a key the relay never published is refused by the real log`() {
|
|
needFnLiveFleet()
|
|
val url = base!!
|
|
val alice = Actor(url, "c")
|
|
val bob = Actor(url, "d")
|
|
|
|
val real = alice.relay.fetchKeys(bob.userId).bundles.single { it.deviceId == bob.identity.deviceId }
|
|
val substituted = io.flamenet.e2e.PeerBundle(
|
|
deviceId = real.deviceId,
|
|
identityDH = real.identityDH,
|
|
identitySigning = DeviceIdentity.create().identitySigningPublic, // not Bob's
|
|
signedPreKeyId = real.signedPreKeyId,
|
|
signedPreKey = real.signedPreKey,
|
|
signedPreKeySignature = real.signedPreKeySignature,
|
|
oneTimePreKeyId = real.oneTimePreKeyId,
|
|
oneTimePreKey = real.oneTimePreKey,
|
|
proto = real.proto,
|
|
)
|
|
|
|
val e = assertFailsWith<KeyNotLoggedException> {
|
|
alice.trust().enforceKeyLog(substituted, bob.userId)
|
|
}
|
|
assertEquals(real.deviceId, e.deviceId)
|
|
}
|
|
|
|
/**
|
|
* The log grows constantly on a shared fleet, so a second check has to
|
|
* reconcile a pin against a bigger tree — the consistency path, against
|
|
* proofs the relay generated rather than ones this repository wrote.
|
|
*/
|
|
@Test
|
|
fun `a later head reconciles with the head we pinned earlier`() {
|
|
needFnLiveFleet()
|
|
val url = base!!
|
|
val alice = Actor(url, "e")
|
|
val bob = Actor(url, "f")
|
|
|
|
val trust = alice.trust()
|
|
val first = alice.relay.fetchKeys(bob.userId).bundles.single { it.deviceId == bob.identity.deviceId }
|
|
trust.enforceKeyLog(first, bob.userId)
|
|
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
|
|
// pinned size to the new one.
|
|
val second = Actor(url, "g")
|
|
val third = alice.relay.fetchKeys(second.userId).bundles
|
|
.single { it.deviceId == second.identity.deviceId }
|
|
trust.enforceKeyLog(third, second.userId)
|
|
|
|
val after = alice.vault.loadKeyLogPin(alice.relay.host)!!
|
|
assertTrue(after.size > pinned.size, "the log should have grown between the two checks")
|
|
}
|
|
|
|
/**
|
|
* 🔴 A pin the relay cannot reconcile must be a fork, against the real log.
|
|
*
|
|
* Same size, different root: a relay that served two trees. There is no
|
|
* proof that makes these agree and there never can be.
|
|
*/
|
|
@Test
|
|
fun `a root we never saw at our pinned size is a fork`() {
|
|
needFnLiveFleet()
|
|
val url = base!!
|
|
val alice = Actor(url, "h")
|
|
val bob = Actor(url, "i")
|
|
|
|
val head = alice.relay.logHead()
|
|
alice.vault.saveKeyLogPin(
|
|
relay = alice.relay.host, pin = Vault.KeyLogPin(head.getInt("size"), Wire.b64(ByteArray(32) { 0x5A }))
|
|
)
|
|
|
|
val bundle = alice.relay.fetchKeys(bob.userId).bundles.single { it.deviceId == bob.identity.deviceId }
|
|
assertFailsWith<KeyLogForkException> {
|
|
alice.trust().enforceKeyLog(bundle, bob.userId)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 🔴 An account with a global identity, verified through the pinned address.
|
|
*
|
|
* The relay files a leaf under an account's **fnid** once it has one, and
|
|
* under `local:<audience>/<user_id>` before that. The subject is inside the
|
|
* hashed bytes, so a verifier that does not know the address cannot
|
|
* recompute the leaf — and before contacts carried one, an honest key from
|
|
* such an account was refused. That is a false positive on a real
|
|
* conversation, which is how a security setting gets switched off.
|
|
*
|
|
* This asserts all three states, because only having all three makes the
|
|
* mechanism legible: refused with no address pinned, accepted once the
|
|
* contact carries the right one, and refused again if the address is wrong.
|
|
*/
|
|
@Test
|
|
fun `a contact carrying the peer's address verifies a key logged under it`() {
|
|
needFnLiveFleet()
|
|
val url = base!!
|
|
val alice = Actor(url, "l")
|
|
val bob = Actor(url, "m")
|
|
|
|
// Bob claims a global identity, then publishes a device under it.
|
|
val accountKey = io.flamenet.e2e.Ed25519.generate()
|
|
val accountKeyB64 = Wire.b64(accountKey.publicKey)
|
|
val audience = java.net.URI(url).host
|
|
val sig = Wire.b64(io.flamenet.e2e.Ed25519.sign(
|
|
accountKey,
|
|
io.flamenet.e2e.FNID.claimCanonical(audience, bob.userId, accountKeyB64),
|
|
))
|
|
bob.relay.claimAccountIdentity(accountKeyB64, sig)
|
|
val fnid = io.flamenet.e2e.FNID.address(accountKey.publicKey)
|
|
|
|
val second = DeviceIdentity.create()
|
|
RelayClient(url, bob.account.username, bob.account.appPassword)
|
|
.also { it.useRelayAuth(second) }
|
|
.registerDevice(second, second.generateOneTimePreKeys(3))
|
|
|
|
val bundle = alice.relay.fetchKeys(bob.userId).bundles.single { it.deviceId == second.deviceId }
|
|
val trust = alice.trust()
|
|
|
|
// 1. Nothing pinned: the honest key is refused, and says why — there
|
|
// ARE publications for this device, just none we can recompute.
|
|
val refusal = assertFailsWith<KeyNotLoggedException> { trust.enforceKeyLog(bundle, bob.userId) }
|
|
assertTrue(refusal.hadEntries, "an address miss must not read as 'never published'")
|
|
|
|
// 2. The contact learns Bob's address from his handle, the way the
|
|
// buddy list does, and the very same key now verifies — with no
|
|
// fnid passed at the call site, because it comes from the pin.
|
|
trust.rememberIdentity(bob.userId, bob.account.username)
|
|
assertEquals(fnid, trust.pinnedFnid(bob.userId))
|
|
trust.enforceKeyLog(bundle, bob.userId)
|
|
|
|
// 3. And a WRONG address still refuses, so what makes this pass is the
|
|
// address being right rather than the check having been softened.
|
|
assertFailsWith<KeyNotLoggedException> {
|
|
trust.enforceKeyLog(bundle, bob.userId, "fnid:definitelynotbob")
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A changed address is a changed identity, against the real relay.
|
|
*
|
|
* An fnid is derived from the account key, so a different one is a
|
|
* different account key — not a new device, whatever it looks like.
|
|
*/
|
|
@Test
|
|
fun `a peer whose address changes is reported as an identity change`() {
|
|
needFnLiveFleet()
|
|
val url = base!!
|
|
val alice = Actor(url, "n")
|
|
val bob = Actor(url, "o")
|
|
|
|
val trust = alice.trust()
|
|
val bundle = alice.relay.fetchKeys(bob.userId).bundles.single { it.deviceId == bob.identity.deviceId }
|
|
|
|
// Pin Bob under an address, with his real keys.
|
|
alice.vault.saveTrust(
|
|
bob.userId,
|
|
Vault.Trust(listOf(Wire.b64(bundle.identitySigning)), verified = false, fnid = "fnid:first"),
|
|
)
|
|
assertEquals(IdentityTrust.PINNED,
|
|
trust.record(bob.userId, listOf(bundle.identitySigning), "fnid:first"))
|
|
|
|
assertEquals(
|
|
IdentityTrust.CHANGED,
|
|
trust.record(bob.userId, listOf(bundle.identitySigning), "fnid:second"),
|
|
"the same keys under a new address is still a different account key",
|
|
)
|
|
}
|
|
|
|
/**
|
|
* The whole send path, refusing on its own rather than because a test
|
|
* called the checker directly.
|
|
*/
|
|
@Test
|
|
fun `a send refuses when enforcement is on and the key is not logged`() {
|
|
needFnLiveFleet()
|
|
val url = base!!
|
|
val alice = Actor(url, "j")
|
|
val bob = Actor(url, "k")
|
|
|
|
val conversations = Conversations(alice.identity, alice.vault, alice.relay)
|
|
.also { it.myUserId = alice.userId }
|
|
conversations.trust.requireKeyLog = true
|
|
|
|
// Honest first: enforcement on must NOT break an ordinary send.
|
|
val sent = conversations.send(bob.userId, "hello, with §19 on")
|
|
assertTrue(sent.reachedSomeone, "enforcement must not refuse an honestly published key")
|
|
|
|
// 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(
|
|
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")
|
|
}
|
|
}
|
|
}
|