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

## Unreleased

- Security: before Kotiko sends your server's access key, the server must now show it holds
the same key, without either side sending it. Another program listening at the address
(for example while your server is stopped) never gets the key. Update your Kotiko server
along with the extension: an older server can't show it, and Kotiko says so in Settings.

- Security: after "Delete everything", Kotiko starts afresh and no longer takes an old-style
server address and token that a page's script left in the browser's storage meanwhile.

- Settings and the welcome page now warn when the address of your lookup service starts
with `http://` and isn't on your computer: your key and the words you look up would
travel unencrypted. The server address already warned.

- Security: Kotiko's default server address is now `http://127.0.0.1:4747`, and an address
you type as `http://localhost:…` is used as `127.0.0.1`, for your server and for Ollama
and LM Studio. Browsers try `localhost` at the IPv6 address `[::1]` first, where another
account on the same computer could listen and receive your server's access token. An
address saved as `localhost` moves to `127.0.0.1` on update; nothing to do on your side.

- Connect OpenRouter: a web page you have open can no longer spoil a sign-in in progress.
Before, any page could open Kotiko's return page with a made-up code while you were
signing in, and your real sign-in then said "This sign-in has expired". Kotiko now
takes a code only from the sign-in it started, and a code that doesn't work no longer
ends the sign-in.

- 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
Expand Down
6 changes: 4 additions & 2 deletions docs/privacy/en.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,8 +128,10 @@ account, the language you asked the bot to write in.

If your browser syncs extension data, the languages you read in, Kotiko's interface
language, and a random value that keeps word choices the same on all your devices are
stored in your browser account by Google or Mozilla, under their terms. Your words and
keys are not synced this way.
stored in your browser account by Google or Mozilla, under their terms. The languages go
there only once you've confirmed them (with your first word, Skip on the welcome page, or a
change in Settings); the ones Kotiko suggests from your browser's languages don't. Your
words and keys are not synced this way.

## Keeping and deleting your data

Expand Down
9 changes: 5 additions & 4 deletions docs/privacy/inventory.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ Last checked against the code: 2026-10-06 (extension 0.2.0, spec 2.0.0).
| 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()` |
| Settings: Kotiko on or off, paused sites (host names you paused), words you chose never to swap, languages hidden, amount, speech, celebrations, add jobs | IndexedDB (`meta`), with a copy in `storage.local` that content scripts and pages read | Never | Nobody | `lib/settings.js`, `background.js` `saveSettings()` |
| Your base languages, Kotiko's interface language, and a random 32-character value that keeps word choices the same across your devices (`seedSalt`) | `storage.sync`, and IndexedDB (`meta`) with a copy in `storage.local` | Through your browser's own sync, if you turned it on | Google or Mozilla, under their sync terms. Stays in your browser account after an uninstall | `background.js` `ensureSeedSalt()`, `welcome.js`, `dashboard.js` |
| The browser's languages (to suggest your base languages) | Your base languages, above | Never | Nobody | `background.js` `detectBrowserBases()` |
| Your base languages, Kotiko's interface language, and a random 32-character value that keeps word choices the same across your devices (`seedSalt`) | IndexedDB (`meta`) with a copy in `storage.local`; the languages also in `storage.sync` once you confirm them (your first word, Skip on the welcome page, or a change in Settings), the random value from the start | Through your browser's own sync, if you turned it on | Google or Mozilla, under their sync terms. Stays in your browser account after an uninstall | `background.js` `saveUi()`, `ensureSeedSalt()`, `welcome.js`, `dashboard.js` |
| The browser's languages (to suggest your base languages) | IndexedDB (`meta`) with a copy in `storage.local` (`ui.baseLangsDetected`); never in `storage.sync` | Never | Nobody | `background.js` `detectBrowserBases()`, `saveUi()` |
| A word you ask to hear | Not kept | Only if you turn on "Online voices" in Settings (off by default); then your browser may send it to its speech service | Your browser's or operating system's speech service (for example Google, Microsoft or Apple) | `lib/speak.js` (`localService`) |
| "Try it on a page" on the welcome page | Nothing | Only if you click it: it opens a Wikipedia search for your word in a new tab | Wikipedia (the Wikimedia Foundation), as any link you open | `lib/welcome-model.js` `wikipediaUrl()` |
| Your base languages and the interface language you chose (none when automatic), for your server's Telegram bot (slice 41 §9) | Your Kotiko server's database (`profile`), if you connect one | Only if you connect a server | Your own Kotiko server, when you change them in the dashboard or the welcome page, after connecting, and after a sync when the server has an older copy | `background.js` `sendProfile()`; `server/lib/kotiko/profile.ex` |
Expand Down Expand Up @@ -119,7 +119,8 @@ at run time.
| `api.anthropic.com` | Yes, when Anthropic is your provider | Lookups |
| `generativelanguage.googleapis.com` | Yes, when Google Gemini is your provider | Lookups |
| `api.groq.com` | Yes, when Groq is your provider | Lookups |
| `localhost` | Yes, when a local provider (Ollama, LM Studio) or a server on this computer is chosen | Default addresses and examples |
| `127.0.0.1` | Yes, when a local provider (Ollama, LM Studio) or a server on this computer is chosen | Default addresses and examples. An `http://localhost` address you type is sent to 127.0.0.1 instead, because browsers try `[::1]` first for that name, where another account on the computer could listen |
| `localhost` | No | Code comments and the check that turns `http://localhost` into 127.0.0.1 |
| `en.wiktionary.org` | Yes, for words in languages with word stress | Pronunciations: the word only |
| `*.wikipedia.org` | Only when you click "Try it on a page" | A link the welcome page opens |
| `es.wikipedia.org` | No | An example in a code comment |
Expand All @@ -141,7 +142,7 @@ differs from this table.

