Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,55 @@ Conventional Commits by release-please.

## Unreleased

- Self-hosted server: after moving your words from the folder used before the rename to
Kotiko, the old copy (`~/.local/share/slovo/slovo.db`) and the old access key could stay
readable by other accounts. The server now makes them private after the move, and at
every start for installs moved earlier. `install-service.sh --uninstall --delete-data`
now deletes that old copy too.

- Self-hosted server: someone who could write in your data folder could choose your access
key (by leaving an `api-token` there) or send your words to a file of theirs (by putting
a link where `kotiko.db` goes). The server now refuses to start, saying what it found
and how to fix it, when the data folder or one of Kotiko's files belongs to another
account, when one of Kotiko's files is a link, or when others can write in a data folder
that also holds other files. A folder others can write in that holds only Kotiko's files
is made private first, with a warning. To keep your words on another disk, point
`KOTIKO_DATA_DIR` there instead of linking `kotiko.db`.

- Self-hosted server: with `LOG_LEVEL=debug`, words the model suggested and the server
refused were written to the log even with `LOG_LOOKUPS=false`. Now only the reasons are,
unless you turn `LOG_LOOKUPS` on.

- Self-hosted server: when your model or transcription key would go over plain `http://`
to another machine (not this computer, not Tailscale), the server now warns at start,
because anyone on the network in between could read it.

- Self-hosted server: a user name and password written inside `LLM_URL` or
`TRANSCRIBE_URL` (`https://user:password@host/...`) went into the log at start. The
server now refuses to start with such an address, without showing it; put the key in
`LLM_API_KEY` or `TRANSCRIBE_API_KEY` instead. The same goes for `PUBLIC_URL`.

- Self-hosted server: an access key you chose yourself that looks easy to guess (few
different characters, a repeated piece, mostly one character) now gets a warning at
every start. Delete `API_TOKEN` to let the server make a random one.

- Self-hosted server: after 10 wrong access keys from one address within a minute, the
server refuses that address for the rest of the minute (`429`) and logs it once. Other
addresses, including yours, keep working. New route `POST /api/v1/proof`: Kotiko can
check that an address really is your server, which proves it holds your access key
without sending it, before Kotiko sends the key there.

- Self-hosted server: another account on the same computer could receive your access key
by listening on `[::1]:4747`, the address browsers try first for `localhost`. The server
now listens on both `127.0.0.1` and `::1` by default (and with `BIND=localhost`), so
nobody else can take either; it refuses to start, saying which address, if another
program already holds one. In Kotiko, enter `http://127.0.0.1:4747` as your server's
address.

- Self-hosted server: requests for many different host names can no longer fill the log.
The server logs each refused name at most once an hour, and at most 1,000 names an hour;
past that it logs one line saying so, and how many it left out.

- Security: a web page that managed to run code inside Kotiko's page script could change
Kotiko's settings, because browsers let that script write Kotiko's storage. It could turn
Kotiko off, pause sites, hide languages, change the words pages show, queue a word to be
Expand Down
14 changes: 10 additions & 4 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Kotiko architecture

Last reviewed: 2026-10-05.
Last reviewed: 2026-10-06.

This page says what runs where, what each part does, where words are kept, what leaves
your machine, and where to look next. It describes the code on `main`. Plans for later
Expand Down Expand Up @@ -120,10 +120,15 @@ order: the log filter that hides secrets, settings, the data folder, the API tok
database migrations (with a backup first), a startup summary, then the supervision tree.

- **HTTP**: Bandit serves [`Kotiko.Router`](../server/lib/kotiko/router.ex), a Plug
router. Every request passes `Kotiko.Plug.HostCheck`
router, on the address in `BIND` and, for a loopback one, on the other loopback address
too ([`Kotiko.Listener`](../server/lib/kotiko/listener.ex)), so no other account can
listen on `[::1]` where browsers send `localhost`. Every request passes
`Kotiko.Plug.HostCheck`
([`plug/host_check.ex`](../server/lib/kotiko/plug/host_check.ex), refuses unknown host
names against DNS rebinding), then the token check (deny by default; only `GET` and
`HEAD /health` are open), then body parsing (64 KB, 1 MB for the batch route).
`HEAD /health` and `POST /api/v1/proof` are open; an address that sent 10 wrong tokens
in a minute gets `429`, [`Kotiko.AuthThrottle`](../server/lib/kotiko/auth_throttle.ex)),
then body parsing (64 KB, 1 MB for the batch route, 1 KB for the proof).
`/api/v1` is forwarded to [`Kotiko.RouterV1`](../server/lib/kotiko/router_v1.ex). The
routes are listed in [`reference/http-api.md`](reference/http-api.md).
- **Settings**: [`Kotiko.Config`](../server/lib/kotiko/config.ex) parses the environment
Expand Down Expand Up @@ -245,7 +250,8 @@ Kotiko treats these as untrusted and checks what crosses them:
HTTP only if you set `BIND` to a network address, with a warning at startup);
- the model's answers, which are checked against the spec before anything is saved;
- Telegram updates (allowlisted user IDs only);
- the files in the data folder, protected by your operating system's file permissions.
- the files in the data folder, protected by your operating system's file permissions
(the server refuses to start when another account could have put files there).

