diff --git a/client/src/main/kotlin/io/flamenet/client/Conversations.kt b/client/src/main/kotlin/io/flamenet/client/Conversations.kt index 6da7c89..7e7df24 100644 --- a/client/src/main/kotlin/io/flamenet/client/Conversations.kt +++ b/client/src/main/kotlin/io/flamenet/client/Conversations.kt @@ -535,6 +535,9 @@ class Conversations( * client re-fetch and re-fail it on every poll, for ever. */ fun poll(limit: Int = 100): List { + // S4: keep auditing the log we depend on. Self-throttled (one head an + // hour) and best-effort — it only acts on a proven fork. + trust.verifyLogConsistencyIfDue() val incoming = relay.pollInbox(identity.deviceId, vault.inboxWatermark, limit) val out = mutableListOf() diff --git a/client/src/main/kotlin/io/flamenet/client/Trust.kt b/client/src/main/kotlin/io/flamenet/client/Trust.kt index f90fbd0..81d6873 100644 --- a/client/src/main/kotlin/io/flamenet/client/Trust.kt +++ b/client/src/main/kotlin/io/flamenet/client/Trust.kt @@ -34,6 +34,28 @@ enum class IdentityTrust { } /** The relay showed two different logs, or a log that shrank. */ +/** Turn a key-log entry into a sentence — S4's "name what changed". Pure. */ +object KeyChangeDescription { + fun describe(kind: String, atIso: String?): String { + val what = when (kind) { + "publish" -> "A new device key was added" + "expire" -> "A device key expired" + "revoke" -> "A device key was revoked" + else -> "Their key set changed" + } + val date = atIso?.let { + try { + val inst = java.time.Instant.parse(it) + java.time.format.DateTimeFormatter + .ofLocalizedDateTime(java.time.format.FormatStyle.MEDIUM) + .withZone(java.time.ZoneId.systemDefault()) + .format(inst) + } catch (_: Exception) { null } + } + return if (date != null) "$what on $date." else "$what." + } +} + class KeyLogForkException(detail: String) : RelayException("the relay's key log does not add up: $detail") @@ -403,6 +425,37 @@ class Trust( if (!ok) forkDetected = true } + /** When the scheduled consistency sweep last succeeded, epoch millis. */ + @Volatile private var lastLogCheck: Long = 0 + private val logCheckIntervalMs = 3_600_000L + + /** + * Verify the log is still an append-only extension of our pin, on a + * schedule rather than only when a session is being established (S4). + * + * This is what makes transparency act instead of merely record: a relay + * that quietly forked or rewound its log between two bundle fetches would + * otherwise go unseen — for an established conversation, possibly for ever. + * Best effort on the network (a failed fetch is retried next cycle); a + * proven fork raises [forkDetected], the standing alarm. Call from the + * poll loop; it self-throttles. + */ + fun verifyLogConsistencyIfDue(now: Long = System.currentTimeMillis()) { + val pin = vault.loadKeyLogPin() ?: return // nothing pinned to extend + if (now - lastLogCheck < logCheckIntervalMs) return + val head = try { head() } catch (_: Exception) { return } // network, not evidence + val ok = try { + when { + pin.size > head.size -> false // shrank + pin.size == head.size -> pin.root == head.root // same size, same root + else -> consistent(pin.size, head.size, pin.root, head.root) + } + } catch (_: Exception) { return } + if (!ok) { forkDetected = true; return } + vault.saveKeyLogPin(Vault.KeyLogPin(head.size, head.root)) + lastLogCheck = now + } + // -- Reading the relay's log --------------------------------------------- private data class Head(val size: Int, val root: String, val audience: String) diff --git a/client/src/test/kotlin/io/flamenet/client/KeyChangeDescriptionTest.kt b/client/src/test/kotlin/io/flamenet/client/KeyChangeDescriptionTest.kt new file mode 100644 index 0000000..fbd1fa9 --- /dev/null +++ b/client/src/test/kotlin/io/flamenet/client/KeyChangeDescriptionTest.kt @@ -0,0 +1,29 @@ +package io.flamenet.client + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** S4's "name what changed" formatter, pinned so the sentence can't drift. */ +class KeyChangeDescriptionTest { + + @Test fun `each kind names itself`() { + assertTrue(KeyChangeDescription.describe("publish", null).startsWith("A new device key was added")) + assertTrue(KeyChangeDescription.describe("expire", null).startsWith("A device key expired")) + assertTrue(KeyChangeDescription.describe("revoke", null).startsWith("A device key was revoked")) + } + + @Test fun `an unknown kind still reads as a change`() { + assertEquals("Their key set changed.", KeyChangeDescription.describe("wat", null)) + } + + @Test fun `a timestamp becomes a when clause`() { + val s = KeyChangeDescription.describe("publish", "2026-08-29T12:00:00Z") + assertTrue(s.contains(" on "), "expected a 'on ' clause, got: $s") + assertTrue(s.endsWith(".")) + } + + @Test fun `a bad timestamp degrades to no clause`() { + assertEquals("A new device key was added.", KeyChangeDescription.describe("publish", "not-a-date")) + } +}