S4: transparency that acts — schedule the check, name the change

Mirror of iOS: the poll loop runs a throttled, best-effort key-log
consistency sweep so a fork between bundle fetches is caught on a live
conversation; and KeyChangeDescription turns a log entry into a named,
dated sentence for the changed-keys warning. Both pure pieces are
unit-tested.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-29 08:01:33 -04:00
co-authored by Claude Opus 4.8
parent 6baaf1315a
commit 68329bcf0f
3 changed files with 85 additions and 0 deletions
@@ -535,6 +535,9 @@ class Conversations(
* client re-fetch and re-fail it on every poll, for ever.
*/
fun poll(limit: Int = 100): List<Received> {
// 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<Received>()
@@ -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)
@@ -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 <date>' 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"))
}
}