Audit M1: verify the signed log head against a pinned issuer key

The engine had no head-signature function at all and Trust.head() threw
the signature away, so a fork was an accusation rather than evidence.
KeyLog.verifyHead now checks the same string the relay signs, the issuer
key is pinned in the vault on first sight, and a head that fails under
the pin raises the standing fork alarm. Four tests pin the string and
the refusals — including a head signed by a different key, which is the
substitution the pin exists to stop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-29 08:57:44 -04:00
co-authored by Claude Opus 4.8
parent e435cd710d
commit b0c5a108b2
5 changed files with 120 additions and 1 deletions
@@ -429,6 +429,19 @@ class RelayClient(
private fun getJson(path: String, query: Map<String, String> = emptyMap()): JSONObject =
requestJson("GET", url(path, query), path, null)
/**
* `/.well-known/flamenet/relay` — the relay's self-description, carrying
* the issuer key its log heads are signed with (audit M1).
*
* ⚠️ Root-relative: [routePath] prefixes the REST mount, and a well-known
* URI is not under it. Unauthenticated by design, so no proof is signed.
*/
fun relayDescriptor(): JSONObject {
val url = base.newBuilder()
.addPathSegments(".well-known/flamenet/relay").build()
return requestJson("GET", url, "/.well-known/flamenet/relay", null)
}
private fun postJson(path: String, body: JSONObject): JSONObject =
requestJson("POST", url(path), path, body.toString().toByteArray(Charsets.UTF_8))
@@ -461,7 +461,37 @@ class Trust(
private data class Head(val size: Int, val root: String, val audience: String)
private fun head(): Head = relay.logHead().let {
Head(it.getInt("size"), it.getString("root"), it.optString("audience"))
val head = Head(it.getInt("size"), it.getString("root"), it.optString("audience"))
verifyHeadSignature(head, it.optString("signature"))
head
}
/**
* Check the relay's signature over a head against the pinned issuer key
* (§19.3, audit M1).
*
* ⚠️ Silent when nothing is published or the relay predates the field — a
* deployment that cannot sign is weaker, not hostile, and refusing it
* outright is a worse failure than the one prevented. A signature that is
* present and WRONG, or that stops matching a key already pinned, is a
* fork and is treated as one.
*/
private fun verifyHeadSignature(head: Head, signatureB64: String) {
if (signatureB64.isEmpty()) return
val signature = runCatching { Wire.unb64(signatureB64) }.getOrNull() ?: return
var issuer = vault.keyLogIssuerPin()
if (issuer == null) {
val published = runCatching {
Wire.unb64(relay.relayDescriptor().optString("issuerPublicKey"))
}.getOrNull()
if (published == null || published.isEmpty()) return
vault.saveKeyLogIssuerPin(published)
issuer = published
}
if (!KeyLog.verifyHead(head.size, head.root, signature, issuer)) {
forkDetected = true
throw KeyLogForkException("log head signature does not verify under the pinned key")
}
}
private fun consistent(from: Int, to: Int, oldRoot: String, newRoot: String): Boolean {