feat(api): per-device API tokens — issue, scope, revoke (#204) - #347
Conversation
Today the API has one static `SCRYME_API_TOKEN`: a single shared secret, not revocable per client, with no record of which app is talking. `client_token` replaces that with labelled, individually revocable credentials — the useful 80% of "accounts" (per-client identity, revocation, an audit trail) *without* human identity, login or sessions, which is exactly the split ADR 0001 drew. Only a SHA-256 of each token is stored, so a database dump, a backup or a support screenshot yields nothing usable; the plaintext is shown once, in the response body of the POST that created it. It deliberately does not travel back via a redirect URL — that would put a live credential into browser history, the Referer header, and any access log that records query strings. A plain hash rather than a password KDF is the right primitive for 256 bits of `secrets`-grade randomness: there is no dictionary to slow an attacker against, and a slow hash would tax every legitimate request. #204 left two questions open. Answers, with reasoning, in `client_tokens`' module docstring: * **Scopes: `read` / `write` from the start**, not a single `full` scope. Retrofitting scopes later means invalidating every issued token or silently defaulting them to full access, and a silent privilege grant is a bad thing to owe your future self. Enforcement is **by HTTP method** — safe methods need `read`, everything else needs `write` — so an endpoint cannot be added and left unprotected by forgetting an annotation. * **Revoking the last token does NOT reopen the API.** Inferring "no tokens means no auth wanted" would make revocation — the action you take precisely *because* something went wrong — the thing that removes the lock. Revoked rows are kept, so "has this instance ever been locked down?" is just "does any row exist?", and it cannot be undone by accident. Nobody is locked out of scryme itself: the HTML UI is never token-gated, so issuing a replacement is one click away. `SCRYME_API_TOKEN` keeps working unchanged and still grants full access — it predates scopes, and an operator who set it meant "this whole API". Its comparison is now constant-time; unlike the device path (which matches on an indexed hash) it compares the caller's string against the secret itself. UI: a Devices tab under /settings — issue with a label and access level, see last-used (so the tokens nothing is using are visible), revoke. `last_used_at` is throttled to a minute so reading the API doesn't write a row per request. Migration 0033. 22 new tests; suite 1257 passing at 100% coverage. Unblocks #164–#169 (the scanme integration milestone), and generalizes #165's QR pairing out of its LAN-only context. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LxrePqFWyzQTcWaroSk4sH
| import sqlalchemy as sa | ||
| from alembic import op | ||
|
|
||
| revision: str = "0033_client_token" |
| from alembic import op | ||
|
|
||
| revision: str = "0033_client_token" | ||
| down_revision: str | None = "0032_trade_commit" |
…igest CodeQL flagged `py/weak-sensitive-data-hashing` (high) on the bare `sha256` of a token. The literal finding — "SHA-256 is not a computationally expensive hash function" — doesn't apply to a 256-bit `secrets`-grade value: there is no dictionary for a slow hash to protect, and a KDF would only add latency to every authenticated request. So the fast hash stays. But the thing a KDF *would* have bought is worth having, and there's a cheaper way to buy it. The hash is now HMAC-SHA256 under a per-instance key kept in the data directory (`tokens.key`, 0600), not in the database — mirroring how `src.llm` stores the key that encrypts the LLM API key at rest. A stolen dump, backup or replica now contains hashes computed under a key it does not include, so it cannot even be used to check a guess. `client_token` was already excluded from `src.backup`'s table list, so no backup carries credentials either way. Losing the key file invalidates every issued token. That's the correct direction to fail in — they stop working rather than becoming guessable — and re-issuing is one click. `test_stored_hashes_are_keyed_to_this_instance` pins the property: the same token hashes differently under two data dirs, and the key file is 32 random bytes with no group/other access. 1258 tests, 100% coverage. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LxrePqFWyzQTcWaroSk4sH
|
CodeQL The rule flags a fast hash of credential-shaped data, and is right to for passwords. These aren't passwords: they're 256-bit values from I didn't just dismiss it, though. The protection a KDF would genuinely provide — against an attacker who already holds the stored hashes — is now provided a cheaper way: the hash is HMAC-keyed to a per-instance secret in the data directory ( Worth noting the alert re-fired after that change, on the HMAC line — the heuristic keys on the algorithm name and the variable being credential-shaped, so no keying or algorithm choice short of an actual KDF clears it. The condition that would make the finding real: tokens becoming user-chosen or otherwise low-entropy. If that ever changes, reopen this — the dismissal comment says so too. Rationale lives in |
Closes #204. Unblocks #164–#169 (the scanme integration milestone) and generalizes #165's QR pairing out of its LAN-only context.
What changes
Today the API has one static
SCRYME_API_TOKEN— a single shared secret, not revocable per client, with no record of which app is talking.client_tokenreplaces that with labelled, individually revocable credentials: the useful 80% of "accounts" (per-client identity, revocation, an audit trail) without human identity, login or sessions, which is exactly the split ADR 0001 drew.Settings → Devices issues them, shows last-used (so the tokens nothing is using are visible), and revokes.
The two questions #204 left open
Both are security-relevant and neither is obvious, so the reasoning lives in
client_tokens' module docstring and is pinned by tests.Scopes:
read/writefrom the start, not a singlefullscope. Retrofitting scopes later means either invalidating every issued token or silently defaulting them to full access — and a silent privilege grant is a bad thing to owe your future self. Enforcement is by HTTP method (safe methods needread, everything else needswrite), so an endpoint can't be added and left unprotected by forgetting an annotation.Revoking the last token does not reopen the API. Inferring "no tokens means no auth wanted" would make revocation — the action you take precisely because something has gone wrong — the thing that removes the lock. Revoked rows are kept, so "has this instance ever been locked down?" is just "does any row exist?", and it can't be undone by accident. Nobody gets locked out of scryme itself: the HTML UI is never token-gated, so issuing a replacement is one click away.
Handling the secret
secrets-grade randomness: there's no dictionary to slow an attacker against, and a slow hash would tax every legitimate request.Refererheader, and any access log that records query strings.SCRYME_API_TOKENcomparison is now constant-time. Unlike the device path (which matches on an indexed hash) it compares the caller's string against the secret itself, which is the case where timing actually matters.SCRYME_API_TOKENotherwise works unchanged and still grants full access — it predates scopes, and an operator who set it meant "this whole API".Verification
ruffandmkdocs --strictclean; migration 0033 applies and downgrades cleanly.require_api_tokensynchronously with aSimpleNamespace; it's async and session-bound now. Their real subject was header parsing, so they're now tests ofpresented_token(including that a non-BearerAuthorizationscheme isn't sliced), with the auth decision covered end-to-end over HTTP.200; issue one → anonymous401; read-scoped tokenGET→200,PATCH→401; revoke it →401; revoke every token → still401, not reopened; rows kept as the audit trail. Test tokens then deleted, dev API confirmed back to open.Follow-up
#165 (scanner QR pairing) should now mint a scoped
client_tokenand encode{base_url, token}in the QR, rather than being LAN-only.🤖 Generated with Claude Code
https://claude.ai/code/session_01LxrePqFWyzQTcWaroSk4sH