Skip to content

Compare expiry timestamps on the same clock, and guard against the next one - #96

Merged
juicycleff merged 3 commits into
mainfrom
fix/sqlite-verification-expiry-tz
Aug 26, 2026
Merged

Compare expiry timestamps on the same clock, and guard against the next one#96
juicycleff merged 3 commits into
mainfrom
fix/sqlite-verification-expiry-tz

Conversation

@juicycleff

@juicycleff juicycleff commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Six lookups bound a local-zone time into a comparison against a TEXT timestamp column. On SQLite that predicate is a string comparison, so both sides have to be on the same clock, and they were not.

Which way it fails depends on where the process runs

West of UTC it fails open. The bound value renders as local wall-clock text, which sorts below the stored UTC text, so the comparison matches rows it should have excluded and a verification token keeps working for the length of the zone offset after it expires. On a US Pacific deployment that is eight hours. The token in a verification email is a bearer credential, so this is a credential outliving the window it was issued for.

East of UTC the same bug fails closed, rejecting tokens that are still valid. That one is only annoying.

Reproduced on this machine at UTC-5: a verification created with an expiry an hour in the past was still returned as the active one.

The fix

Both sides on UTC, in both places. The comparisons bind a UTC value, and the model converters normalise on the way in, so a caller passing a local-zone timestamp cannot reintroduce the mismatch from the storage direction.

This is the same fault as the oauth2 device code sweep fixed in #88.

The second one is ListActiveSignals in the shared signals plugin, which has the fault from the other end: it takes the caller's clock as an argument, and risk.go hands it a bare time.Now(). Risk signals decide whether a past event still constrains a sign-in, so west of UTC an expired signal keeps applying, and east of UTC a live one stops early. That second direction fails open on a security control.

Then two more in the same plugin, and these are the interesting ones because they already had conformance cases that passed. CountEventsSince backs the circuit breaker and actions.go calls it with a bare time.Now(); ListReceivedEvents has it on both bounds of its audit window. West of UTC the breaker counts too far back and trips early. East of UTC it counts too little and does not trip at all, which is a rate limit quietly ceasing to limit.

The cases passed because I had written them to construct tidy UTC inputs. That is the real lesson from this branch: a conformance case that supplies its own well-formed input tests the store against a caller that does not exist. Those cases now pass a local-zone time on purpose, matching what the callers actually do, and they fail on sqlite before this change.

An earlier version of this branch claimed the verification lookup was the only remaining case and that the plugin suites covered the rest. That was wrong twice over, and it is why the commit history here has a correction in it.

Guarded, not just fixed

Both new cases live in the shared contract suites rather than in sqlite tests, so all four backends assert the boundary. Each fails on sqlite before this change and passes after. Memory, postgres and mongo were always correct, because their expiry columns hold a real instant rather than text, and that is precisely why sqlite-only tests would have been the wrong place to put them.

Each case also checks that a live row is still returned, so a fix that simply rejected everything would not pass.

A check, instead of another sweep

I said last time that a manual sweep had covered everything. It had not, so this branch also adds internal/sqliteguard: a plain test that parses every SQLite store file, finds each Where clause comparing a timestamp column against a bound parameter, and requires the bound value to be on UTC. No linter plugin, runs with the ordinary suite, fails where you are already looking.

It found a sixth instance on its first run, one my grep had missed because the column is not at the start of the clause:

store/sqlite/principal.go  Where("(expires_at IS NULL OR expires_at >= ?)", q.ActiveAsOf)

That gates whether a service account or agent principal is still active, and engine_principal.go passes a bare time.Now(). West of UTC an expired principal keeps authenticating. Same severity as the verification token, and it survived a sweep I had already called complete.

Go-side comparisons are not checked and do not need to be. time.Time compares instants rather than text, so the bug only exists where the comparison happens inside the database.

ListReceivedEvents also now asserts the newest-first ordering it documents and nothing checked. Ordering a text timestamp column is a string sort, so it only agrees with chronological order while every stored value is on one clock.

Worth reading store/sqlite/principal.go's note on FindActiveDelegation alongside this. Somebody hit a nastier version of the same fault there, where the column's NUMERIC affinity made the predicate constant-true, and solved it by filtering expiry in Go rather than in SQL. That is a stronger fix than normalising, and it is the better answer wherever the row count allows it.

Checks

All four backends green on the full contract suite. The exact CI conformance command green end to end, full suite 96 packages green, lint clean at 2.12.2 and 2.13.1, goimports clean.

Two lookups bound a local-zone time into a comparison against a TEXT
timestamp column. On SQLite that predicate is a string comparison, so both
sides have to be on the same clock, and they were not. Which way it breaks
depends on where the process runs, and neither direction is acceptable.

