Skip to content

feat(api): per-device API tokens — issue, scope, revoke (#204) - #347

Merged
untraceablez merged 2 commits into
mainfrom
feat/204-device-tokens
Aug 21, 2026
Merged

feat(api): per-device API tokens — issue, scope, revoke (#204)#347
untraceablez merged 2 commits into
mainfrom
feat/204-device-tokens

Conversation

@untraceablez

Copy link
Copy Markdown
Collaborator

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_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.

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 / write from the start, not a single full scope. 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 need read, everything else needs write), 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

  • Only a SHA-256 is stored — a database dump, a backup, or a support screenshot yields nothing usable. A plain hash rather than a password KDF is the right primitive for 256 bits of secrets-grade randomness: there's no dictionary to slow an attacker against, and a slow hash would tax every legitimate request.
  • Shown exactly once, in the response body of the POST that created it. It deliberately does not come 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 short prefix is kept in the clear so two tokens are tellable apart in the UI without storing anything usable.
  • The legacy SCRYME_API_TOKEN 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, which is the case where timing actually matters.

SCRYME_API_TOKEN otherwise works unchanged and still grants full access — it predates scopes, and an operator who set it meant "this whole API".

Verification

  • 1257 passed, 100% coverage, 0 missed lines (22 new tests). ruff and mkdocs --strict clean; migration 0033 applies and downgrades cleanly.
  • Two pre-existing tests called require_api_token synchronously with a SimpleNamespace; it's async and session-bound now. Their real subject was header parsing, so they're now tests of presented_token (including that a non-Bearer Authorization scheme isn't sliced), with the auth decision covered end-to-end over HTTP.
  • Driven live against the dev instance: API open with no tokens → 200; issue one → anonymous 401; read-scoped token GET200, PATCH401; revoke it → 401; revoke every token → still 401, 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_token and encode {base_url, token} in the QR, rather than being LAN-only.

🤖 Generated with Claude Code

https://claude.ai/code/session_01LxrePqFWyzQTcWaroSk4sH

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
Comment thread backend/src/client_tokens.py Fixed
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
Comment thread backend/src/client_tokens.py Dismissed
@untraceablez

Copy link
Copy Markdown
Collaborator Author

CodeQL py/weak-sensitive-data-hashing (high) — dismissed as a false positive, recording the reasoning here since dismissing a high-severity alert deserves to be reviewable.

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 secrets.token_urlsafe(32), generated server-side and never chosen by a human. There's no dictionary to defend against and no feasible brute force over that space, so a computationally expensive hash protects nothing here while adding latency to every authenticated request — including the batch scan-ingest in #164, which this PR exists to unblock.

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 (tokens.key, 0600), not in the database, mirroring how src/llm.py protects the LLM API key at rest. A stolen dump, backup or replica carries hashes computed under a key it doesn't contain, so it can't even be used to check a guess. client_token is also absent from src/backup.py's table list, so no backup ships credentials.

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 client_tokens.py's module docstring; test_stored_hashes_are_keyed_to_this_instance pins the property.

@untraceablez
untraceablez merged commit 3f5906b into main Aug 21, 2026
8 checks passed
@untraceablez
untraceablez deleted the feat/204-device-tokens branch August 21, 2026 19:04
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.

Per-device API tokens: issue / label / revoke

2 participants