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