GetActiveEmailVerification in the core store is the serious one. West of UTC
the bound value renders as local wall-clock text, which sorts below the
stored UTC text, so the query matches rows it should have excluded and a
verification token keeps working for the length of the zone offset after it
expires. On a US Pacific deployment that is eight hours. The token in a
verification email is a bearer credential, so this is a credential outliving
the window it was issued for. East of UTC the same bug rejects tokens that
are still valid.

ListActiveSignals in the shared signals plugin has the same fault from the
other end: it takes the caller's clock as an argument and risk.go hands it a
bare time.Now(). Risk signals are what decide whether a past event still
constrains a sign-in, so west of UTC an expired signal keeps applying and
east of UTC a live one stops early. That second direction fails open on a
security control.

Both are fixed on both sides. The comparisons bind a UTC value, and the
model converters normalise on the way in, so a caller passing a local-zone
timestamp cannot reintroduce the mismatch from the storage direction.

Guarded by two new cases in the shared contract suites rather than in sqlite
tests, so all four backends assert the boundary. Each fails on sqlite before
this change and passes after; memory, postgres and mongo were always right,
because their expiry columns hold a real instant rather than text, which is
exactly why a sqlite-only test would have been the wrong place for them.
Both cases also assert that a live row is still returned, so a fix that
simply rejected everything would not pass.

An earlier version of this commit claimed the verification lookup was the
only remaining case and that the plugin stores were already covered. Neither
was true: the plugin suites had no signals case at all, which is why nothing
caught this one until I went looking.
@juicycleff
juicycleff force-pushed the fix/sqlite-verification-expiry-tz branch from 2544ab4 to 9977b0b Compare August 26, 2026 02:06
@juicycleff juicycleff changed the title Stop serving expired email verifications as active on SQLite Compare expiry timestamps on the same clock on SQLite Aug 26, 2026
Rex Raphael added 2 commits August 25, 2026 21:43
Two more comparisons against a TEXT timestamp column bound the caller's
clock unnormalised, and both had conformance cases that passed anyway
because those cases were the ones passing UTC.

CountEventsSince is the one that matters. actions.go calls it with a bare
time.Now() and it backs the circuit breaker, so west of UTC the count sweeps
in events from further back than the window and the breaker trips early,
while east of UTC it sees fewer than it should and does not trip at all.
A rate limit that quietly stops limiting is the failure worth avoiding here.

ListReceivedEvents has the same problem on both bounds of its window, which
skews the audit trail by the zone offset in whichever direction the zone
runs.

Both binds are now .UTC(), and fromReceivedEvent normalises ReceivedAt on
the way in so the stored side cannot drift either.

The cases now pass a local-zone time on purpose, matching what the real
callers do. That is the actual lesson from this one: a conformance case that
constructs its own tidy UTC input tests the store against a caller that does
not exist. Both cases fail on sqlite before this change and pass after.

Also added the newest-first assertion ListReceivedEvents documents but
nothing checked. Ordering a text timestamp column is a string sort, so it
only agrees with chronological order while every stored value is on one
clock, which is now true going forward.
This is the fifth and sixth time this repo has hit the same fault, so it is
worth a check rather than another round of greps.

internal/sqliteguard parses every SQLite store file, finds each Where clause
that compares a timestamp column against a bound parameter, and requires the
bound value to have been put on UTC. It is a plain test, so it runs with the
ordinary suite, needs no linter plugin, and fails where a developer is
already looking. A Go-side comparison is not checked and does not need to be:
time.Time compares instants rather than text, so the bug only exists where
the comparison happens inside the database.

It earned its place immediately. It found a sixth instance that my own grep
had missed, because the grep expected the column at the start of the clause
and this one does not have it there:

  store/sqlite/principal.go  Where("(expires_at IS NULL OR expires_at >= ?)", q.ActiveAsOf)

That gates whether a service account or agent principal is still active, and
engine_principal.go passes a bare time.Now(). West of UTC an expired
principal keeps authenticating for the length of the zone offset. It is the
same severity as the verification token, and it survived a manual sweep that
I had already described as complete.

Fixed, and proved rather than assumed: EphemeralPrincipalExpiry now passes
ActiveAsOf on the local clock like its real caller does, and it fails with
the fix reverted. The previous version of that case constructed a UTC value
and passed either way, which is the same blind spot the shared signals cases
had.

Worth reading store/sqlite/principal.go's note on FindActiveDelegation next
to this. Somebody hit a nastier version of the same thing there, where the
column's NUMERIC affinity made the predicate constant-true, and solved it by
filtering expiry in Go instead. That is a stronger fix than normalising, and
it is the right answer wherever the row count allows it.
@juicycleff juicycleff changed the title Compare expiry timestamps on the same clock on SQLite Compare expiry timestamps on the same clock, and guard against the next one Aug 26, 2026
@juicycleff
juicycleff merged commit 604ebc4 into main Aug 26, 2026
16 checks passed
@juicycleff
juicycleff deleted the fix/sqlite-verification-expiry-tz branch August 26, 2026 04:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant