Skip to content

Security

What is stored, and what is not

Nothing here ever persists a credential in a form that can be replayed.

Credential Column Algorithm
Password users.password_hash Argon2id, via argon2-cffi defaults
Link code link_tokens.token_digest SHA-256
Refresh token refresh_tokens.token_digest SHA-256
API key api_keys.token_digest SHA-256

Argon2 for the password because it is chosen by a human and must survive an offline attack. SHA-256 for the rest because they are 32 bytes of secrets output — there is no dictionary to run against them, and the cost of Argon2 on every request would buy nothing.

A database dump gives an attacker no usable credential.

The three token types

They are deliberately different, and the differences are the design.

Access token — a signed JWT

Nothing looks it up by id. It is checked by verifying a signature, which is cheap, and it is short-lived: 30 minutes by default.

It can be ended early, by exactly one lever. Every token carries the token_generation its account had when the token was minted, and revoke_all_for_user bumps that counter — so a password change or a "sign out everywhere" invalidates issued tokens immediately rather than leaving them working for their remaining lifetime. The check costs nothing: the account row is already loaded to see whether it is active.

A counter rather than a timestamp, because iat has one-second granularity. A token minted and a cutoff set in the same second compare equal, and the old token slips through — which is exactly what a live smoke test caught before this shipped.

typ: "access" is in the payload and checked on the way in, so a refresh token cannot be presented as a bearer.

Refresh token — a random secret, stored as a digest

Looked up on every use, which is what makes it revocable. That is its whole reason to exist.

Rotation: every refresh mints a new token and revokes the one it came from.

Theft detection: if a token that has already been exchanged is presented again, two parties hold it and there is no way to tell which one is asking. The entire family — every token descended from that login — is revoked. The legitimate holder is signed out, which is a much smaller harm than the alternative.

login  →  T1
T1     →  T2   (T1 revoked, replaced_by = T2)
T1     →  ✗    family revoked; T2 dies too

That revocation commits before the error is raised. It is the one place a service commits its own work: the request is about to fail, the caller rolls back on failure, and a rollback would undo the revocation — leaving the stolen family alive and the detection doing nothing at all. It was written that way, shipped that way, and a test caught it.

API key — for programs, not people

Shown once, at creation. Does not expire on its own, because a nightly job should not stop at 3am. An 8-character prefix is kept in the clear so a key can be recognised in a list and revoked without being produced.

Sent as X-API-Key. Resolves to the same User as a bearer token, so a route that reads or writes books does not have to care which arrived.

Managing the account is the exception, and it refuses a key with 403: issuing, listing and revoking keys; the profile, contact and password; sessions and logout-everywhere; closing the account; and linking or switching messengers. A key that could mint keys made revoking a leaked one pointless — its holder had already made a successor — and a key that could change the contact or close a passwordless account was the account.

Showing a secret on a screen

An API key is shown once, at creation, and marked for concealment — Telegram renders it behind a tap. Providers that cannot conceal text still show it, because a key nobody can see is worse than one that is merely not hidden, and Capabilities.spoiler is how each provider says which it is.

HTML parsing is switched on only for that message, and both halves are escaped first. Enabling it everywhere would mean a Persian description containing a < or an & could break a screen or fail to send.

Rate limiting

Fixed windows, per address and per account:

Route Limit
POST /auth/login 5/min per IP, 10/min per identifier
POST /auth/register 3/hour per IP

Both, because one alone is not enough. Per-IP alone lets a botnet hammer one account; per-account alone lets one attacker lock every user out by trying their addresses.

Fixed windows are not the most elegant algorithm — a burst straddling a boundary briefly gets double the budget. That is an acceptable trade for something a login route consults on every request. What it buys is the thing that matters: password guessing stops being free.

With Redis it is shared across workers. Without, it is per-process — correct on one worker and useless across several, and the API logs a warning saying so at startup rather than pretending otherwise. The in-process counter forgets windows that have ended, so a flood of distinct keys cannot grow it forever. The Redis counter is created together with its expiry in one transaction (SET NX EX, then INCR), so a crash cannot leave a counter that never resets; a counter stranded without an expiry by an older version is given one on its next hit.

A refused request is a 429 with a Retry-After header in seconds.

Account enumeration

A wrong password and an unknown account return the same status and the same body. Two different answers would turn the login route into a tool for finding out who has an account.

The same rule applies elsewhere: books.require raises NotFound for a non-member rather than PermissionDenied, so book ids cannot be probed. Trying to revoke someone else's API key returns 404, not 403.

Webhooks

This is the one place where the honest answer is uncomfortable.

Telegram echoes a secret token we registered, in X-Telegram-Bot-Api-Secret-Token. That is compared with hmac.compare_digest. Real verification.

Bale and Rubika sign nothing. There is no header, no signature, no shared secret. Their adapters' verify_webhook returns True and the docstring says plainly that it cannot prove the caller is who they claim — because claiming a check that is not happening would be worse than admitting there is none.

