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.
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/meis 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.