Skip to content
Merged
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,19 @@ Conventional Commits by release-please.

## Unreleased

- Security: Kotiko no longer sends your server's access key; it signs each request. Your
server signs each answer too, and Kotiko uses an answer only when it carries that
signature, so if another program takes your server's place (say, while the server
restarts) it gets neither your key nor your words. Update the server and extension
together: an older server turns signed requests away, and Kotiko asks you to update it.
Tools such as `curl` can still send the key as before. The privacy policy (version 4)
says so.

- Security: a web page, or any program on your computer, could lock Kotiko out of your
server for a minute by sending it a burst of wrong keys or malformed checks. Requests
from your own computer are no longer locked out; requests from other computers still
are, including those a reverse proxy on your computer passes on.

- Release process: the check that holds store uploads until the security review is closed
could miss a "Gate: open" line written with a lookalike letter from another alphabet, an
invisible character or a fullwidth colon, and read the review as closed. It now reads
Expand Down
11 changes: 7 additions & 4 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ The extension works in one of two modes, chosen in its settings (`wordsHome` in
```
web page ── content script ◀── storage.local (a copy of the words and settings pages need)
▲
popup, dashboard, welcome ──▶ background ──HTTP + token──▶ Kotiko server ──▶ model API
popup, dashboard, welcome ──▶ background ──HTTP, signed──▶ Kotiko server ──▶ model API
│ (Elixir, ──▶ en.wiktionary.org
Telegram (your phone) ◀──── long polling ────────────────────┤ SQLite) ──▶ transcription
▼ (optional)
Expand Down Expand Up @@ -126,9 +126,12 @@ database migrations (with a backup first), a startup summary, then the supervisi
`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` 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).
`HEAD /health` and `POST /api/v1/proof` are open; `Bearer <token>` or a request signed
with it, [`Kotiko.RequestAuth`](../server/lib/kotiko/request_auth.ex), whose answers are
signed back; an address other than this computer's 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; a signed
request's body is checked against its signed hash first).
`/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
4 changes: 2 additions & 2 deletions docs/best-practices.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,14 +134,14 @@ What has to happen for the badge, and to keep it, and who does it:
|---|---|---|---|---|---|
| `know_secure_design` | MUST | Met | The lead maintainer applies the Saltzer and Schroeder principles plus limited attack surface and allowlist input validation; how each is applied is written down in docs/security/assurance-case.md section 4 (for example deny-by-default authentication, loopback bind, one auth plug before routing). | `docs/security/assurance-case.md:102-117`; `server/lib/kotiko/router.ex:206-214` (`authorize/2`) | – |
| `know_common_errors` | MUST | Met | The common weaknesses for this kind of software and how each is countered (XSS, SQL injection, command injection, path traversal, missing authentication, CSRF, SSRF, unsafe deserialization, resource exhaustion, hard-coded credentials, secrets in logs, DNS rebinding, supply chain) are listed in docs/security/assurance-case.md section 5; the adversarial review in docs/research/06-adversarial-qa.md drove the fixes. | `docs/security/assurance-case.md:119-140`; `docs/research/06-adversarial-qa.md` | – |
| `crypto_published` | MUST | Met | Kotiko implements no cryptography. It relies on TLS (Erlang/OTP's ssl for the server's outbound requests, the browser for the extension), SHA-256, PKCE with S256 (RFC 7636) for connecting OpenRouter, and the operating system's and browser's secure random sources. | `server/lib/kotiko/token.ex:59`; `server/lib/kotiko/llm/cache.ex:26,47`; `extension/lib/pkce.js:33-37`; `extension/lib/llm/client.js:50` | – |
| `crypto_published` | MUST | Met | Kotiko implements no cryptography. It relies on TLS (Erlang/OTP's ssl for the server's outbound requests, the browser for the extension), SHA-256, HMAC-SHA256 (RFC 2104, through Erlang's :crypto and the browser's WebCrypto) to sign each request to the server and each answer instead of sending the token, PKCE with S256 (RFC 7636) for connecting OpenRouter, and the operating system's and browser's secure random sources. | `server/lib/kotiko/token.ex:59`; `server/lib/kotiko/llm/cache.ex:26,47`; `extension/lib/pkce.js:33-37`; `extension/lib/llm/client.js:50` | – |
| `crypto_call` | SHOULD | Met | Only library calls: Erlang :crypto, Plug.Crypto.secure_compare, OTP ssl, and the browser's Web Crypto API. | Same; `server/lib/kotiko/router.ex:214` | – |
| `crypto_floss` | MUST | Met | Erlang/OTP (crypto and ssl, built on OpenSSL) and the Web Crypto API (implemented by Firefox and Chromium) are FLOSS. | – | – |
| `crypto_keylength` | MUST | Met | By default Kotiko's secrets are 256-bit: the server generates the API token from 32 random bytes. A token the person sets themselves must be at least 24 characters, and the server suggests openssl rand -hex 24 (192 bits). Hashes are SHA-256. TLS key exchange and lengths are left to Erlang/OTP's and the browser's defaults; Kotiko offers no setting that weakens them. | `server/lib/kotiko/token.ex:11,48,59`; `server/lib/kotiko/config.ex:297`; `docs/reference/configuration.md:91` | – |
| `crypto_working` | MUST | Met | No MD4, MD5, DES, RC4, ECB or disabled certificate checks in Kotiko's code. | `grep -rniE "md5\|md4\|rc4\|:des\b\|verify_none\|\becb\b" server/lib server/config extension scripts --include=*.ex --include=*.exs --include=*.js --include=*.mjs --include=*.sh` finds nothing (2026-10-06) | – |
| `crypto_weaknesses` | SHOULD | Met | No default security mechanism depends on a weak algorithm or mode: there is no SHA-1, MD4, MD5, RC4, DES, ECB or disabled certificate check in Kotiko's code. SHA-256 is used where a hash is needed (lookup cache keys, the PKCE challenge), and TLS is left to Erlang/OTP and the browser. | `grep -rniE "sha1\|sha-1"` over the same paths finds nothing (2026-10-06) | – |
| `crypto_pfs` | SHOULD | Met | Kotiko negotiates no keys itself. Its TLS connections use Erlang/OTP (TLS 1.3 and 1.2 by default from OTP 26, the oldest supported) and the browser, which use forward-secret key exchange (TLS 1.3 always; ECDHE for TLS 1.2). | `.github/workflows/ci.yml:46-47` (OTP 26.2 and 28.1) | – |
| `crypto_password_storage` | MUST | N/A | Kotiko has no user passwords. The server is protected by one random API token (bearer token) and compares it in constant time; there are no user accounts. | `server/lib/kotiko/router.ex:206-214`; `server/lib/kotiko/token.ex` | – |
| `crypto_password_storage` | MUST | N/A | Kotiko has no user passwords. The server is protected by one random API token, sent by tools as a bearer token or used by the extension to sign each request (HMAC-SHA256), and compares either in constant time; there are no user accounts. | `server/lib/kotiko/router.ex:206-214`; `server/lib/kotiko/token.ex` | – |
| `crypto_random` | MUST | Met | Tokens, ids, salts and nonces come from cryptographically secure generators: :crypto.strong_rand_bytes on the server, crypto.getRandomValues and crypto.randomUUID in the extension (including the PKCE verifier). Math.random is used only for non-secret values (instance labels, confetti). | `server/lib/kotiko/token.ex:59`; `server/lib/kotiko/uuid7.ex:19`; `extension/lib/pkce.js:34`; `extension/lib/store.js:85`; `extension/content.js:70`; non-secret: `extension/content.js:471`, `extension/lib/word-source.js:53`, `extension/ui/confetti.js:121` | – |
| `delivery_mitm` | MUST | Met | The source is delivered from GitHub over HTTPS (git over HTTPS or SSH). The release pipeline publishes each release on GitHub with SHA256SUMS and Sigstore build provenance, and the stores (Chrome Web Store, addons.mozilla.org) sign and deliver their packages over HTTPS. | Repository URL; `.github/workflows/release.yml:128-165`; `docs/verify.md` | – |
| `delivery_unsigned` | MUST | Met | No hashes are published or fetched over HTTP: SHA256SUMS is a GitHub release file served over HTTPS and covered by the release's attestation. | `.github/workflows/release.yml:128-160`; no `http://` fetch outside localhost in `scripts/` or `.github/workflows/` | – |
Expand Down
10 changes: 7 additions & 3 deletions docs/privacy/en.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Kotiko privacy policy

Version 3 · 6 October 2026
Version 4 · 7 October 2026

Kotiko is a browser extension that swaps words on the web pages you read for words you're
learning in other languages. This page says exactly what it keeps, what it sends, and to
Expand Down Expand Up @@ -87,8 +87,10 @@ and once in the background for words saved earlier. Wikimedia's privacy policy:
<https://foundation.wikimedia.org/wiki/Policy:Privacy_policy>.

**Your key and your server's access key** are kept in a part of the extension's storage
that web pages and Kotiko's own page scripts can't read. Each is sent only to the service
it belongs to, at the address you chose in Kotiko's settings.
that web pages and Kotiko's own page scripts can't read. Your key is sent only to the
service it belongs to, at the address you chose in Kotiko's settings. Your server's access
key is never sent at all: Kotiko signs each request to your server with it, and uses an
answer only if the server signed it with the same key.

**Listening to a word.** Kotiko uses the voices on your computer. If you turn on "Allow
online voices" in Settings (it's off by default), your browser may send the word you play
Expand Down Expand Up @@ -181,6 +183,8 @@ everything in Kotiko's Settings.

## Changelog

- **Version 4, 7 October 2026.** Your server's access key no longer leaves your browser:
Kotiko signs each request to your server with it instead of sending it.
- **Version 3, 6 October 2026.** "What websites can see" now says what a site can learn
from the words Kotiko swaps: Kotiko swaps only text on your screen, at most 500
different words in one visit to a page, and adds nothing where it's paused or off.
Expand Down
4 changes: 2 additions & 2 deletions docs/privacy/inventory.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ Last checked against the code: 2026-10-06 (extension 0.2.0, spec 2.0.0).
| Text you type or paste to add words (the popup, the dashboard, the welcome page, bulk add), plus your base languages, up to five of your recent target languages and a language hint | Add jobs in IndexedDB (`meta`), with a copy in `storage.local` for the popup (kept 7 days after they finish, at most 20); the checked result in a lookup cache in IndexedDB (30 days, at most 5,000) | Yes, when you add a word that needs a lookup. Never for "word = meaning" lines | The provider you chose (OpenRouter by default; OpenAI, Anthropic, Google Gemini, Groq, a local Ollama or LM Studio, or any address you enter), or your Kotiko server, which asks its own provider | `lib/llm/client.js` `call()`, `background.js` `lookupJob()` |
| Your saved words (word, meaning, sense, base language), to write their pronunciations | Nothing new | Yes, for words saved without a pronunciation (added by hand, imported, or from an older version), at most twice per word, in the background, when words live in this browser and a provider is set up | The provider you chose | `lib/refresh-job.js` → `client.respell()` |
| A word you add in a language with word stress (Russian, Ukrainian, Spanish and others in `spec/pronunciation.json`): the word only | The Wiktionary page's pronunciation section, 30 days, in the lookup cache | Yes, when you add the word with a lookup or as "word = meaning", and once in the background for words saved earlier | The Wikimedia Foundation (en.wiktionary.org), with a `Api-User-Agent` header naming Kotiko and `hello@scriptkittyos.com` | `background.js` `wiktionaryPage()`, `lib/wiktionary-pass.js` |
| Your provider key and your server's access token | IndexedDB (`secrets`), which only the background reads. Never in `storage.local`, `storage.sync` or `storage.session`; never shown again in full (the settings show the first six and last four characters) | Only to the service it belongs to, and only at an address chosen on one of Kotiko's own pages (slice 28 §7) | Your provider; your Kotiko server | `background.js` `secret()`, `secretFor()`, `routeAllows()` |
| Your provider key and your server's access token | IndexedDB (`secrets`), which only the background reads. Never in `storage.local`, `storage.sync` or `storage.session`; never shown again in full (the settings show the first six and last four characters) | The provider key: only to the service it belongs to, and only at an address chosen on one of Kotiko's own pages (slice 28 §7). The server's token: never; each request to the server is signed with it (`Authorization: Kotiko-HMAC v1 ts, nonce, body hash, mac`) and the server's answers must carry its signature (slice 54, D-01) | Your provider; nobody (your server gets signatures made with the token) | `background.js` `secret()`, `secretFor()`, `routeAllows()`, `request()`; `lib/server-auth.js` |
| Which provider, model and server address you use; whether a key is saved (yes or no, never the key) | IndexedDB (`meta`), with a copy in `storage.local` that pages read | Each request goes to that address | Your provider or your server | `lib/local-mode.js` `readSettings()` |
| The model list (any provider whose models Kotiko lists) and your remaining free lookups (OpenRouter only) | IndexedDB `meta`; the list refreshed at most daily, the count at most every 5 minutes while lookups run | The request itself, with your key | Your provider (`/models`); OpenRouter (`/key`) | `lib/llm/client.js`, `lib/llm/catalog.js` |
| The Test button's lookup | Nothing | The word "hello" and your base languages | Your provider | `lib/llm/client.js` `test()` |
Expand Down Expand Up @@ -156,7 +156,7 @@ differs from this table.

| File | Count | What they fetch |
|---|---|---|
| `extension/background.js` | 8 | `importScripts` of Kotiko's own library files; Kotiko's own `stopwords.json` and `sensitive-sites.json` (from the package, no network); the server (`request()`), and before the token goes there, its proof that it holds the same token (`askProof()`: a random value out, a keyed hash back, no token); the provider client's `fetch`; Wiktionary (`wiktionaryPage()`); OpenRouter's key exchange for the sign-in |
| `extension/background.js` | 8 | `importScripts` of Kotiko's own library files; Kotiko's own `stopwords.json` and `sensitive-sites.json` (from the package, no network); the server (`request()`, each request signed with the token and each answer's signature checked, `lib/server-auth.js`; the token itself is never sent), and before the first request and before any write whose last proof is over 30 s old, its proof that it holds the same token (`askProof()`: a random value out, a keyed hash back, no token); the provider client's `fetch`; Wiktionary (`wiktionaryPage()`); OpenRouter's key exchange for the sign-in |
| `extension/lib/llm/client.js` | 1 | The provider: `/chat/completions`, `/models`, `/key` (OpenRouter) |
| `extension/lib/pkce.js` | 1 | OpenRouter's `/api/v1/auth/keys` (the sign-in's code exchange) |
| `extension/lib/i18n.js` | 1 | Kotiko's own `_locales/*/messages.json` (from the package) |
Expand Down
23 changes: 16 additions & 7 deletions docs/reference/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,9 @@ behind a reverse proxy). Used only in the pairing string from

