Contacts carry the peer's address, so an honest key verifies
The relay files a key-log 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 enforcement refused honest keys from any account that had claimed an
identity. A false positive on a real conversation is how a security setting
gets switched off and left off, so this is the half that decides whether the
feature is usable at all.
No relay change was needed. `GET /e2e/account/identity/{handle}` already
resolves a handle to an address, unauthenticated, and the buddy list already
carries the handle. The contact record now learns the address once and pins it
beside the identity keys, and `enforceKeyLog` recomputes against the pinned one
by default.
⚠️ **Pinned, not looked up per check**, and that is the entire point. Reading it
fresh each time would let a relay that wants to substitute a key simply also
change the name it files that key under, and every verification would agree
with the new answer. Recomputing against the address this contact has always
had is what makes the check about a person rather than about whatever the relay
says today.
First contact is still trust-on-first-use. Taking the relay's word once is not
a check; what it buys is that every later answer has to match it, and the
safety number is what covers the first sighting.
🔴 A CHANGED address is an identity change, for a stronger reason than a
changed key: an fnid is derived from the account key, so a new address is not a
new device — it is a different account key, and therefore a different person.
Accepting a change re-resolves rather than keeping the old address, because if
the address is what changed, keeping it would leave every later check
recomputing against a name the account no longer publishes under, and honest
keys would stay refused for ever.
⚠️ An unreachable relay pins NOTHING rather than pinning "no address".
`rememberIdentity` does nothing once an address is pinned, so a wrong answer
would be permanent — and the next successful lookup would then read as the
address changing, which is the alarm, for a network blip.
Proven against relay1 in three states, because only all three make it legible:
refused with nothing pinned, accepted once the contact carries the right
address, and refused again with a wrong one — so what makes it pass is the
address being right, not the check having been loosened. A changed address is
asserted to come back CHANGED against the real relay too.
Tested: 100 engine, 65 client, none failing, live suites enabled.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
6a6bcc9d08
commit
cfeefa0616
@@ -168,7 +168,12 @@ class Conversations(
|
||||
val theirs = bundles.filter { it.deviceId != identity.deviceId }
|
||||
if (theirs.isEmpty()) return
|
||||
|
||||
if (trust.record(toUserId, theirs.map { it.identitySigning }) == IdentityTrust.CHANGED) {
|
||||
// The pinned address is passed back in so a CHANGED address is caught
|
||||
// here too, not only a changed key.
|
||||
val pinned = trust.pinnedFnid(toUserId)
|
||||
if (trust.record(toUserId, theirs.map { it.identitySigning }, pinned)
|
||||
== IdentityTrust.CHANGED
|
||||
) {
|
||||
throw IdentityChangedException(toUserId)
|
||||
}
|
||||
if (!trust.requireKeyLog) return
|
||||
|
||||
@@ -273,6 +273,25 @@ class RelayClient(
|
||||
.put("account_key", accountKeyB64)
|
||||
.put("sig", signatureB64))
|
||||
|
||||
/**
|
||||
* `GET /e2e/account/identity/{handle}` — the global address behind a local
|
||||
* handle, or null when that account has not claimed one.
|
||||
*
|
||||
* Unauthenticated by design: it discloses a public key's address, which was
|
||||
* already public, and a directory only this relay's own users can read is
|
||||
* not one a federated peer could use.
|
||||
*
|
||||
* ⚠️ **404 is not "no such person".** The relay answers the same way for an
|
||||
* account that does not exist and one that has never enrolled, deliberately,
|
||||
* so this cannot be used to enumerate accounts. Null here means "no address
|
||||
* to pin", nothing more.
|
||||
*/
|
||||
fun resolveIdentity(handle: String): String? = try {
|
||||
getJson("/e2e/account/identity/$handle").optString("fnid").ifEmpty { null }
|
||||
} catch (_: RelayException.NotFound) {
|
||||
null
|
||||
}
|
||||
|
||||
// -- Transparency log (§19) ---------------------------------------------
|
||||
//
|
||||
// Unauthenticated on the relay, and no proof headers are sent: the value of
|
||||
|
||||
@@ -120,12 +120,23 @@ class Trust(
|
||||
* deliberate one — the alternative is a client that cannot start a
|
||||
* conversation without an out-of-band channel it does not have.
|
||||
*/
|
||||
fun record(userId: Long, offered: List<ByteArray>): IdentityTrust {
|
||||
fun record(userId: Long, offered: List<ByteArray>, fnid: String? = null): IdentityTrust {
|
||||
val encoded = offered.map(Wire::b64)
|
||||
val existing = vault.loadTrust(userId)
|
||||
|
||||
// A different address for someone we already know is not a new device.
|
||||
// The fnid is derived from the account key, so a new one means a new
|
||||
// account key — a different person, however the relay presents it.
|
||||
if (fnid != null && existing?.fnid != null && existing.fnid != fnid) {
|
||||
return IdentityTrust.CHANGED
|
||||
}
|
||||
|
||||
if (existing == null || existing.identities.isEmpty()) {
|
||||
if (encoded.isEmpty()) return IdentityTrust.UNKNOWN
|
||||
vault.saveTrust(userId, Vault.Trust(encoded, verified = false))
|
||||
vault.saveTrust(
|
||||
userId,
|
||||
Vault.Trust(encoded, verified = false, fnid = fnid ?: existing?.fnid),
|
||||
)
|
||||
return IdentityTrust.PINNED
|
||||
}
|
||||
// ⚠️ EVERY offered key must already be pinned — not "any". A relay
|
||||
@@ -137,6 +148,52 @@ class Trust(
|
||||
return if (existing.verified) IdentityTrust.VERIFIED else IdentityTrust.PINNED
|
||||
}
|
||||
|
||||
/**
|
||||
* Learn this contact's global address and pin it.
|
||||
*
|
||||
* 🔴 **Pinned on first sight and never quietly replaced.** The address is
|
||||
* what the key log files their publications under, so it decides which leaf
|
||||
* a served key has to hash to. Re-reading it from the relay on every check
|
||||
* would let a relay that wants to substitute a key simply also change the
|
||||
* name it files it under, and every verification would agree.
|
||||
*
|
||||
* A DIFFERENT address later is therefore treated like a changed identity
|
||||
* key, and for a stronger reason than one: an fnid is derived from the
|
||||
* account key, so a new address is not a new device — it is a different
|
||||
* account key, which is to say a different person.
|
||||
*
|
||||
* ⚠️ First contact is still trust-on-first-use. Taking the relay's word
|
||||
* once is not a check; what it buys is that every later answer has to match
|
||||
* it. The safety number is what covers the first sighting, and nothing here
|
||||
* replaces reading it aloud.
|
||||
*
|
||||
* Cheap and idempotent: does nothing once an address is pinned. Safe to
|
||||
* call whenever the contact list is refreshed.
|
||||
*/
|
||||
fun rememberIdentity(userId: Long, handle: String): IdentityTrust {
|
||||
val existing = vault.loadTrust(userId)
|
||||
if (existing?.fnid != null) return state(userId)
|
||||
|
||||
val resolved = try {
|
||||
relay.resolveIdentity(handle)
|
||||
} catch (_: Exception) {
|
||||
// ⚠️ Unreachable is not "has no identity". Pinning null on a failed
|
||||
// lookup would bake a wrong answer in permanently, and the next
|
||||
// successful lookup would then read as an identity change.
|
||||
return state(userId)
|
||||
} ?: return state(userId)
|
||||
|
||||
vault.saveTrust(
|
||||
userId,
|
||||
existing?.copy(fnid = resolved)
|
||||
?: Vault.Trust(identities = emptyList(), verified = false, fnid = resolved),
|
||||
)
|
||||
return state(userId)
|
||||
}
|
||||
|
||||
/** The address this contact was pinned under, if any. */
|
||||
fun pinnedFnid(userId: Long): String? = vault.loadTrust(userId)?.fnid
|
||||
|
||||
/** Trust as it stands, for UI that has not triggered a fetch. */
|
||||
fun state(userId: Long): IdentityTrust {
|
||||
val t = vault.loadTrust(userId) ?: return IdentityTrust.UNKNOWN
|
||||
@@ -171,10 +228,19 @@ class Trust(
|
||||
* key that is no longer in use and says nothing about the new one; leaving
|
||||
* the tick in place would show "verified" for something nobody verified.
|
||||
*/
|
||||
fun acceptIdentityChange(userId: Long) {
|
||||
fun acceptIdentityChange(userId: Long, handle: String? = null) {
|
||||
val offered = relay.fetchKeys(userId).map { it.identitySigning }
|
||||
if (offered.isEmpty()) return
|
||||
vault.saveTrust(userId, Vault.Trust(offered.map(Wire::b64), verified = false))
|
||||
// Re-resolve rather than keep the old address: if what changed WAS the
|
||||
// address, keeping it would leave every later check recomputing against
|
||||
// a name this account no longer publishes under, and honest keys would
|
||||
// stay refused for ever.
|
||||
val address = handle?.let { h -> runCatching { relay.resolveIdentity(h) }.getOrNull() }
|
||||
?: vault.loadTrust(userId)?.fnid
|
||||
vault.saveTrust(
|
||||
userId,
|
||||
Vault.Trust(offered.map(Wire::b64), verified = false, fnid = address),
|
||||
)
|
||||
}
|
||||
|
||||
/** Forget a person entirely — used when they are removed as a contact. */
|
||||
@@ -187,19 +253,21 @@ class Trust(
|
||||
* there is no sensible way to continue with a key that failed this.
|
||||
*/
|
||||
/**
|
||||
* @param fnid the peer's global identity, when it is known.
|
||||
* @param fnid the peer's global identity. Defaults to the address pinned
|
||||
* for this contact by [rememberIdentity], which is the answer that should
|
||||
* almost always be used.
|
||||
*
|
||||
* ⚠️ **Required for any account that has claimed one.** The relay files a
|
||||
* leaf under the fnid once an account has it, and under
|
||||
* `local:<audience>/<user_id>` before that; the subject is inside the
|
||||
* hashed bytes, so guessing wrong makes an honest key unverifiable. Nothing
|
||||
* in the relay's contact or device routes returns a peer's fnid, so this
|
||||
* has to come from the contact record — which is stronger anyway, because
|
||||
* it checks the key was logged under *that person* rather than under
|
||||
* whatever subject the relay chose. Until contacts carry it, enforcement is
|
||||
* only reliable for accounts that have not claimed an identity.
|
||||
* ⚠️ **Required for any account that has claimed one**, because the relay
|
||||
* files a leaf under the fnid once an account has it and under
|
||||
* `local:<audience>/<user_id>` before that — and the subject is inside the
|
||||
* hashed bytes, so guessing wrong makes an honest key unverifiable.
|
||||
*
|
||||
* Taking it from the contact record rather than from the relay's current
|
||||
* answer is the point: it checks the key was logged under **the person this
|
||||
* contact has always been**, not under whatever subject the relay would
|
||||
* like to file it against today.
|
||||
*/
|
||||
fun enforceKeyLog(bundle: PeerBundle, userId: Long, fnid: String? = null) {
|
||||
fun enforceKeyLog(bundle: PeerBundle, userId: Long, fnid: String? = pinnedFnid(userId)) {
|
||||
val head = head()
|
||||
|
||||
val pin = vault.loadKeyLogPin()
|
||||
|
||||
@@ -163,27 +163,21 @@ class LiveKeyLogTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 What an account with a global identity does to enforcement.
|
||||
* 🔴 An account with a global identity, verified through the pinned address.
|
||||
*
|
||||
* The relay files a leaf under the account's **fnid** once it has one, and
|
||||
* 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 guesses wrong cannot recompute the leaf
|
||||
* and cannot find it — and the failure is [KeyNotLoggedException], which
|
||||
* reads as "this key was never published" when the key was published
|
||||
* perfectly well under a name the verifier did not know.
|
||||
* 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.
|
||||
*
|
||||
* That is a **false positive on an honest key**, which is the worst kind of
|
||||
* failure a check like this can have: it refuses real conversations and
|
||||
* teaches people to turn the setting off.
|
||||
*
|
||||
* This test pins the behaviour both ways so the limitation is a fact in the
|
||||
* suite rather than a comment. The fix is for the contact record to carry
|
||||
* the peer's fnid — which is materially stronger anyway, because it checks
|
||||
* the key was logged under *that person* rather than under whatever subject
|
||||
* the relay chose to file it against.
|
||||
* 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 `an account with an fnid needs its fnid to verify, and is refused without one`() {
|
||||
fun `a contact carrying the peer's address verifies a key logged under it`() {
|
||||
val url = base ?: return
|
||||
val alice = Actor(url, "l")
|
||||
val bob = Actor(url, "m")
|
||||
@@ -199,25 +193,61 @@ class LiveKeyLogTest {
|
||||
bob.relay.claimAccountIdentity(accountKeyB64, sig)
|
||||
val fnid = io.flamenet.e2e.FNID.address(accountKey.publicKey)
|
||||
|
||||
// A device published AFTER the claim is logged under the fnid.
|
||||
val second = DeviceIdentity.create()
|
||||
val bobSecond = RelayClient(url, bob.account.username, bob.account.appPassword)
|
||||
RelayClient(url, bob.account.username, bob.account.appPassword)
|
||||
.also { it.useRelayAuth(second) }
|
||||
bobSecond.registerDevice(second, second.generateOneTimePreKeys(3))
|
||||
.registerDevice(second, second.generateOneTimePreKeys(3))
|
||||
|
||||
val bundle = alice.relay.fetchKeys(bob.userId).single { it.deviceId == second.deviceId }
|
||||
val trust = alice.trust()
|
||||
|
||||
// Without the fnid the honest key is refused — but reported as "there
|
||||
// are publications, none of which match", not as "never published".
|
||||
val refusal = assertFailsWith<KeyNotLoggedException> {
|
||||
alice.trust().enforceKeyLog(bundle, bob.userId)
|
||||
// 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")
|
||||
}
|
||||
assertTrue(
|
||||
refusal.hadEntries,
|
||||
"an fnid-subject miss must be distinguishable from a key that was never logged",
|
||||
}
|
||||
|
||||
/**
|
||||
* 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`() {
|
||||
val url = base ?: return
|
||||
val alice = Actor(url, "n")
|
||||
val bob = Actor(url, "o")
|
||||
|
||||
val trust = alice.trust()
|
||||
val bundle = alice.relay.fetchKeys(bob.userId).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",
|
||||
)
|
||||
// With it, the very same key verifies.
|
||||
alice.trust().enforceKeyLog(bundle, bob.userId, fnid)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -130,6 +130,97 @@ class TrustTest {
|
||||
assertEquals(60, t.safetyNumber(7)?.length)
|
||||
}
|
||||
|
||||
// -- Global address (fnid) -----------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `an address is resolved once and pinned`() {
|
||||
server.enqueue(json("""{"handle":"bob","fnid":"fnid:abc","audience":"r"}"""))
|
||||
val t = trust()
|
||||
t.rememberIdentity(7, "bob")
|
||||
assertEquals("fnid:abc", t.pinnedFnid(7))
|
||||
|
||||
// Idempotent: no second request, so the queue staying empty is the
|
||||
// assertion. A lookup per check is a lookup a relay can answer
|
||||
// differently each time.
|
||||
t.rememberIdentity(7, "bob")
|
||||
assertEquals("fnid:abc", t.pinnedFnid(7))
|
||||
assertEquals(1, server.requestCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an account with no address pins nothing and is not an error`() {
|
||||
server.enqueue(MockResponse().setResponseCode(404).setBody("""{"reason":"no such identity"}"""))
|
||||
val t = trust()
|
||||
t.rememberIdentity(7, "bob")
|
||||
assertNull(t.pinnedFnid(7))
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ An unreachable relay must not pin "no address".
|
||||
*
|
||||
* Baking that in would be permanent — `rememberIdentity` does nothing once
|
||||
* an address is pinned — and the next successful lookup would then look
|
||||
* like the address changing, which is the alarm.
|
||||
*/
|
||||
@Test
|
||||
fun `a failed lookup pins nothing rather than pinning absence`() {
|
||||
server.enqueue(MockResponse().setResponseCode(503).setBody("down"))
|
||||
val t = trust()
|
||||
t.rememberIdentity(7, "bob")
|
||||
assertNull(t.pinnedFnid(7))
|
||||
|
||||
server.enqueue(json("""{"handle":"bob","fnid":"fnid:abc","audience":"r"}"""))
|
||||
t.rememberIdentity(7, "bob")
|
||||
assertEquals("fnid:abc", t.pinnedFnid(7))
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 A different address is a different account key, so a different person.
|
||||
*
|
||||
* Not a new device, whatever the relay presents it as: the fnid is derived
|
||||
* from the account key.
|
||||
*/
|
||||
@Test
|
||||
fun `a changed address is an identity change`() {
|
||||
server.enqueue(json("""{"handle":"bob","fnid":"fnid:abc","audience":"r"}"""))
|
||||
val t = trust()
|
||||
t.rememberIdentity(7, "bob")
|
||||
t.record(7, listOf(key(1)), "fnid:abc")
|
||||
assertEquals(IdentityTrust.PINNED, t.state(7))
|
||||
|
||||
assertEquals(IdentityTrust.CHANGED, t.record(7, listOf(key(1)), "fnid:different"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the same address with the same keys stays pinned`() {
|
||||
server.enqueue(json("""{"handle":"bob","fnid":"fnid:abc","audience":"r"}"""))
|
||||
val t = trust()
|
||||
t.rememberIdentity(7, "bob")
|
||||
t.record(7, listOf(key(1)), "fnid:abc")
|
||||
assertEquals(IdentityTrust.PINNED, t.record(7, listOf(key(1)), "fnid:abc"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `enforcement recomputes against the pinned address by default`() {
|
||||
server.enqueue(json("""{"handle":"bob","fnid":"fnid:abc","audience":"r"}"""))
|
||||
val t = trust()
|
||||
t.rememberIdentity(7, "bob")
|
||||
|
||||
val b = bundle("d1", key(1))
|
||||
// The leaf the relay would have written for an account WITH this fnid.
|
||||
val leaf = Wire.b64(KeyLog.leafHash(KeyLog.canonicalLeaf(
|
||||
kind = "publish", subject = "fnid:abc", deviceId = b.deviceId,
|
||||
ikSigB64 = Wire.b64(key(1)), ikDhB64 = Wire.b64(key(2)),
|
||||
)))
|
||||
server.enqueue(json("""{"size":1,"root":"$leaf","audience":"r"}"""))
|
||||
server.enqueue(json("""{"device_id":"d1","size":1,"entries":[{"index":0,"kind":"publish","leaf":"$leaf"}]}"""))
|
||||
server.enqueue(json("""{"index":0,"size":1,"leaf":"$leaf","root":"$leaf","proof":[]}"""))
|
||||
|
||||
// No fnid argument: it comes from the pin, which is the whole point.
|
||||
t.enforceKeyLog(b, 7)
|
||||
assertEquals(leaf, vault.loadKeyLogPin()?.root)
|
||||
}
|
||||
|
||||
// -- Key-log enforcement -------------------------------------------------
|
||||
|
||||
@Test
|
||||
|
||||
Reference in New Issue
Block a user