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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@ Conventional Commits by release-please.

## Unreleased

- Self-hosted server: other accounts on the same computer can no longer read your words.
The database (with the `-wal` and `-shm` files next to it), the model list cache and a
new data folder are now readable only by you, like the API token and the backups
already were. At start, the server makes these files private again if an older version
left them readable, and logs what it changed. It leaves a data folder that also holds
other files as it is and warns with the command to fix it. `run.sh` and the service
from `install-service.sh` now start the server with umask `077`; run
`install-service.sh` again to update the service.

- Self-hosted server: your keys can now live in their own files instead of `.env`. Set
`LLM_API_KEY_FILE=/path/to/file` in place of `LLM_API_KEY=...`, and the same for
`API_TOKEN`, `TELEGRAM_BOT_TOKEN` and `TRANSCRIBE_API_KEY`; this is how Docker secrets
Expand Down
24 changes: 21 additions & 3 deletions docs/reference/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ The folder that holds your words and the server's files (see

- Default: `$XDG_DATA_HOME/kotiko`, or `~/.local/share/kotiko` when `XDG_DATA_HOME` isn't
set (or isn't an absolute path, which the XDG Base Directory spec says to ignore). The
server creates it if it's missing.
server creates it if it's missing, readable only by you (`0700`).
- Words already in `~/.local/share/kotiko` stay there: while `$XDG_DATA_HOME/kotiko` has no
`kotiko.db` and `~/.local/share/kotiko` has one, the server keeps using
`~/.local/share/kotiko`. To move them, stop the server and move the folder.
Expand Down Expand Up @@ -291,11 +291,29 @@ check.

| File | What it is | Permissions |
|---|---|---|
| `kotiko.db` (with `kotiko.db-wal` and `kotiko.db-shm` while running) | Your words, kept responses for repeated adds (24 hours), cached lookups (30 days), and the background jobs' state. SQLite | Created with your umask: usually readable by other users of the computer unless your umask is `077`. The default folder is inside your home folder. To keep it private: `chmod 700` the data folder (usually `~/.local/share/kotiko`) |
| `kotiko.db` (with `kotiko.db-wal` and `kotiko.db-shm` while running) | Your words, kept responses for repeated adds (24 hours), cached lookups (30 days), and the background jobs' state. SQLite | `0600`. The server creates it empty and private before SQLite opens it, and SQLite gives `-wal` and `-shm` the database's permissions |
| `api-token` | The generated API token, when `API_TOKEN` isn't set | `0600`, written so no other user can read it at any moment |
| `models-cache.json` | OpenRouter's model list as last read, used when the server starts offline | Your umask |
| `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:

- 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
names what it changed.
- The data folder is made `0700` when it holds nothing but Kotiko's files. A folder with
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.

`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
permissions (Linux, macOS, WSL). On Windows, keep the data folder on WSL's own disk, not
on a Windows drive such as `/mnt/c`, which doesn't keep them; the server warns if it can't.

To go back to a backup, see the README, "Updating". To start over, stop the server and
delete `kotiko.db`.

Expand Down
18 changes: 9 additions & 9 deletions docs/security/assurance-case.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ its argument and evidence.
| Only `/health` answers without the token | One plug runs before routing and allows exactly `GET` and `HEAD` `/health`; everything else needs `Bearer <token>`, compared in constant time. It matches on the raw path, so encoded spellings can't slip past | [`router.ex`](../../server/lib/kotiko/router.ex) `authorize/2`; [`router_auth_test.exs`](../../server/test/kotiko/router_auth_test.exs) "every method and path spelling is 401", "only GET and HEAD /health are open"; [`router_socket_test.exs`](../../server/test/kotiko/router_socket_test.exs) "encoded and dotted paths need the token" (through a real socket) |
| `/health` says nothing else | It returns name, version, API versions and database state only | [`health.ex`](../../server/lib/kotiko/health.ex) `report/1` |
| A strong token, kept private | 32 bytes from `:crypto.strong_rand_bytes`, written to a file made 0600 before the token goes in, then renamed into place; a chosen `API_TOKEN` under 24 characters stops the boot | [`token.ex`](../../server/lib/kotiko/token.ex) `generate/1`, `save/2`, `check/3`; [`token_test.exs`](../../server/test/kotiko/token_test.exs) "first boot makes a 43-character token in a 0600 file", "short or whitespace-only tokens are refused"; [`config.ex`](../../server/lib/kotiko/config.ex) `api_token/1` |
| Other accounts on the computer can't read the words | The BEAM can't set a mode on create, so files are created empty, made 0600, then filled: the database before SQLite opens it (SQLite gives `-wal`, `-shm` and `-journal` the database's mode, checked by a test), each backup before `VACUUM INTO`, the token and model cache through a private temporary file. A data folder the server makes is 0700. At every boot, before the Repo opens, `make_private/1` tightens any of Kotiko's files others can open and the folder when it holds only Kotiko's files, and warns otherwise. `run.sh` sets umask 077 and the unit `UMask=0077` | [`private.ex`](../../server/lib/kotiko/private.ex); [`data_dir.ex`](../../server/lib/kotiko/data_dir.ex) `make_private/1`; [`private_test.exs`](../../server/test/kotiko/private_test.exs); [`data_dir_test.exs`](../../server/test/kotiko/data_dir_test.exs); [`boot_test.exs`](../../server/test/kotiko/boot_test.exs) (boots with umask 022 and checks the modes); [`scripts_test.exs`](../../server/test/kotiko/scripts_test.exs) |
| Listens on this computer unless told otherwise; warns when it doesn't | `BIND` defaults to `127.0.0.1`; at boot the address is classified (loopback, Tailscale, LAN, all interfaces, public) and the last three log a warning; a public address repeats it daily | [`config.ex`](../../server/lib/kotiko/config.ex) `@default_bind`; [`exposure.ex`](../../server/lib/kotiko/exposure.ex) `classify/2`; [`application.ex`](../../server/lib/kotiko/application.ex) `repeat_public_warning/2`; [`exposure_test.exs`](../../server/test/kotiko/exposure_test.exs) |
| Answers only to names it knows | A plug before routing allows `localhost`, IP literals, this machine's own name and `ALLOWED_HOSTS`; others get 421, with or without a token | [`host_check.ex`](../../server/lib/kotiko/plug/host_check.ex); [`host_check_test.exs`](../../server/test/kotiko/plug/host_check_test.exs); [`router_auth_test.exs`](../../server/test/kotiko/router_auth_test.exs) "an unknown name gets 421 with or without a token, on every route" |
| Strangers can't make it do work | Body parsing is a plug after `authorize`; 64 KB cap, 1 MB for the batch route | [`router.ex`](../../server/lib/kotiko/router.ex) `parse_body/2`; [`router_auth_test.exs`](../../server/test/kotiko/router_auth_test.exs) "are parsed only after auth", "over 64 KB are refused with 413"; [`router_v1_test.exs`](../../server/test/kotiko/router_v1_test.exs) "takes at most 500 words, and bodies up to 1 MB" |
Expand Down Expand Up @@ -61,7 +62,7 @@ its argument and evidence.
| Someone on the same network | Plain-HTTP traffic when the server listens beyond loopback | Loopback by default; startup warnings; Tailscale or HTTPS advice |
| A malicious or broken model answer | The word pipeline on the server or in the extension | Schema and rule checks, length caps, script checks, text-only rendering |
| A Telegram user who isn't allowed | The bot's chat | Sender allowlist |
| Another user on the server machine | Files in the data folder, depending on permissions | `api-token` and backups are 0600; the database follows the umask (residual risk, section 7) |
| Another user on the server machine | Files in the data folder, depending on permissions | Every file the server keeps there is 0600 (the database is created private before SQLite opens it, so `-wal` and `-shm` are too) and a folder it makes 0700; each start re-checks them; `run.sh` and the systemd unit use umask 077 |
| A compromised dependency or GitHub Action | The build, CI, and later the release | Lockfiles, actions pinned by commit SHA, Dependabot, OSV-Scanner over every lockfile in CI and before each release, `mix hex.audit` and `mix deps.audit` in CI, no runtime npm code in the extension |
| A malicious pull request | The code base | Review by a maintainer; CI with read-only token; branch protection on going public ([PUBLIC_CHECKLIST.md](../PUBLIC_CHECKLIST.md)) |

Expand All @@ -84,7 +85,7 @@ its argument and evidence.
│ ▼ HTTPS ▼ HTTPS ▼ file system
│ model provider, Wiktionary Telegram (allowlisted data folder
│ (answers untrusted) sender IDs) (kotiko.db,
│ api-token 0600)
│ api-token: 0600)
CI (GitHub Actions, read-only token) ──▶ stores, after a maintainer approves (slice 30)
```

Expand All @@ -96,7 +97,7 @@ its argument and evidence.
| Extension to server | HTTP with `Authorization: Bearer` | Token check, Host check, body cap; the extension validates the address and every response | [`router.ex`](../../server/lib/kotiko/router.ex); [`lib/url.js`](../../extension/lib/url.js); [`lib/validate-words.js`](../../extension/lib/validate-words.js) |
| Server or extension to model provider | The learner's typed text, the key | HTTPS to an address the owner set; answers parsed as JSON and checked by the word spec; deadline and attempt cap per lookup | [`llm.ex`](../../server/lib/kotiko/llm.ex) `interpret/3`; [`spec/models.json`](../../spec/models.json) budgets; [`lib/llm/client.js`](../../extension/lib/llm/client.js) |
| Server to Telegram | Updates in, messages out | HTTPS; sender allowlist | [`bot.ex`](../../server/lib/kotiko/bot.ex); [`telegram.ex`](../../server/lib/kotiko/telegram.ex) |
| Server to file system | The database, token, backups | `api-token` 0600; `backups/` 0700 with 0600 files; database follows the umask | [`token.ex`](../../server/lib/kotiko/token.ex); [`migrations.ex`](../../server/lib/kotiko/migrations.ex) `backup!/2` |
| Server to file system | The database, token, backups | `kotiko.db` and its `-wal`/`-shm`, `api-token`, `models-cache.json` 0600; `backups/` 0700 with 0600 files; a data folder the server makes 0700, one that holds only Kotiko's files made 0700 at start (otherwise a warning); umask 077 in `run.sh` and the unit (`UMask=0077`) | [`private.ex`](../../server/lib/kotiko/private.ex); [`data_dir.ex`](../../server/lib/kotiko/data_dir.ex) `make_private/1`; [`migrations.ex`](../../server/lib/kotiko/migrations.ex) `backup!/2`; [`data_dir_test.exs`](../../server/test/kotiko/data_dir_test.exs); [`boot_test.exs`](../../server/test/kotiko/boot_test.exs) |
| CI to stores | Release packages | The store jobs run in the `release` environment: a maintainer approves each run, and only `v*` tags (signed, verified against `.github/allowed_signers`) may deploy. The maintainer creates the environment before the first release | [slice 30](../../slices/30-release-pipeline/SPEC.md) |

## 4. Secure design principles
Expand Down Expand Up @@ -162,12 +163,11 @@ These match "What you can't expect" in the [requirements](requirements.md).

- **Data at rest is not encrypted.** Anyone who can read the browser profile or the data
folder gets the words, keys and token. Assumption: the user's account and disk are theirs.
- **The database file follows the umask.** The server makes `api-token` 0600 and the
`backups/` folder 0700, but creates `kotiko.db` (and the data folder, when it makes it)
with the process's default permissions, and the systemd unit written by
`install-service.sh` sets no `UMask`. On a computer shared with other accounts, they may
be able to read the word list. Not yet assigned to a slice; the natural homes are 29
(server ops) or 40 (packaging).
- **A shared data folder stays shared.** When `KOTIKO_DATA_DIR` points at a folder that
also holds other files, the server makes its own files private but leaves the folder's
permissions alone and warns at every start; other accounts can then see the files' names
and sizes, not their contents. On a disk that keeps no POSIX permissions (a Windows drive
in WSL) nothing can be made private; the server warns.
- **Plain HTTP beyond loopback** exposes the token and words to the network. The server
warns; it does not refuse.
- **The model provider sees every lookup**, under its own terms.
Expand Down
14 changes: 9 additions & 5 deletions docs/security/requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ Kotiko has two parts: the browser extension, and an optional server you run your
and saves it in `api-token` in the data folder, readable only by you (mode 0600). If you
pick your own with `API_TOKEN`, it must be at least 24 characters, or the server won't
start.
- **Other accounts on the computer can't read your words.** The database, its `-wal` and
`-shm` files, the backups, the token and the model list cache are readable only by you
(0600), and a data folder the server makes only by you (0700). At every start it makes
them private again if they aren't, and warns, with the command to run, about anything it
can't or won't change (a data folder that also holds other files).
- **It listens only on your computer** unless you change `BIND`. When you do, it says at
startup who can now reach it, and warns when your token would cross a network in plain
HTTP (repeating that warning daily when the address is reachable from the internet).
Expand Down Expand Up @@ -73,11 +78,10 @@ Kotiko has two parts: the browser extension, and an optional server you run your

- **Protection from someone who can read your browser profile or the server's data
folder.** Keys, the token and your words are not encrypted at rest.
- **That the word database is private to you by default.** The server makes `api-token`
and its backups readable only by you, but it creates `kotiko.db` with your system's
default file permissions (your umask), which on many systems lets other accounts on the
same computer read it. Run the server under your own account on a computer you don't
share, or tighten the folder yourself (`chmod 700 ~/.local/share/kotiko`).
- **A private data folder when you share it.** If `KOTIKO_DATA_DIR` points at a folder
that also holds other files, the server keeps its own files private but leaves the
folder as it is, and warns at every start. Others can then see the files' names, not
what's in them.
- **Privacy on a network if you use plain HTTP** to a server on another machine. The token
and your words travel unencrypted. Use Tailscale or HTTPS through a reverse proxy.
- **Privacy from the model provider you chose.** Lookups go to it under its terms.
Expand Down
2 changes: 2 additions & 0 deletions server/install-service.sh
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,8 @@ StartLimitBurst=5
WorkingDirectory=${dir//%/%%}
ExecStart="$(exec_escape "$dir")/run.sh"
$environment
# Files the server makes are readable by you only (run.sh sets the same umask).
UMask=0077
Restart=on-failure
RestartSec=5
# 78 is a mistake in .env: retrying won't fix it. Fix .env, then restart by hand.
Expand Down
2 changes: 1 addition & 1 deletion server/lib/kotiko/application.ex
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ defmodule Kotiko.Application do
1. `Kotiko.Config.load!/0` parse and check the settings; exit 78 on a mistake
2. `Kotiko.DataDir.resolve_and_migrate!/0`
the data folder; copies the words from before the
rename to Kotiko, once
rename to Kotiko, once; makes the files private
3. API token from API_TOKEN, the saved file, or a new one
4. `Kotiko.Migrations.run!/0` back the database up if a migration is pending, then
migrate on one connection, before the pool opens
Expand Down
4 changes: 3 additions & 1 deletion server/lib/kotiko/config.ex
Original file line number Diff line number Diff line change
Expand Up @@ -550,10 +550,12 @@ defmodule Kotiko.Config do
end

@doc false
# A folder this makes is private (0700); Kotiko.DataDir.make_private/1 sees to one that
# was already there.
# Sobelow: the folder is KOTIKO_DATA_DIR or the default, never from a request.
# sobelow_skip ["Traversal.FileModule"]
def ensure_dir(dir) do
with :ok <- File.mkdir_p(dir),
with :ok <- Kotiko.Private.mkdir_p(dir),
{:ok, %File.Stat{type: :directory, access: :read_write}} <- File.stat(dir) do
:ok
else
Expand Down
Loading