An offline cache TTL needs a clock-rollback policy
AAn offline cache can pass its age check after the device clock moves backward. A timestamp later than “now” produces a negative age, and a check that only asks whether age is below the TTL accepts it. Reject future timestamps and remember the latest wall-clock time observed, while recognizing that neither check provides trusted elapsed time.
A negative age passed the upper-bound check #
The feature reused a biometric-protected Keychain cache shared with a companion extension. Its initial eligibility rule was effectively:
now - lastWritten <= maxAge
That has two problems. Moving the clock before the write makes the age negative. Moving it backward after a previous access can also make an expired cache look young again without crossing the write timestamp.
The cache contained independently written items. Using the newest item’s date would make an older item appear refreshed, so eligibility used the oldest modification date. A separate check rejected any item dated in the future. Checking only the oldest date would miss a newer, future-dated item.
Retain the latest observed time #
The repair added a clock mark in a separate Keychain item. Each eligibility read retained max(previousMark, now). If now preceded the previous mark, access was refused. Writing the rolled-back time into the mark would let the next read pass, so repeated reads were part of the regression test.
This Python model isolates the arithmetic from Keychain storage. Inputs are timestamps in the same unit and a nonnegative maximum age. None represents initial mark creation; it does not distinguish first use from a failed storage read.
def cache_status(written_at, now, mark, max_age):
if not written_at:
return "unavailable", mark
previous = now if mark is None else mark
updated = max(previous, now)
fresh = (
now >= previous
and max(written_at) <= now
and now - min(written_at) <= max_age
)
return ("fresh" if fresh else "expired"), updated
Persist the returned mark reliably. Define separate behavior for a missing mark, failed reads, and failed writes. Serialize the read/advance/write operation across its writers: two callers storing independently calculated maxima can otherwise overwrite a newer mark with an older one. This function does not implement those storage guarantees.
A successful online sign-in reset the mark to the current time, allowing recovery after a fast clock was legitimately corrected. That reset belonged to the authenticated online path, not to an offline retry.
Refusing access did not mean deleting the cache #
The main app’s expiry policy could not delete data the companion extension still needed. An expired result blocked that entry point and requested an online sign-in; it left the shared cache intact.
Offline use also had to stay separate from an ordinary signed-in state. Otherwise, the normal cache writer could rewrite cached data, advance its modification dates, and renew its apparent freshness without server contact. A timestamp is useful evidence of synchronization only while that write invariant holds.
Eligibility was checked again when opening the cache. A login screen left visible across the deadline could not reuse the earlier “fresh” result.
Test the rollback twice #
The original implementation’s red run reported four failed clock-mark assertions, including rollback refusal, repeated refusal, mark advancement, and online-sign-in reset. After the repair, the recorded core-target run passed 184 tests in 31 suites. Those are historical Xcode results, not a new Keychain integration run.
A separate run of the function above passed 15 synthetic-timestamp tests, covering expiry boundaries, future-dated items, repeated rollback refusal, and rechecking at open without mutating the cache. It did not exercise Keychain persistence. For example, two reads with these synthetic inputs must preserve the higher mark:
result, mark = cache_status([100], 190, 200, 1000)
assert (result, mark) == ("expired", 200)
assert cache_status([100], 190, mark, 1000) == ("expired", 200)
A high-water mark cannot establish time that was never observed #
A device can spend real time offline while its wall clock never exceeds the saved mark. These checks cannot prove how much time passed, guarantee remote revocation, or withstand a compromised device that can alter the protected state. Biometric access control does not authenticate the clock.
Test your cache by expiring it, moving the injected clock backward, and reading eligibility twice. Both reads should refuse access, and the second must not inherit a lowered mark.