Handle a wrong clock (ios#45, Android half) #65

Merged
paulhitt merged 1 commits from feat/handle-a-wrong-clock into main 2026-09-04 20:49:13 +00:00
6 changed files with 403 additions and 8 deletions
+17 -1
View File
@@ -159,7 +159,7 @@ being the one that leaks.
| Feature | iOS | Android | Note |
|---|:--:|:--:|---|
| Back off on repeated app-lock attempts | ✅ | ✅ | ios#41 — 3 free attempts, then 30s doubling to a 5-minute ceiling |
| Handle a wrong clock | ✅ | ⚪ | ios#45 |
| Handle a wrong clock | ✅ | ✅ | ios#45 — skew learned free from the `Date` header; expiry takes the LATER of device and relay |
| Zero key material after use | ✅ | ⚪ | ios#39 |
| Pin the relay's identity | ✅ | ⚪ | ios#19 |
@@ -169,6 +169,22 @@ keystore, and no amount of back-off changes that — the same wording `AppLock`
itself uses, kept identical on both platforms so neither can be read as
promising more.
🔴 **The clock rule is an asymmetry, not a correction.** A story disappearing
after an hour is a promise the SENDER made, enforced on the READER's device — so
a reader whose clock runs behind keeps a message the sender was told would be
gone, and nobody involved can tell. Expiry therefore takes the LATER of the
device's clock and the relay's: a device set back cannot extend a message's
life, and neither can a relay that lies about the time, because the other source
overrules it. The remaining failure, expiring early, costs the reader something
they were about to lose anyway.
⚠️ Android needs synchronisation iOS does not. The Swift version is
`nonisolated(unsafe) static var` because its writers sit on one actor; here the
observation is written from whichever OkHttp thread finished a call and read from
the UI thread, so the two fields are `@Volatile` and updated together under a
lock. A torn read of "skew from now, timestamp from an hour ago" would age out an
observation just made, or trust one that had not been.
🔴 One divergence from iOS, and it is a bug the Swift version cannot have. iOS
computes `30 * pow(2, steps)` in a `Double`, which saturates to infinity and
still caps correctly. Kotlin shifting a `Long` does not: `30L shl 60` wraps
@@ -3,6 +3,7 @@ package io.flamenet.messenger.app
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import io.flamenet.client.DeviceClock
import io.flamenet.client.AccountsClient
import io.flamenet.client.RelayClient
import io.flamenet.client.RelayAccounts
@@ -1890,7 +1891,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
runCatching {
v.mergeImportedPayload(
HistoryExport.open(bytes, passphrase),
nowSeconds = System.currentTimeMillis() / 1000.0)
nowSeconds = DeviceClock.expirySeconds())
}
}
@@ -1,6 +1,7 @@
package io.flamenet.messenger.app
import io.flamenet.client.Conversations
import io.flamenet.client.DeviceClock
import io.flamenet.client.MessengerApi
import io.flamenet.client.RelayClient
import io.flamenet.e2e.Attachment
@@ -896,7 +897,7 @@ class Session(
}
val arrived = everything.filter { it.content.isRenderable }
val touched = mutableSetOf<String>()
val now = System.currentTimeMillis() / 1000.0
val now = DeviceClock.expirySeconds()
val incomingStories = mutableListOf<Vault.Story>()
// storyKey, who reacted, the emoji ("" takes one back).
val storyReactionsIncoming = mutableListOf<Triple<String, Long, String>>()
@@ -1125,7 +1126,7 @@ class Session(
bytes, mime, width, height, caption, recipients,
lifetimeSeconds = lifetimeSeconds, groupId = gid)
if (posted.reached > 0) {
val now = System.currentTimeMillis() / 1000.0
val now = DeviceClock.expirySeconds()
vault.saveStories(
vault.loadStories() + Vault.Story(
id = posted.ref.id,
@@ -1189,7 +1190,7 @@ class Session(
: io.flamenet.client.Conversations.TextStoryPosted {
val posted = conversations.postTextStory(text, toUserIds, lifetimeSeconds)
if (posted.reached > 0) {
val now = System.currentTimeMillis() / 1000.0
val now = DeviceClock.expirySeconds()
vault.saveStories(
vault.loadStories() + Vault.Story(
id = posted.key,
@@ -1224,7 +1225,7 @@ class Session(
/** Everything still live, newest first. */
fun stories(): List<Vault.Story> {
val now = System.currentTimeMillis() / 1000.0
val now = DeviceClock.expirySeconds()
val all = vault.loadStories()
// ios#54 — anything of YOUR OWN that has expired is archived here,
// before `saveStories` drops it. This is the only moment it can be
@@ -1340,7 +1341,7 @@ class Session(
lifetimeSeconds = lifetimeSeconds,
duration = duration, streamUrl = streamUrl)
if (posted.reached > 0) {
val now = System.currentTimeMillis() / 1000.0
val now = DeviceClock.expirySeconds()
vault.saveStories(
vault.loadStories() + Vault.Story(
id = posted.ref.id,
@@ -1793,7 +1794,7 @@ class Session(
}
fun markStorySeen(storyId: String) {
val now = System.currentTimeMillis() / 1000.0
val now = DeviceClock.expirySeconds()
vault.saveStories(
vault.loadStories().map { if (it.id == storyId) it.copy(seen = true) else it },
now,
@@ -0,0 +1,181 @@
package io.flamenet.client
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
import kotlin.math.abs
import kotlin.math.roundToInt
/**
* What the time is, when the device's own answer cannot be trusted (ios#45).
*
* 🔴 **A wrong device clock breaks a promise made to somebody else.** Almost
* everything time-dependent here is a commitment: a story disappears after an
* hour, a message clears itself after a day. Those are promises the SENDER
* made, and they are enforced on the READER's device — so a reader whose clock
* is running behind keeps a message the sender was told would be gone, and
* nobody involved can tell.
*
* That asymmetry decides the whole design. Being wrong in one direction costs
* the reader something they were going to lose anyway; being wrong in the other
* direction breaks somebody else's expectation silently. So:
*
* 🔴 **For expiry, take the LATEST plausible time.** Never the device's alone,
* never the server's alone — whichever is further ahead. A device clock set back
* cannot extend a message's life, and neither can a relay that lies about the
* time, because the other source overrules it. The cost is that a device clock
* set FORWARD expires things early, which is the failure worth having.
*
* ⚠️ **The relay is a hint, not an authority.** It is told the time by nobody we
* trust, and a hostile one would like to move time backwards so that
* disappearing messages linger. Taking the later of the two is exactly what
* stops that being useful to it.
*
* ⚠️ **Process-wide and synchronised, not a singleton object with plain vars.**
* The Swift version can be `nonisolated(unsafe) static var` because the places
* that write it are on one actor; here the observation is written from whatever
* OkHttp thread finished a call and read from the UI thread, so the fields are
* `@Volatile` and the pair is updated under a lock. A torn read of "skew from
* now, timestamp from an hour ago" would age out an observation that had just
* been made, or trust one that had not.
*/
object DeviceClock {
/**
* Difference between the last server time seen and this device's clock, in
* milliseconds. Positive means the device is BEHIND.
*
* ⚠️ Not persisted. A skew learned yesterday says nothing about today — the
* clock may have been corrected, or the phone rebooted with a fresh answer
* from the network.
*/
@Volatile
private var skewMillis: Long? = null
/** When the skew was learned, so a very old observation can be ignored. */
@Volatile
private var observedAt: Long? = null
private val lock = Any()
/**
* How far out the clock has to be before it is worth saying anything.
*
* ⚠️ Five minutes. Ordinary drift is seconds; a phone whose clock is out by
* more than this will have visible symptoms — stories that vanish, messages
* that will not send — and being told why is better than guessing.
*/
const val SUSPECT_THRESHOLD_MS = 300_000L
/** An observation ages out rather than being trusted for ever. */
const val OBSERVATION_LIFETIME_MS = 3_600_000L
/**
* Learn the skew from a response's `Date` header.
*
* ⚠️ Free: every HTTP response already carries one, so this costs no extra
* request and no extra round trip.
*/
fun observe(serverMillis: Long, deviceNow: Long = System.currentTimeMillis()) {
synchronized(lock) {
skewMillis = serverMillis - deviceNow
observedAt = deviceNow
}
}
/** Parse and record a `Date` header, if there is a usable one. */
fun observe(header: String?, deviceNow: Long = System.currentTimeMillis()): Boolean {
val parsed = header?.let { parseHttpDate(it) } ?: return false
observe(parsed, deviceNow)
return true
}
/** Forget what was learned. For tests, and for signing out. */
fun forget() {
synchronized(lock) {
skewMillis = null
observedAt = null
}
}
/** The skew, if it is recent enough to mean anything. */
fun usableSkew(now: Long = System.currentTimeMillis()): Long? = synchronized(lock) {
val skew = skewMillis ?: return null
val at = observedAt ?: return null
if (now - at >= OBSERVATION_LIFETIME_MS) return null
skew
}
/** Whether the clock is far enough out to be worth telling somebody about. */
fun isSuspect(now: Long = System.currentTimeMillis()): Boolean {
val skew = usableSkew(now) ?: return false
return abs(skew) > SUSPECT_THRESHOLD_MS
}
/**
* What to say about it, or null when there is nothing to say.
*
* ⚠️ Names the symptom before the cause. Somebody whose stories keep
* vanishing does not think "my clock is wrong"; they think the app is
* broken.
*/
fun warning(now: Long = System.currentTimeMillis()): String? {
val skew = usableSkew(now) ?: return null
if (abs(skew) <= SUSPECT_THRESHOLD_MS) return null
val minutes = (abs(skew) / 60_000.0).roundToInt()
val direction = if (skew > 0) "behind" else "ahead"
return "This phone's clock is about $minutes minutes $direction. " +
"Stories and disappearing messages may vanish early or linger, " +
"because both are timed. Turning on automatic date and time in " +
"Settings fixes it."
}
/**
* The time to judge an expiry against, in milliseconds.
*
* 🔴 **The later of the device's clock and the server's.** Neither alone: a
* device set back would keep a disappearing message past the moment its
* sender was told it would go, and a relay that lied about the time could
* arrange the same thing. Taking whichever is further ahead means both have
* to be wrong in the same direction to extend anything, and the remaining
* failure — expiring early — costs the reader something they were about to
* lose anyway.
*/
fun nowForExpiry(deviceNow: Long = System.currentTimeMillis()): Long {
val skew = usableSkew(deviceNow) ?: return deviceNow
return maxOf(deviceNow, deviceNow + skew)
}
/**
* The same in seconds, which is how the vault stores expiries.
*
* ⚠️ A Double of seconds, matching `Vault.Story.expires` — converting to a
* Long of seconds here would floor away sub-second precision on a boundary
* that decides whether a story is still on screen.
*/
fun expirySeconds(deviceNow: Long = System.currentTimeMillis()): Double =
nowForExpiry(deviceNow) / 1000.0
/**
* RFC 7231 `Date`, which is what an HTTP header carries.
*
* ⚠️ Fixed locale and zone. A formatter that picked those up from the device
* parses nothing on a phone set to most of the world — and a `SimpleDateFormat`
* is not thread-safe, so one is built per call rather than shared. This runs
* once per HTTP response, which is nowhere near hot enough to care.
*/
private fun parseHttpDate(header: String): Long? {
val formatter = SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz", Locale.US)
formatter.timeZone = TimeZone.getTimeZone("GMT")
formatter.isLenient = false
return runCatching { formatter.parse(header)?.time }.getOrNull()
}
/** For tests that want to state a server time rather than compute one. */
fun httpDate(millis: Long): String {
val formatter = SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz", Locale.US)
formatter.timeZone = TimeZone.getTimeZone("GMT")
return formatter.format(Date(millis))
}
}
@@ -802,6 +802,12 @@ class RelayClient(
}
private fun checked(response: Response, path: String): Response {
// ios#45 — learn the clock skew from the header every response already
// carries. Done HERE because this is the one funnel every relay call
// passes through, and before the success check, so a 4xx teaches us the
// time just as well as a 200 does: a phone whose clock is wrong enough
// to be refused is exactly the phone that needs to be told why.
DeviceClock.observe(response.header("Date"))
if (response.isSuccessful) return response
val body = response.body?.string().orEmpty()
val code = response.code
@@ -0,0 +1,190 @@
package io.flamenet.client
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* Handling a wrong device clock (ios#45, Android half).
*
* 🔴 **The rule under test is an asymmetry, not a correction.** A story
* disappearing after an hour is a promise the SENDER made, enforced on the
* READER's device. A reader whose clock runs behind keeps a message the sender
* was told would be gone, and nobody involved can tell. Being wrong the other
* way costs the reader something they were about to lose anyway.
*
* So expiry takes the LATER of the device's clock and the relay's, and these
* tests are mostly about that one decision holding in every direction.
*/
class DeviceClockTest {
@BeforeTest fun clean() = DeviceClock.forget()
@AfterTest fun tidy() = DeviceClock.forget()
private val now = 1_700_000_000_000L
// ---- Learning the skew ----------------------------------------------
@Test
fun `a Date header teaches the skew`() {
assertTrue(DeviceClock.observe(DeviceClock.httpDate(now + 600_000L), now))
val skew = DeviceClock.usableSkew(now)
// Positive means the device is BEHIND the server.
assertEquals(600_000L, skew)
}
@Test
fun `an absent or unparseable header teaches nothing`() {
assertFalse(DeviceClock.observe(null as String?, now))
assertFalse(DeviceClock.observe("not a date", now))
assertFalse(DeviceClock.observe("", now))
assertNull(DeviceClock.usableSkew(now))
}
/**
* ⚠️ A skew learned yesterday says nothing about today: the clock may have
* been corrected, or the phone rebooted with a fresh answer from the
* network. An observation that has aged out is the same as none.
*/
@Test
fun `an old observation is not used`() {
DeviceClock.observe(now + 600_000L, now)
assertEquals(600_000L, DeviceClock.usableSkew(now + 60_000L))
assertNull(DeviceClock.usableSkew(now + DeviceClock.OBSERVATION_LIFETIME_MS))
assertNull(DeviceClock.usableSkew(now + DeviceClock.OBSERVATION_LIFETIME_MS + 1))
}
// ---- The rule that matters ------------------------------------------
/**
* 🔴 **A device clock set BACK cannot extend a message's life.** This is the
* case the whole file exists for: the phone thinks it is earlier than it is,
* so on its own reading a disappearing message is still alive. The server's
* time overrules it.
*/
@Test
fun `a device running behind does not keep a message alive`() {
// Server says it is ten minutes later than this phone believes.
DeviceClock.observe(now + 600_000L, now)
assertEquals(now + 600_000L, DeviceClock.nowForExpiry(now))
}
/**
* 🔴 **And a relay that lies about the time cannot either.** A hostile relay
* would like to move time BACKWARDS so disappearing messages linger. Taking
* the later of the two means its answer is simply ignored.
*/
@Test
fun `a relay claiming an earlier time is ignored`() {
DeviceClock.observe(now - 600_000L, now)
assertEquals(now, DeviceClock.nowForExpiry(now),
"the relay's earlier time was taken, so a hostile relay can extend expiries")
}
/** With nothing learned, the device's own clock is all there is. */
@Test
fun `with no observation it is the device clock`() {
assertEquals(now, DeviceClock.nowForExpiry(now))
}
/** An aged-out observation cannot extend anything either. */
@Test
fun `an expired observation falls back to the device clock`() {
DeviceClock.observe(now + 600_000L, now)
val later = now + DeviceClock.OBSERVATION_LIFETIME_MS + 1
assertEquals(later, DeviceClock.nowForExpiry(later))
}
/**
* ⚠️ Both directions of the same rule in one place: whatever the relay says,
* the answer is never EARLIER than the device's own clock.
*/
@Test
fun `the answer is never earlier than the device clock`() {
for (offset in longArrayOf(-3_600_000L, -1L, 0L, 1L, 3_600_000L)) {
DeviceClock.forget()
DeviceClock.observe(now + offset, now)
assertTrue(
DeviceClock.nowForExpiry(now) >= now,
"an offset of $offset produced a time before the device's own",
)
}
}
// ---- Telling somebody -----------------------------------------------
@Test
fun `ordinary drift says nothing`() {
DeviceClock.observe(now + 4_000L, now)
assertFalse(DeviceClock.isSuspect(now))
assertNull(DeviceClock.warning(now))
}
@Test
fun `a badly wrong clock is reported, with the symptom first`() {
DeviceClock.observe(now + 900_000L, now)
assertTrue(DeviceClock.isSuspect(now))
val warning = DeviceClock.warning(now)!!
assertTrue(warning.contains("15 minutes behind"), warning)
// 🔴 The symptom before the cause. Somebody whose stories keep vanishing
// does not think "my clock is wrong"; they think the app is broken.
assertTrue(warning.contains("Stories and disappearing messages"), warning)
}
@Test
fun `a clock running fast is described as ahead`() {
DeviceClock.observe(now - 900_000L, now)
assertTrue(DeviceClock.warning(now)!!.contains("15 minutes ahead"))
}
/** Exactly at the threshold is not yet worth saying. */
@Test
fun `the threshold is a boundary, not a range`() {
DeviceClock.observe(now + DeviceClock.SUSPECT_THRESHOLD_MS, now)
assertFalse(DeviceClock.isSuspect(now))
DeviceClock.forget()
DeviceClock.observe(now + DeviceClock.SUSPECT_THRESHOLD_MS + 1, now)
assertTrue(DeviceClock.isSuspect(now))
}
// ---- Shape ----------------------------------------------------------
/**
* ⚠️ Seconds as a Double, matching `Vault.Story.expires`. Flooring to a Long
* of seconds here would lose the sub-second part on the boundary that
* decides whether a story is still on screen.
*/
@Test
fun `expiry seconds keep sub-second precision`() {
DeviceClock.observe(now + 1_500L, now)
assertEquals((now + 1_500L) / 1000.0, DeviceClock.expirySeconds(now))
}
@Test
fun `forget clears both halves of the observation`() {
DeviceClock.observe(now + 600_000L, now)
DeviceClock.forget()
assertNull(DeviceClock.usableSkew(now))
assertFalse(DeviceClock.isSuspect(now))
assertEquals(now, DeviceClock.nowForExpiry(now))
}
/**
* 🔴 The round trip through the header format, because that is what actually
* arrives. A formatter that took its locale from the device parses nothing
* on a phone set to most of the world, and the failure is silent — the
* header is simply never understood and the skew is never learned.
*/
@Test
fun `the header format round-trips`() {
val header = DeviceClock.httpDate(now)
assertTrue(header.endsWith("GMT"), header)
assertTrue(DeviceClock.observe(header, now))
// Formatting drops milliseconds, so the skew lands within one second.
assertTrue(kotlin.math.abs(DeviceClock.usableSkew(now)!!) < 1_000L)
}
}