Android: refuse a key we have reason to distrust
Android had the transparency log's verifier and nothing that called it. The parity table listed trust state, verification, identity-change acceptance, and key-log enforcement as absent, and they were — so the relay could hand out a key it minted, the conversation would encrypt to it, and every byte on the wire would be correctly encrypted to the wrong person. A log nothing checks records the substitution faithfully and is never read. Two independent checks, because neither is sufficient alone. **Pinning, always on.** Every identity key a peer offers is compared against what was pinned on first contact; a send to a changed identity is refused until a person accepts it. The comparison is "every offered key is already pinned", not "any" — a relay adding one key of its own ALONGSIDE the real ones is the attack, and the fact that all the genuine devices still work is exactly what makes it invisible. There is a test for that case specifically. **The log, opt-in.** `require-key-log` on the Account screen makes a send demand that the relay prove the recipient's key appears in the log it publishes. Off by default for the same reason the web engine's is: a relay predating the log has none, and refusing every peer on those deployments is a worse failure than the one being prevented. `KeyLog.verifyConsistency` did not exist in the Kotlin engine. Inclusion alone is worth very little — a relay that rewrites history reissues perfectly valid inclusion proofs against its new tree, honest proofs about a dishonest log. Consistency against a head this device already pinned is the one thing it cannot produce. Ported from `engine/src/keylog.js`, which is normative, rather than re-derived: a re-derivation that accepts too much passes every honest test and fails only against an attacker. Head gossip rides along with messages and compares a peer's view against ours, which is what turns a per-device check into a detectable fork — alone, each client only knows the relay was consistent with itself. Tested, 100 engine / 52 client, none failing. Consistency is checked over EVERY pair of prefix sizes in the published vectors, because the traversal has a special case when the old size is a power of two: an implementation that gets that wrong still passes 3 -> 5 and fails 2 -> 5. The proof builder the test uses is trustworthy because the tree it builds is the one whose roots already matched the published vectors at every size. The refusals are what carry the weight, so each has its own test: a head going backwards, two roots at one size, a tampered proof node, a served key absent from the log, an inclusion proof for somebody else's leaf, and a rewritten log that cannot be reconciled with a pinned root. A trust check that accepts everything passes every happy-path test ever written. Reachable from the UI, per the standing rule. The thread shows identity state and the safety number; the changed state offers accept and nothing else, because an "ignore" affordance would be a way to silence the one signal in this product that means interception. `CrudSurfaceTest` asserts the controls exist. ⚠️ Not yet exercised against a live relay — the tests drive a real HTTP server with hand-built responses. Implemented and unit-tested, not proven in the field. ⚠️ Sealed sender is still absent, so the relay still learns who talks to whom; these protect different things. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
76d485fb55
commit
0330a7b84a
@@ -105,4 +105,66 @@ object KeyLog {
|
||||
val got = rootFromInclusion(leaf, index, size, proof) ?: return false
|
||||
return Bytes.constantTimeEquals(got, root)
|
||||
}
|
||||
|
||||
/**
|
||||
* Prove the log of size `n` still contains everything the log of size `m` did.
|
||||
*
|
||||
* 🔴 **Inclusion alone is not enough, and this is the half that says so.**
|
||||
* A relay that rewrites history can reissue perfectly valid inclusion
|
||||
* proofs against its new tree — every one of them verifies, because they
|
||||
* are honest proofs about a dishonest log. What it cannot do is produce a
|
||||
* consistency proof between the tree a client already pinned and the tree
|
||||
* it would rather serve, because no such proof exists. Checking only
|
||||
* inclusion buys nothing against the attacker this is for.
|
||||
*
|
||||
* A faithful port of `engine/src/keylog.js`, which is normative, and
|
||||
* checked against it by the interop harness. The traversal is subtle
|
||||
* enough that a re-derivation is a liability: a version that accepts too
|
||||
* much passes every honest test and fails only against an attacker.
|
||||
*
|
||||
* ⚠️ When `m` is a power of two the first node is the old root itself and
|
||||
* is **not transmitted** — the proof begins one node later. Missing that
|
||||
* yields a verifier that rejects honest proofs at exactly the sizes a log
|
||||
* spends most of its life at.
|
||||
*/
|
||||
fun verifyConsistency(
|
||||
from: Int,
|
||||
to: Int,
|
||||
proof: List<ByteArray>,
|
||||
oldRoot: ByteArray,
|
||||
newRoot: ByteArray,
|
||||
): Boolean {
|
||||
if (from <= 0 || from > to) return false
|
||||
if (from == to) return proof.isEmpty() && Bytes.constantTimeEquals(oldRoot, newRoot)
|
||||
|
||||
var fn = from - 1
|
||||
var sn = to - 1
|
||||
while (fn and 1 == 1) { fn = fn shr 1; sn = sn shr 1 }
|
||||
if (proof.isEmpty()) return false
|
||||
|
||||
var fr: ByteArray
|
||||
var sr: ByteArray
|
||||
val rest: List<ByteArray>
|
||||
if (fn == 0) {
|
||||
fr = oldRoot; sr = oldRoot; rest = proof
|
||||
} else {
|
||||
fr = proof[0]; sr = proof[0]; rest = proof.drop(1)
|
||||
}
|
||||
|
||||
for (c in rest) {
|
||||
if (sn == 0) return false
|
||||
if (fn and 1 == 1 || fn == sn) {
|
||||
fr = nodeHash(c, fr)
|
||||
sr = nodeHash(c, sr)
|
||||
while (fn != 0 && fn and 1 == 0) { fn = fn shr 1; sn = sn shr 1 }
|
||||
} else {
|
||||
sr = nodeHash(sr, c)
|
||||
}
|
||||
fn = fn shr 1
|
||||
sn = sn shr 1
|
||||
}
|
||||
return sn == 0 &&
|
||||
Bytes.constantTimeEquals(fr, oldRoot) &&
|
||||
Bytes.constantTimeEquals(sr, newRoot)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,8 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
|
||||
private const val CREDENTIAL = "credential"
|
||||
private const val SESSION_PREFIX = "session/"
|
||||
private const val HISTORY_PREFIX = "history/"
|
||||
private const val TRUST_PREFIX = "trust/"
|
||||
private const val KEYLOG_PIN = "keylog-pin"
|
||||
|
||||
/** The two kinds of thread, named once so they cannot drift apart. */
|
||||
fun threadForUser(userId: Long) = "user:$userId"
|
||||
@@ -154,6 +156,61 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
|
||||
fun knownPeers(): List<String> =
|
||||
storage.names().filter { it.startsWith(SESSION_PREFIX) }.map { it.removePrefix(SESSION_PREFIX) }
|
||||
|
||||
// -- Identity trust (§19) ------------------------------------------------
|
||||
//
|
||||
// ⚠️ **Encrypted at rest like everything else here, and that is the point
|
||||
// rather than a nicety.** These records are what the client compares a
|
||||
// served key against. Somewhere an attacker can rewrite them is somewhere
|
||||
// an attacker can pin their own key and have the comparison agree with
|
||||
// them for ever afterwards — silently, because a match is what "fine"
|
||||
// looks like.
|
||||
|
||||
private fun trustName(userId: Long) = TRUST_PREFIX + userId
|
||||
|
||||
/**
|
||||
* What we have seen this person's devices use, and whether a human checked.
|
||||
*
|
||||
* `identities` is every identity key pinned for them, base64. Plural
|
||||
* because a person is not a device: someone with a phone and a tablet has
|
||||
* two, and both are legitimately theirs.
|
||||
*/
|
||||
data class Trust(val identities: List<String>, val verified: Boolean)
|
||||
|
||||
fun loadTrust(userId: Long): Trust? = readJson(trustName(userId))?.let { o ->
|
||||
val a = o.optJSONArray("identities") ?: org.json.JSONArray()
|
||||
Trust(
|
||||
identities = (0 until a.length()).map { a.getString(it) },
|
||||
verified = o.optBoolean("verified", false),
|
||||
)
|
||||
}
|
||||
|
||||
fun saveTrust(userId: Long, trust: Trust) = writeJson(trustName(userId), JSONObject()
|
||||
.put("identities", org.json.JSONArray(trust.identities))
|
||||
.put("verified", trust.verified))
|
||||
|
||||
fun forgetTrust(userId: Long) = storage.delete(trustName(userId))
|
||||
|
||||
// -- Key-log pin (§19.3) -------------------------------------------------
|
||||
|
||||
/**
|
||||
* The head this device last accepted, and the reason a rewritten log is
|
||||
* detectable at all.
|
||||
*
|
||||
* Trust on first use: absent, the first head seen is taken and pinned.
|
||||
* Present, every later head must be provably an extension of it — so a
|
||||
* relay that rewrites history has to produce a consistency proof between
|
||||
* two trees where none exists, which is the one thing it cannot do.
|
||||
*/
|
||||
data class KeyLogPin(val size: Int, val root: String)
|
||||
|
||||
fun loadKeyLogPin(): KeyLogPin? = readJson(KEYLOG_PIN)?.let {
|
||||
KeyLogPin(it.getInt("size"), it.getString("root"))
|
||||
}
|
||||
|
||||
fun saveKeyLogPin(pin: KeyLogPin) = writeJson(KEYLOG_PIN, JSONObject()
|
||||
.put("size", pin.size)
|
||||
.put("root", pin.root))
|
||||
|
||||
// -- Inbox watermark -----------------------------------------------------
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user