The full threat model, every input and how it is checked, and the evidence are in the
[assurance case](security/assurance-case.md).
Expand Down
65 changes: 55 additions & 10 deletions docs/reference/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,18 @@ The TCP port the server listens on.
The address the server listens on.

- Default: `127.0.0.1`, this computer only.
- Allowed: an IPv4 or IPv6 address (`100.101.102.103`, `0.0.0.0`, `::1`) or a host name
that resolves to one (`localhost`).
- Allowed: an IPv4 or IPv6 address (`100.101.102.103`, `0.0.0.0`, `::`, `::1`) or a host
name that resolves to one. `localhost` is always `127.0.0.1`, whatever the hosts file says.
- Browsers look up `localhost` themselves and try the IPv6 address `::1` first. So that no
other program or account on this computer can listen on the address the browser tries
and receive the token, the server listens on both loopback addresses: with `127.0.0.1`
(the default) or `localhost` it also listens on `::1`, with `::1` also on `127.0.0.1`,
and with `0.0.0.0` also on `::1`. `::` covers IPv4 too (dual-stack). On a computer
without IPv6 the server starts on `127.0.0.1` alone and logs a line saying so. If another
program already listens on one of these addresses at `PORT`, the server doesn't start
(status 1) and says which address; stop that program or choose another `PORT`.
- In the extension, use `http://127.0.0.1:4747` (not `localhost`) for a server on the same
computer.
- Example: your Tailscale address, to use the extension on another computer.
- At start, the server logs who can reach it. For a local network address, `0.0.0.0` or a
public address, that line is a warning: the server speaks plain HTTP, so anyone on the way
Expand Down Expand Up @@ -82,7 +92,8 @@ behind a reverse proxy). Used only in the pairing string from
[`mix kotiko.token`](#mix-kotikotoken).

- Default: none. Allowed: an `http://` or `https://` address; a trailing `/` is dropped.
- Wrong: anything else stops the server ("isn't a web address").
- Wrong: anything else stops the server ("isn't a web address"), and so does a user name
or password in the address (`https://user:password@...`).

### `API_TOKEN`

Expand All @@ -94,6 +105,16 @@ The token the extension sends as `Authorization: Bearer <token>` (see the
random source, written as 43 URL-safe characters, in a file only you can read (mode 0600).
- Allowed: at least 24 characters. Example: the output of `openssl rand -hex 24`.
- Wrong: a shorter token stops the server (the message gives its length, not its value).
- A token that looks easy to guess gets a warning at every start (never showing the
token): one with fewer than 10 different characters, one that repeats a shorter piece
(`passwordpasswordpassword`), or one with under 64 bits by its characters' frequencies
(its length times the Shannon entropy of the character counts, low when most characters
are the same). This is a simple check that catches typed tokens, not a guarantee. The
same check runs on a token you put in `api-token` yourself. Ten wrong tokens from one
address within a minute lock that address out for the rest of the minute
([HTTP API](http-api.md#requests)), but [`POST /api/v1/proof`](http-api.md#post-apiv1proof)
lets anyone who can reach the server test guesses offline, so only a random token is
safe on a network.
- To replace the saved token: `mix kotiko.token --rotate`, then restart. With
`API_TOKEN` set, edit `.env` instead; with [`API_TOKEN_FILE`](#api_token_file), replace
the file's contents.
Expand All @@ -107,7 +128,9 @@ The key for the model API that works out which word you mean. A secret.
and adds answer `503 lookup_not_set_up` until you set it. A local model such as Ollama
needs no key.
- Example: a key from <https://openrouter.ai/keys>.
- Sent only to `LLM_URL`.
- Sent only to `LLM_URL`. When `LLM_URL` is a plain `http://` address on another machine
(not this computer, not a Tailscale address or `*.ts.net` name), the server warns at
start: anyone on the network in between can read the key.
- Or from a file: [`LLM_API_KEY_FILE`](#llm_api_key_file).

### `LLM_URL`
Expand All @@ -117,6 +140,9 @@ The model API: any OpenAI-compatible API's base URL.
- Default: `https://openrouter.ai/api/v1`.
- Example: `http://localhost:11434/v1` for a local Ollama.
- Allowed: an `http://` or `https://` address. Wrong: anything else stops the server.
- A user name or password in the address (`https://user:password@host/v1`) stops the
server too, without showing it: the address is written to the log at start. Put the key
in [`LLM_API_KEY`](#llm_api_key) (or [`LLM_API_KEY_FILE`](#llm_api_key_file)) instead.
- Your typed text goes to this address (README, "A different model").

### `LLM_MODEL`
Expand Down Expand Up @@ -158,7 +184,9 @@ A speech-to-text endpoint for Telegram voice notes: a local whisper.cpp server
upload.

- Default: none; voice notes aren't transcribed (typing still works).
- Allowed: an `http://` or `https://` address. Wrong: anything else stops the server.
- Allowed: an `http://` or `https://` address. Wrong: anything else stops the server, and
so does a user name or password in the address: put the key in
[`TRANSCRIBE_API_KEY`](#transcribe_api_key) instead.

### `TRANSCRIBE_MODEL`

Expand All @@ -171,6 +199,8 @@ The model name sent with each voice note.
Sent as `Authorization: Bearer <key>` to `TRANSCRIBE_URL`, for hosted services. A secret.

- Default: none (a local whisper.cpp server needs none).
- With a plain `http://` `TRANSCRIBE_URL` on another machine (not this computer or
Tailscale), the server warns at start, as for `LLM_API_KEY`.
- Or from a file: [`TRANSCRIBE_API_KEY_FILE`](#transcribe_api_key_file).

### `KOTIKO_DATA_DIR`
Expand Down Expand Up @@ -199,7 +229,9 @@ How much the server logs: `debug`, `info`, `warning` or `error` (`warn` works to
### `LOG_LOOKUPS`

`true` to log what you look up and the model's answer, at debug level (so also set
`LOG_LEVEL=debug`). For troubleshooting a model.
`LOG_LEVEL=debug`). For troubleshooting a model. Without it, even `LOG_LEVEL=debug` logs
no words: not what you typed, not the words the model answered or the server refused
(only the reasons), not the words a database upgrade flags.

- Default: `false`. Allowed: `true`, `yes`, `on`, `1`, `false`, `no`, `off`, `0`.
- Wrong: anything else stops the server.
Expand Down Expand Up @@ -296,8 +328,21 @@ check.
| `models-cache.json` | OpenRouter's model list as last read, used when the server starts offline | `0600` |
| `backups/kotiko-pre-<version>-<time>.db` | A copy of the database made before an update changes it; the newest five are kept | Folder `0700`, files `0600` |

Only your account can open these files. At every start the server checks them before it
opens the database:
Only your account can open these files. At every start, before it reads or writes any of
them, the server checks that nobody else could have put a file there that it would trust
(the token in `api-token` opens the server, and your words are written to `kotiko.db`). It
doesn't start (status 78) and names each problem, with how to fix it, when:

- the data folder, or one of the files above, belongs to another account;
- one of the files above (or `backups/` or a backup in it) is a symbolic link. Kotiko
never makes links, and a link would send your words, or read a token, wherever it
points. To keep your words on another disk, point `KOTIKO_DATA_DIR` there instead (the
data folder itself may be a link);
- other users can write in the data folder and it holds other files too. A folder others
can write in that holds only Kotiko's files is made `0700` first, with a warning.

The server reads `api-token` the same careful way: never through a link, and only if it
belongs to your account. Then it checks the permissions:

- Any of the files above that other users can open (say, from an install before this
check) is made `0600` again, and `backups/` `0700`. The server logs one warning that
Expand All @@ -306,8 +351,8 @@ opens the database:
anything else in it may be shared on purpose (`KOTIKO_DATA_DIR=~`, say), so the server
leaves it as it is and warns at every start, with the command that makes it private
(`chmod 700 <folder>`). Giving Kotiko a folder of its own stops the warning too.
- A file it can't change (owned by another account, say) gets a warning with the
`chmod` that fixes it. The server starts either way.
- A file it can't change gets a warning with the `chmod` that fixes it. The server starts
either way.

`run.sh` and the systemd service from `install-service.sh` also start the server with
umask `077`, so a file it makes is private from the moment it exists. These are POSIX
Expand Down
Loading