Repository navigation
Description
The gap
docs/architecture/license/app.md lists this under Known gaps:
A stored file whose
meta.issuedis more than five minutes ahead of the clock makesGET /license/statusfail with 500 instead of answeringclock_untrusted:status()re-runsverify(), and nothing catches its rejection.
Why it matters
clock_untrusted is a state the app is designed to show: Settings has a notice for it, and the sidebar draws a red status row. But the one case that most obviously produces it — a machine whose clock is behind the time the license was issued — never reaches that code. verify() raises before _status() can classify anything, so the user gets a 500 and a broken Settings pane instead of "This computer's clock is behind the time the license was issued."
A laptop clock running fast is the ordinary cause, not tampering. It should read as a explainable state, not a crash.
Where
surfsense_local/backend/modules/license/service.py—status()callsverify(row.certificate, instant)unguarded.surfsense_local/backend/modules/license/verify.py— raisesLicenseRejectedError("clock_untrusted").
Note the asymmetry worth preserving: import_certificate() should keep rejecting a bad file with 422, because the user is handing one over right then. Only reading the status of an already-stored file should degrade to a state instead of an error. Fix status(), not verify().
What done looks like
GET /license/statuson a stored file whosemeta.issuedis more thanMAX_CLOCK_DRIFTahead answers200withstate: "clock_untrusted".PUT /licensestill answers 422 for the same file — importing is a different moment.- Decide deliberately what the other rejections (
bad_signature,not_a_license_file,unsupported_algorithm) should do on a stored file. A file that was valid at import cannot become unsigned, so if you leave those raising, say why in the PR. Don't silently swallow every rejection into one state. - Update
docs/architecture/license/app.mdif the answer changes what the State table describes, and delete the Known gaps line in the same PR.
How to verify
surfsense_local/backend/tests/integration/license/test_license.py already covers these routes and shows how to sign a fixture file. Add a failing test first: store a file, move the clock back past the drift window, read the status.
cd surfsense_local/backend
uv run pytest -m integration -k license
uv run ruff check .Before you start
Read CONTRIBUTING.md, the license doc and ADR 0019. Open your PR against dev, not main. Comment here to claim this.
Activity
ybai08 commentedon Sep 29, 2026
I'd like to take this. Plan: add a failing integration test at
GET /license/status, then havestatus()trust the stored file's signature only and let the existing watermark classify the clock, so import keeps answering 422. The doc's Known gaps line goes in the same PR.fix(local): read a stored license issued ahead of the clock as clock_…
MODSetter commentedon Sep 29, 2026
Fixed by #2019,
fix(local): read a stored license issued ahead of the clock as clock_untrusted, by @ybai08. Merged intodev.Closing by hand rather than by keyword:
Fixes #1939only auto-closes on a merge to the default branch, and this repo merges throughdev(CONTRIBUTING), so this would have stayed open and claimable while the work was already done. It reachesmainwith the next release.Nothing left to pick up here.