### `API_TOKEN`

The token the extension sends as `Authorization: Bearer <token>` (see the
[HTTP API](http-api.md#requests)). A secret.
The token that opens the server. A secret. The extension never sends it: it signs each
request with it ([signed requests](http-api.md#signed-requests)); `curl` and other tools
send it as `Authorization: Bearer <token>` (see the [HTTP API](http-api.md#requests)).

- Default: none. Then the server uses the token saved in `<data folder>/api-token`, and on
the first start makes one there: 32 random bytes from the operating system's secure
Expand All @@ -110,11 +111,19 @@ The token the extension sends as `Authorization: Bearer <token>` (see the
(`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.
same check runs on a token you put in `api-token` yourself. Ten wrong tokens or failed
signatures from one address within a minute lock that address out for the rest of the
minute ([HTTP API](http-api.md#requests)), except requests from this computer
(127.0.0.0/8 and `::1`) without a forwarding header, which every local program and web
page shares, so none of them can lock the extension out;
[`POST /api/v1/proof`](http-api.md#post-apiv1proof) isn't limited for them either.
Requests a reverse proxy on this computer forwards (they carry a header such as
`X-Forwarded-For` or `Forwarded`) are limited, all together: a stranger's lockout
refuses everyone coming through the proxy for the rest of that minute, never the
extension. A token the server made (256 random bits) can't be guessed, so this costs
nothing; a weak token you chose can be guessed by any program on this computer without
limit, and the proof lets anyone who can reach the server test guesses offline. Keep
the generated token.
- 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 Down
Loading