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:
co-authored by
Claude Opus 4.8
parent
e435cd710d
commit
b0c5a108b2
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user