| File | Count | What they fetch |
|---|---|---|
| `extension/background.js` | 7 | `importScripts` of Kotiko's own library files; Kotiko's own `stopwords.json` and `sensitive-sites.json` (from the package, no network); the server (`request()`); 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()`), 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/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
4 changes: 4 additions & 0 deletions docs/reproducible-builds.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ text file's line endings as LF on checkout, so the files match.
- **Which files**: everything in `extension/` except dotfiles, `*.map`, editor leftovers and
the patterns in `extension/.buildignore`, plus `LICENSE`, `NOTICE` and `LICENSES/`. A
symlink stops the build.
- **Code only the tests use**: the lines between `// test-only: start` and
`// test-only: end` in a script (the `globalThis.__kotiko` hook at the end of
`background.js`) are left out of the zips, so a zip's `background.js` is the source file
without them. A block left open, or `__kotiko` outside one, stops the build.
- **The manifests**: written from `extension/manifest.json` by fixed rules (Chrome drops
Firefox's keys, Firefox drops `background.service_worker`), as JSON with two-space
indentation.
Expand Down
15 changes: 10 additions & 5 deletions docs/security/assurance-case.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,9 @@ its argument and evidence.
| Words stay out of logs at the default level | Lookup input and model answers are logged only with `LOG_LOOKUPS=true`, at debug; save failures log the field names, not the values | [`llm.ex`](../../server/lib/kotiko/llm.ex) `log_lookups?/0`; [`router_v1.ex`](../../server/lib/kotiko/router_v1.ex) `result_json/1`; [`llm_test.exs`](../../server/test/kotiko/llm_test.exs) "no path logs the input or a returned word at the default level" |
| Bad settings stop the server; secrets aren't echoed | Every variable is parsed by one function that returns a problem; any problem halts with status 78 and one message; secret problems are labelled "(value hidden)" | [`config.ex`](../../server/lib/kotiko/config.ex) `parse/2`, `secret_error/2`; [`config_test.exs`](../../server/test/kotiko/config_test.exs) "a short API_TOKEN is refused without echoing it" |
| Text never becomes HTML in the extension | ESLint bans `innerHTML`, `outerHTML` and `insertAdjacentHTML` in `extension/` and runs `eslint-plugin-no-unsanitized`; CI fails on any finding | [`eslint.config.js`](../../eslint.config.js); [`ci.yml`](../../.github/workflows/ci.yml) `npm run lint` |
| Pages can't use the extension to change words | One message router checks each sender's kind (extension page or content script) against the handler's allowed list; content scripts may only `sync`, read `sensitiveSites` and mark one word never to swap (`neverSwap`); `oauth.code` is accepted only from the docs site's callback page (`https://kotiko.org/connect/` with a code; the site's own test checks that page has no script) | [`lib/messages.js`](../../extension/lib/messages.js) `senderKinds`, `createMessageRouter`; [`background.js`](../../extension/background.js) handler table; [`sync-libs.test.mjs`](../../test/unit/sync-libs.test.mjs) "disallowed senders get forbidden"; [`local-mode.test.mjs`](../../test/bg/local-mode.test.mjs) "content scripts can't set, describe or remove secrets, change modes, write words or move them" |
| Pages can't use the extension to change words | One message router checks each sender's kind (extension page or content script) against the handler's allowed list; content scripts may only `sync`, read `sensitiveSites` and mark one word never to swap (`neverSwap`); `oauth.code` is accepted only from the docs site's callback page (`https://kotiko.org/connect/` with a code; the site's own test checks that page has no script), and only with the random `state` of the sign-in waiting, so a page that opens the callback with a code of its own neither reaches OpenRouter nor uses up the sign-in | [`lib/messages.js`](../../extension/lib/messages.js) `senderKinds`, `createMessageRouter`; [`background.js`](../../extension/background.js) handler table; [`sync-libs.test.mjs`](../../test/unit/sync-libs.test.mjs) "disallowed senders get forbidden"; [`local-mode.test.mjs`](../../test/bg/local-mode.test.mjs) "content scripts can't set, describe or remove secrets, change modes, write words or move them" |
| Pages can't change your settings, word list or add queue | Content scripts can write `storage.local` and `storage.sync` (Firefox can't stop them). The background keeps the real copy of every setting, of the pages' word list and of the add jobs in its IndexedDB, reads only that, and mirrors it to `storage.local`; a change it didn't make there is put back and an unknown key removed. Pages change settings with `settings.set` (pages only), checked against an allowlist of keys and shapes; content scripts may only add or remove one never-swap word. Chrome 140+ closes `storage.sync` to content scripts (`setAccessLevel`), and only then are changes there taken | [`lib/settings.js`](../../extension/lib/settings.js) `createArea`, `edit`; [`background.js`](../../extension/background.js) `area`, `adoptOnce`, `adoptSync`, `saveSettings`; [`settings.test.mjs`](../../test/bg/settings.test.mjs) (one test per attack); [`settings.test.mjs`](../../test/unit/settings.test.mjs); [`privacy.spec.mjs`](../../test/e2e/privacy.spec.mjs) "settings, the add queue and the bases written from a content script are put back and never used" |
| The token goes only to the server that holds it | Before the first request that carries the token in a worker's life (and after the server couldn't be reached), the background sends a fresh random nonce to `POST /api/v1/proof` and checks the answer, HMAC-SHA256 of the nonce under the token, with WebCrypto; a missing or wrong proof means nothing is sent. Another account listening at the address (on `[::1]`, or on 127.0.0.1 while the server is stopped) never gets the token (slice 54, B-01). The extension also uses `127.0.0.1` for a typed `localhost` | [`background.js`](../../extension/background.js) `proveServer`, `askProof`; [`background.test.mjs`](../../test/bg/background.test.mjs) "the server proves it holds the token before it gets it"; [`loopback.spec.mjs`](../../test/e2e/loopback.spec.mjs) |
| Key and token out of content scripts' reach | Secrets live in the extension origin's IndexedDB, which content scripts can't open; the upgrade moves an old token out of `storage.local` | [`lib/store.js`](../../extension/lib/store.js) `secrets`; [`lib/local-mode.js`](../../extension/lib/local-mode.js) migration step 6; [`local-mode.test.mjs`](../../test/bg/local-mode.test.mjs) "the key and the server token are in no storage area a content script can read" |
| Answers are checked before use | Server word lists pass `validateWordsResponse` (shape, types, lengths, counts); model answers pass the shared word spec in both runtimes, against the same fixtures | [`lib/validate-words.js`](../../extension/lib/validate-words.js); [`lib/wordspec.js`](../../extension/lib/wordspec.js); [`word_spec.ex`](../../server/lib/kotiko/word_spec.ex) `process/2`; [`sync-libs.test.mjs`](../../test/unit/sync-libs.test.mjs) "validateWordsResponse"; [`wordspec.test.mjs`](../../test/unit/wordspec.test.mjs), [`word_spec_test.exs`](../../server/test/kotiko/word_spec_test.exs) |
| No telemetry | The only network calls are to the user's server, the model provider, OpenRouter's key exchange (when the user signs in there), Telegram and Wiktionary, as listed above and in [`background.js`](../../extension/background.js) and [`lib/llm/client.js`](../../extension/lib/llm/client.js) | Code reading; slice 28 writes the full data inventory (planned) |
Expand Down Expand Up @@ -175,8 +176,9 @@ These match "What you can't expect" in the [requirements](requirements.md).
could plant a token or a database link. On a disk that keeps no POSIX permissions (a
Windows drive in WSL) nothing can be made private; the server warns about the files and
refuses a folder that others can write in.
- **Plain HTTP beyond loopback** exposes the token and words to the network. The server
warns; it does not refuse.
- **Plain HTTP beyond loopback** exposes the token and words to the network, and a lookup
service's key and the words looked up. The server warns, and so do the extension's
address fields for the server and for a lookup service; neither refuses.
- **The model provider sees every lookup**, under its own terms.
- **Pages can see swapped words** in their own DOM.
- **A subverted content script** (code running in Kotiko's content-script world) can't
Expand All @@ -191,8 +193,11 @@ These match "What you can't expect" in the [requirements](requirements.md).
`storage.local` as it is, once; one from before slice 28 also takes its server address
and the address of a local or "custom" lookup service. These were writable by content
scripts then and can't be checked. An address for a hosted service, which no page shows
a field for, is dropped. There have been no releases, so only builds from source are
affected.
a field for, is dropped. A store set up from `storage.local` is kept only on an update
from 0.1 or 0.2 (`onInstalled`'s `previousVersion`); on any other start it is dropped
like a new install's, and a store born from "Delete everything" never reads
`storage.local` at all, even after the worker restarts (slice 54, A-06). There have been
no releases, so only builds from source are affected.
- **One token, full access.** Anyone with it can read, change and delete every word.
- **Model answers are checked, not verified**; a plausible wrong meaning can be saved.
- **No rate limit** for requests that carry the token.
Expand Down
10 changes: 9 additions & 1 deletion extension/_locales/en/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -459,9 +459,13 @@
"description": "The server answered that the access key is wrong."
},
"error_server_address_invalid": {
"message": "That server address doesn't look right. Try one like http://localhost:4747.",
"message": "That server address doesn't look right. Try one like http://127.0.0.1:4747.",
"description": "The server address can't be used as typed. Keep the example address as it is."
},
"error_not_kotiko_server_no_proof": {
"message": "Kotiko didn't send your access key: the server at that address couldn't show it has the same key. If it's your Kotiko server, update it. Otherwise, check the address.",
"description": "Slice 25 error (code not_kotiko_server, reason no_proof): before sending the access key, Kotiko asks the server to prove it holds the same key; an older Kotiko server, or another program listening at the address, can't."
},
"error_not_kotiko_server": {
"message": "Something answered at that address, but it isn't a Kotiko server. Check the address.",
"description": "The address answered with something other than Kotiko's word list."
Expand Down Expand Up @@ -4940,6 +4944,10 @@
"message": "The privacy policy couldn't be shown here. Read it at kotiko.org/privacy.",
"description": "Shown on the privacy policy page if its text can't be loaded. Keep the address kotiko.org/privacy as it is."
},
"lookup_url_unencrypted": {
"message": "This address starts with http://, so your key and the words you look up travel unencrypted. If anyone else shares this network, use an https:// address.",
"description": "Warning under the address of a lookup service (\"Another service\", Ollama, LM Studio) in Settings and on the welcome page, when the address uses http:// and isn't on this computer or a Tailscale network. Lookups still work. Keep http:// and https:// as they are."
},
"server_url_unencrypted": {
"message": "This address starts with http://, so your access key travels unencrypted. If anyone else shares this network, use an https:// address (for example with tailscale serve).",
"description": "Warning under the server address field when the address uses http:// and isn't on this computer or a Tailscale network. The connection still works. Keep http://, https:// and \"tailscale serve\" as they are."
Expand Down
2 changes: 1 addition & 1 deletion extension/_locales/es/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -459,7 +459,7 @@
"description": "The server answered that the access key is wrong."
},
"error_server_address_invalid": {
"message": "Esa dirección de servidor no parece correcta. Prueba una como http://localhost:4747.",
"message": "Esa dirección de servidor no parece correcta. Prueba una como http://127.0.0.1:4747.",
"description": "The server address can't be used as typed. Keep the example address as it is."
},
"error_not_kotiko_server": {
Expand Down
Loading