What protects them instead is the path: /api/v1/webhooks/{provider}/{secret}, compared in constant time, 404 on a mismatch. An unconfigured provider and a wrong secret return the same 404, so a prober cannot tell which they got wrong.

That is weaker than a signature. It is what the providers make possible.

Polling avoids the question entirely, and is the default. If you switch — KASBBOOK_UPDATE_MODE=webhook — the path secret becomes load-bearing for Bale and Rubika, and nginx must not log it. See operations.md.

Passwords typed into a chat

The bot can set a password, which means someone types one into Telegram. That is only acceptable because the message is deleted the moment it is handled — apps/bot/runner.py removes every incoming message, and a test asserts the password case specifically. Without that the password would sit in the chat, on the device, and in every backup of both.

A delete that fails — too old, or no permission in a group — is logged and ignored rather than failing an update that already succeeded. It is worth knowing that this makes the guarantee best-effort rather than absolute.

Changing an existing password requires the current one. Whoever holds the linked messenger can already read and change the books, so this buys less than it looks; what it does buy is that a stolen phone cannot quietly take the web side too. Setting a first password does not ask, because there is nothing to ask for.

Either way, every session is revoked afterwards — refresh tokens and already-issued access tokens.

Secrets in logs

httpx logs full request URLs at INFO, and every one of these bot APIs puts the token in the path. That is how a token ended up in a journal here once.

apps/bot/runner.py pins httpx and httpcore to WARNING. It is not optional and it has a comment saying why.

Unhandled exceptions are logged in full and reported to the caller as {"detail": "something went wrong on our side"} — an exception message can carry a query, a path, or a value from someone else's account.

Transport

The API binds to 127.0.0.1 only. TLS and edge rate limiting belong in the reverse proxy already on the box, not in the application process.

CORS origins are listed explicitly and never *. A wildcard with credentials enabled is a hole, not a convenience — and it is off entirely unless KASBBOOK_CORS_ORIGINS is set.

X-Forwarded-For is trusted for rate limiting only when KASBBOOK_TRUSTED_PROXY says a proxy is in front. Without that, anyone can set the header and rate limiting becomes decorative. Behind that proxy the right-most hop is the client: the proxy appends the address it saw to whatever the caller sent, so anything to its left is the caller's own claim.

Output and input hygiene

  • CSV export prefixes ' to any text cell (category, currency, description) that starts with =, +, -, @, tab or carriage return, so a spreadsheet shows it instead of running it. Amount columns are left as numbers.
  • Search treats % and _ as ordinary characters, not LIKE wildcards.
  • Amounts must be below 10^24 (the width of the stored column); a larger one is refused with 422 rather than failing inside the database.

Process isolation

Both systemd units run with the privileges they actually need, which is none:

NoNewPrivileges  PrivateTmp  ProtectSystem=strict  ProtectHome
ReadWritePaths=/opt/kasbbook  ProtectKernelTunables  ProtectControlGroups

The bot makes outbound HTTPS calls and talks to loopback. Everything else is denied.

What is deliberately not built

  • No 2FA. Worth having; not yet written. On the roadmap.
  • No password reset by email. There is no mail path, and a reset flow without one is a support ticket pretending to be a feature.
  • No recovery of a deleted account. DELETE /auth/me is immediate and irreversible: there is no grace period and no undo, and the preview endpoint exists so the decision is made with the consequences in view. It asks for the password when the account has one, and refuses while a shared book would be orphaned — see data-model.md.
  • No unofficial provider APIs. No reverse-engineered web-client endpoints, no logging in as a human with an OTP. A bot posts as a bot.

Consent-based invitations and account switching

Members may create book categories through the explicit create_category permission. This does not grant transaction editing or category rename/delete rights. Viewers and inactive memberships cannot create categories.

Unknown messenger starts create a fresh account directly. Existing explicit link deep links retain their proof-based linking behavior. Account switching requires a five-minute, one-time code delivered to an already-linked identity on the destination account's same provider. Unknown destinations use decoy challenges with the same requester response. Only a hash is stored; raw proof never enters conversation state or logs. Each challenge permits five attempts; requesting identities and destination accounts are limited to five requests per hour. A new request consumes previous challenges for that identity.

Switching moves only the requesting messenger identity. It never merges books, memberships, credentials, payroll or balances. A source account with books must retain another linked identity or password-backed email/phone access. The destination identity used for proof must still belong to the destination when redeemed. Access-token generations advance and refresh tokens are revoked for both accounts. API keys retain their existing account ownership.

Team invitations are provider-scoped and require recipient consent. Numeric IDs on different providers never match. Observed usernames can change or be ambiguous; ambiguous lookup is refused and numeric IDs are recommended. Before delivery and acceptance, sender permission is checked again. Recipient-scoped lookup hides invitations from other users.