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
@@ -127,6 +127,18 @@ object KeyLog {
* yields a verifier that rejects honest proofs at exactly the sizes a log
* spends most of its life at.
*/
/**
* Verify the relay's signature over a log head (§19.3, audit M1).
*
* The relay signs `fnkt-v1|<size>|<root-b64>` with its issuer key. Without
* checking it, a head is an unauthenticated pair of numbers and a fork
* caught by gossip is one party's word rather than something bound to the
* relay's own key.
*/
fun verifyHead(size: Int, rootB64: String, signature: ByteArray,
issuerKey: ByteArray): Boolean =
Ed25519.verify(issuerKey, signature, "fnkt-v1|$size|$rootB64".toByteArray(Charsets.UTF_8))
fun verifyConsistency(
from: Int,
to: Int,
@@ -64,6 +64,7 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
private const val STORY_AUDIENCE = "story-audience"
private const val ROSTER = "roster"
private const val DEVICE_OWNER_PREFIX = "devowner/"
private const val KT_ISSUER_PIN = "kt-issuer-pin"
/** Kept because the whole log is re-sealed on every write. */
private const val CALL_LOG_MAX = 500
@@ -574,6 +575,23 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
fun saveStoryAudience(audience: JSONObject) = writeJson(STORY_AUDIENCE, audience)
// -- Key-log issuer pin (audit M1) ---------------------------------------
/**
* The relay's log-signing key, pinned on first sight.
*
* 🔴 Pinned, not fetched-and-trusted: a key taken fresh from the same relay
* whose signature it checks proves nothing — the relay would serve whatever
* key matches what it just signed. Pinned once, it is what makes a fork
* evidence rather than an accusation.
*/
fun keyLogIssuerPin(): ByteArray? =
readJson(KT_ISSUER_PIN)?.optString("key")?.takeIf { it.isNotEmpty() }
?.let { runCatching { Bytes.unb64(it) }.getOrNull() }
fun saveKeyLogIssuerPin(key: ByteArray) =
writeJson(KT_ISSUER_PIN, JSONObject().put("key", Bytes.b64(key)))
// -- Device → owner binding (audit C1/C2) --------------------------------
/**
@@ -0,0 +1,46 @@
package io.flamenet.e2e
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* Audit M1: a log head is only evidence if its signature verifies under the
* PINNED issuer key. These pin the exact string the relay signs
* (`fnkt-v1|<size>|<root>`) so the two implementations cannot drift apart —
* a mismatch there would silently turn every head into "unverifiable".
*/
class KeyLogHeadSignatureTest {
private val issuer = Ed25519.generate()
private fun sign(size: Int, root: String): ByteArray =
Ed25519.sign(issuer.seed, "fnkt-v1|$size|$root".toByteArray(Charsets.UTF_8))
@Test fun `a genuine head verifies`() {
val root = Bytes.b64(ByteArray(32) { 7 })
assertTrue(KeyLog.verifyHead(12, root, sign(12, root), issuer.publicKey))
}
@Test fun `a head signed for a different size is refused`() {
val root = Bytes.b64(ByteArray(32) { 7 })
assertFalse(KeyLog.verifyHead(13, root, sign(12, root), issuer.publicKey),
"a relay must not be able to replay a signature onto another size")
}
@Test fun `a head signed for a different root is refused`() {
val real = Bytes.b64(ByteArray(32) { 7 })
val forged = Bytes.b64(ByteArray(32) { 9 })
assertFalse(KeyLog.verifyHead(12, forged, sign(12, real), issuer.publicKey),
"swapping the root is exactly the fork this signature exists to catch")
}
@Test fun `a head signed by another key is refused`() {
val other = Ed25519.generate()
val root = Bytes.b64(ByteArray(32) { 7 })
val bySomeoneElse = Ed25519.sign(
other.seed, "fnkt-v1|12|$root".toByteArray(Charsets.UTF_8))
assertFalse(KeyLog.verifyHead(12, root, bySomeoneElse, issuer.publicKey),
"the PIN is what decides; a freshly served key must not substitute")
}
}