diff --git a/client/src/main/kotlin/io/flamenet/client/RelayClient.kt b/client/src/main/kotlin/io/flamenet/client/RelayClient.kt index ba55c0e..6bb9fad 100644 --- a/client/src/main/kotlin/io/flamenet/client/RelayClient.kt +++ b/client/src/main/kotlin/io/flamenet/client/RelayClient.kt @@ -429,6 +429,19 @@ class RelayClient( private fun getJson(path: String, query: Map = 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)) diff --git a/client/src/main/kotlin/io/flamenet/client/Trust.kt b/client/src/main/kotlin/io/flamenet/client/Trust.kt index 81d6873..ffaf289 100644 --- a/client/src/main/kotlin/io/flamenet/client/Trust.kt +++ b/client/src/main/kotlin/io/flamenet/client/Trust.kt @@ -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 { diff --git a/engine/src/main/kotlin/io/flamenet/e2e/KeyLog.kt b/engine/src/main/kotlin/io/flamenet/e2e/KeyLog.kt index 46bb8ee..e61496a 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/KeyLog.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/KeyLog.kt @@ -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||` 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, diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt index 6aad2cc..00ea5e4 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt @@ -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) -------------------------------- /** diff --git a/engine/src/test/kotlin/io/flamenet/e2e/KeyLogHeadSignatureTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/KeyLogHeadSignatureTest.kt new file mode 100644 index 0000000..b027347 --- /dev/null +++ b/engine/src/test/kotlin/io/flamenet/e2e/KeyLogHeadSignatureTest.kt @@ -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||`) 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") + } +}