diff --git a/CHANGELOG.md b/CHANGELOG.md index 503cd6e..c8eb5d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/privacy/en.md b/docs/privacy/en.md index 4cc8528..8a65cea 100644 --- a/docs/privacy/en.md +++ b/docs/privacy/en.md @@ -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 diff --git a/docs/privacy/inventory.md b/docs/privacy/inventory.md index 9fc35f3..68f835d 100644 --- a/docs/privacy/inventory.md +++ b/docs/privacy/inventory.md @@ -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` | @@ -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 | @@ -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) | diff --git a/docs/reproducible-builds.md b/docs/reproducible-builds.md index 2dd4f2a..9444215 100644 --- a/docs/reproducible-builds.md +++ b/docs/reproducible-builds.md @@ -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. diff --git a/docs/security/assurance-case.md b/docs/security/assurance-case.md index 7e9d45a..3ac1739 100644 --- a/docs/security/assurance-case.md +++ b/docs/security/assurance-case.md @@ -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) | @@ -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 @@ -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. diff --git a/extension/_locales/en/messages.json b/extension/_locales/en/messages.json index e42ece3..10bfcd8 100644 --- a/extension/_locales/en/messages.json +++ b/extension/_locales/en/messages.json @@ -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." @@ -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." diff --git a/extension/_locales/es/messages.json b/extension/_locales/es/messages.json index 66c7233..fcef1cb 100644 --- a/extension/_locales/es/messages.json +++ b/extension/_locales/es/messages.json @@ -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": { diff --git a/extension/background.js b/extension/background.js index 469a073..d579177 100644 --- a/extension/background.js +++ b/extension/background.js @@ -72,6 +72,16 @@ function getStore() { return storeP; } +// "Delete everything" leaves a new database holding only this mark (slice 54, A-06): the +// store set up in it later, by this worker or by one started after this one stopped, takes +// nothing from storage.local, which content scripts can write while Kotiko is wiped. Without +// it, a 0.2-style `token` and `serverUrl` planted there were adopted as the trusted server. +const BORN_FROM_WIPE = "bornFromWipe"; +const NO_LEGACY = { get: async (defaults) => ({ ...defaults }), remove: async () => {} }; +// Whether this worker's start set the store up from what storage.local held (an update from +// 0.2, or a database lost some other way); `onInstalled` keeps it only for the former. +let adoptedLegacy = false; + // Everything that reads settings or secrets waits for the upgrade (slice 11 section 8), // which runs at every worker start until it has finished once. let readyP = null; @@ -83,10 +93,11 @@ function ready() { const r = await Local.migrate({ store, storage: trustedCopy, - legacy: ext.storage.local, + legacy: (await store.meta.get(BORN_FROM_WIPE)) ? NO_LEGACY : ext.storage.local, uiLanguage: uiLanguage(), beforeDone: async (from) => from === "new" && (await store.meta.get(TRUSTED)) !== true && store.meta.set(TRUSTED, "fresh"), }); + if (r.migrated && (await store.meta.get("migratedFrom")) !== "new") adoptedLegacy = true; await adoptOnce(store); if (r.migrated && r.home === "local" && r.seeded) projector.schedule(); if (r.migrated) refresh.nudge().catch(() => {}); @@ -138,7 +149,7 @@ const trustedCopy = Settings.createArea({ }); // Everything outside the upgrade itself waits for it, so an install from before this // version never reads its settings before they were taken over (`adoptOnce`). -const area = Object.fromEntries(["get", "set", "remove", "heal", "has"].map((op) => [op, async (...a) => (await ready().catch(() => {}), trustedCopy[op](...a))])); +const area = Object.fromEntries(["get", "set", "update", "remove", "heal", "has"].map((op) => [op, async (...a) => (await ready().catch(() => {}), trustedCopy[op](...a))])); area.reset = () => trustedCopy.reset(); // What Kotiko keeps in storage.local; an install from before the trusted copy has these to @@ -201,7 +212,9 @@ async function adoptSync(changes) { await ready(); const patch = {}; const current = await area.get({ ui: null, seedSalt: null }); - const ui = changes.ui ? pageValue("ui", changes.ui.newValue) : undefined; + const incoming = changes.ui ? pageValue("ui", changes.ui.newValue) : undefined; + // The languages detected here stay this browser's own, whatever another one synced. + const ui = incoming && { ...incoming, ...(current.ui?.baseLangsDetected ? { baseLangsDetected: current.ui.baseLangsDetected } : {}) }; if (ui && !Settings.same(ui, current.ui)) { patch.ui = ui; if (ui.baseLangs) patch.baseLangs = ui.baseLangs; @@ -211,10 +224,17 @@ async function adoptSync(changes) { await area.set(patch); } -// storage.sync's `ui` (slice 50) follows the trusted copy, for the learner's other browsers. -async function saveUi(ui) { - await area.set({ ui }); - await Promise.resolve().then(() => ext.storage.sync.set({ ui })).catch(() => {}); +// storage.sync's `ui` (slice 50) follows the trusted copy, for the learner's other browsers, +// once the learner has confirmed their languages, and never with the ones read from this +// browser's settings (`baseLangsDetected`): those stay on the device (slice 54, C-07). +// `with` goes into the trusted copy in the same write (the pages' `baseLangs`), so nothing +// reads the bases half changed. +async function saveUi(ui, { with: also = {} } = {}) { + await area.set({ ...also, ui }); + if (ui?.baseLangsConfirmed !== true) return; + const synced = { ...ui }; + delete synced.baseLangsDetected; + await Promise.resolve().then(() => ext.storage.sync.set({ ui: synced })).catch(() => {}); } // Reactions to a change of the trusted copy, whoever in this file made it. @@ -243,8 +263,8 @@ function saveSettings(m) { if (patch.ui?.baseLangs && !patch.baseLangs) patch.baseLangs = patch.ui.baseLangs; const { ui } = patch; delete patch.ui; - await area.set(patch); - if (ui) await saveUi(ui); + if (ui) await saveUi(ui, { with: patch }); + else await area.set(patch); if (patch.baseLangs) { projector.schedule(); mirrorBaseRules().catch(() => {}); @@ -268,20 +288,22 @@ function saveSettings(m) { const routeKey = (route) => `route:${route}`; const providerOf = (id) => globalThis.KOTIKO_SPEC.providers.providers.find((p) => p.id === id) ?? null; -// The address a request on `route` would use, in the form routes are stored in. +// The address a request on `route` would use, in the form routes are stored in: plain-http +// localhost as 127.0.0.1 (slice 54, B-01), like every request. function routeUrl(route, url) { if (route === "server") { const n = normalizeServerUrl(url ?? ""); return n.ok ? n.url : null; } - return url ? String(url).trim().replace(/\/+$/, "") || null : null; + return url ? globalThis.ServerUrl.pinLoopback(String(url).trim().replace(/\/+$/, "")) || null : null; } // Read from the store every time (one small read), so a wiped store (slice 12's "delete -// everything") is never outlived by a remembered address. +// everything") is never outlived by a remembered address. One bound before addresses were +// pinned to 127.0.0.1 compares in today's form. async function trustedUrl(route) { const bound = await (await getStore()).meta.get(routeKey(route)); - if (bound) return bound; + if (bound) return bound.startsWith(RAW) ? bound : routeUrl(route, bound) ?? bound; // Nothing named yet (a new install, or after "delete everything"): the built-in addresses. if (route.startsWith("lookup:")) return routeUrl(route, providerOf(route.slice("lookup:".length))?.baseUrl ?? null); return route === "server" ? routeUrl(route, Local.DEFAULT_SERVER) : null; @@ -382,9 +404,10 @@ const home = async () => (await settings()).wordsHome ?? "local"; // The learner's base languages: slice 50's list (`ui`, the trusted copy of what // storage.sync holds) when it exists, else the pages' copy, else the browser's language. -async function currentBases() { - await ready().catch(() => {}); - const { ui, baseLangs } = await area.get({ ui: null, baseLangs: null }); +// `get` reads the trusted copy directly, inside one of its updates (the projector's). +async function currentBases(get = null) { + if (!get) await ready().catch(() => {}); + const { ui, baseLangs } = await (get ?? area.get)({ ui: null, baseLangs: null }); if (Array.isArray(ui?.baseLangs) && ui.baseLangs.length) return ui.baseLangs.slice(0, 4); if (Array.isArray(baseLangs) && baseLangs.length) return baseLangs.slice(0, 4); return Local.detectBases(uiLanguage()); @@ -454,9 +477,95 @@ async function connection() { // Only the address a Kotiko page named gets the token (slice 28 §7). const token = await secretFor("server", n.url); if (!token) throw addressChanged("server"); + // And only once the server there has shown it holds the same token (below). + await proveServer(n.url, token); return { base: n.url, token }; } +// Slice 54, B-01 (defence in depth). Before the token goes to an address, the server there +// proves it holds the same token without either side sending it: Kotiko sends a fresh random +// nonce to `POST /api/v1/proof` (no token), the server answers base64url(HMAC-SHA256(token, +// "kotiko-proof-v1:" + nonce)), and Kotiko checks it with WebCrypto (`subtle.verify`, which +// compares in constant time). Another program listening at the address (on [::1], or on +// 127.0.0.1 while Kotiko's server is stopped) never gets the token. A proof holds for this +// worker's life and that address and token, and is asked for again after the server can't be +// reached (it may have stopped, and something else started listening). +// - 200 with a proof that doesn't match: that server has another token (the learner's is +// wrong, or it isn't theirs): server_key_rejected, reason "wrong_proof". +// - no route (401, 404 or 405: a server from before this check), or an answer with no +// proof: not_kotiko_server, reason "no_proof". An older server has to be updated: +// anything could answer 404, so a missing route can't be trusted either. +const PROOF_CONTEXT = "kotiko-proof-v1:"; +const PROOF_TIMEOUT_MS = 10_000; +let proven = null; // {base, token} proven in this worker's life +let proving = null; // {base, token, promise} under way +const utf8 = (text) => new TextEncoder().encode(text); +const b64url = (bytes) => btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +const B64URL = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; +// Already checked to be 43 base64url characters (32 bytes). +function fromB64url(text) { + const out = []; + let bits = 0; + let n = 0; + for (const c of text) { + bits = ((bits << 6) | B64URL.indexOf(c)) & 0xffff; + n += 6; + if (n >= 8) { + n -= 8; + out.push((bits >> n) & 0xff); + } + } + return Uint8Array.from(out); +} + +function proveServer(base, token) { + if (proven?.base === base && proven.token === token) return Promise.resolve(); + if (proving?.base === base && proving.token === token) return proving.promise; + const promise = askProof(base, token) + .then(() => { + proven = { base, token }; + }) + .finally(() => { + if (proving?.promise === promise) proving = null; + }); + proving = { base, token, promise }; + return promise; +} + +async function askProof(base, token) { + const nonce = b64url(crypto.getRandomValues(new Uint8Array(32))); + let res; + try { + res = await fetch(`${base}/api/v1/proof`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ nonce }), + cache: "no-store", + credentials: "omit", + signal: AbortSignal.timeout(PROOF_TIMEOUT_MS), + }); + } catch (e) { + if (e?.name === "AbortError") throw e; + throw codedError("server_unreachable", `Can't reach ${base}. Is the server running?`, { reason: e?.name === "TimeoutError" ? "timeout" : "network" }); + } + const noProof = (status) => codedError("not_kotiko_server", `${base} didn't prove it holds this token, so it wasn't sent.`, { reason: "no_proof", status }); + if (res.status === 401 || res.status === 404 || res.status === 405) throw noProof(res.status); + const body = await res.json().catch(() => null); + if (!res.ok) { + const e = body?.error; + const code = e && typeof e === "object" && typeof e.code === "string" ? e.code : fromStatus(res.status); + // A server error's own words (a 0.2-style string) go in the details, as for a sync. + const said = typeof e === "string" ? { error: e.slice(0, 200) } : {}; + throw codedError(code, `The server answered ${res.status} to the proof.`, { ...(e?.details && typeof e.details === "object" ? e.details : {}), ...said, status: res.status }); + } + const proof = typeof body?.proof === "string" && /^[A-Za-z0-9_-]{43}$/.test(body.proof) ? body.proof : null; + if (!proof) throw noProof(res.status); + const key = await crypto.subtle.importKey("raw", utf8(token), { name: "HMAC", hash: "SHA-256" }, false, ["verify"]); + if (!(await crypto.subtle.verify("HMAC", key, fromB64url(proof), utf8(PROOF_CONTEXT + nonce)))) { + throw codedError("server_key_rejected", `The server at ${base} has another token.`, { reason: "wrong_proof", status: res.status }); + } +} + // Calls the server and returns the raw response. Network failures become coded errors. async function request(conn, path, init = {}) { try { @@ -466,6 +575,8 @@ async function request(conn, path, init = {}) { cache: "no-store", }); } catch (e) { + // The server may have stopped, and another program may listen there next: prove again. + if (e?.name !== "AbortError") proven = null; if (e?.name === "TimeoutError") { throw codedError("server_unreachable", `${conn.base} took too long to answer.`, { reason: "timeout" }); } @@ -630,7 +741,7 @@ async function requestSync(opts) { const projector = globalThis.KotikoProjection.createProjector({ list: async () => (await getStore()).list(), storage: area, - bases: currentBases, + bases: (get) => currentBases(get), enabled: async () => !wiped && (await home()) === "local", onError: (e) => console.warn("Kotiko couldn't update the page word list:", e?.message ?? e), }); @@ -1130,6 +1241,10 @@ async function wipeEverything(m) { secretCache.clear(); client.reset(); await globalThis.KotikoStore.wipe({ indexedDB: globalThis.indexedDB }); + // An empty database with one mark, so the next store is set up afresh (A-06, above). + const born = await globalThis.KotikoStore.open({ indexedDB: globalThis.indexedDB, now }); + await born.meta.set(BORN_FROM_WIPE, wipedAt); + born.close(); await ext.storage.local.set({ words: [] }); await ext.storage.local.clear(); await Promise.resolve(ext.storage.session?.clear?.()).catch(() => {}); @@ -1255,6 +1370,18 @@ async function discardPlanted() { ensureSeedSalt().catch(() => {}); } +// Slice 54, A-06: a 0.2 `token` and `serverUrl` (and whatever else storage.local held) count +// only on an update from 0.1 or 0.2, the versions that kept them there. A store set up from +// storage.local at any other start (an update from a later version whose database was lost, +// a browser update) keeps none of it. `ready()` first, so this start's set-up has run. +const fromBeforeStore = (version) => /^0\.[0-2]\./.test(String(version ?? "")); +async function keepLegacyOnlyFrom(details) { + await ready(); + if (!adoptedLegacy || (details?.reason === "update" && fromBeforeStore(details.previousVersion))) return; + adoptedLegacy = false; + await discardPlanted(); +} + async function firstInstall() { await ready().catch(() => {}); await discardPlanted().catch((e) => console.warn("Kotiko install:", e?.message ?? e)); @@ -1264,11 +1391,10 @@ async function firstInstall() { const { ui } = await area.get({ ui: {} }); const keep = ui?.baseLangsConfirmed === true && Array.isArray(ui.baseLangs) && ui.baseLangs.length; const next = keep ? ui.baseLangs.slice(0, 4) : bases; - await saveUi({ uiLang: "auto", ...ui, baseLangs: next, baseLangsDetected: bases, baseLangsConfirmed: !!keep }); const { onboarding } = await area.get({ onboarding: null }); const patch = { baseLangs: next }; if (!onboarding) patch.onboarding = { completedAt: null, skipped: false, version: 2 }; - await area.set(patch); + await saveUi({ uiLang: "auto", ...ui, baseLangs: next, baseLangsDetected: bases, baseLangsConfirmed: !!keep }, { with: patch }); projector.schedule(); await openWelcome(); } @@ -1295,8 +1421,7 @@ async function upgradeBases() { // Over four: drop detected languages no word uses, from the end. for (let i = next.length - 1; next.length > 4 && i >= 0; i--) if (!present.includes(next[i])) next.splice(i, 1); const bases = next.slice(0, 4); - await saveUi({ uiLang: "auto", ...ui, baseLangs: bases, baseLangsDetected: detected, baseLangsConfirmed: false }); - await area.set({ baseLangs: bases }); + await saveUi({ uiLang: "auto", ...ui, baseLangs: bases, baseLangsDetected: detected, baseLangsConfirmed: false }, { with: { baseLangs: bases } }); projector.schedule(); } @@ -1359,15 +1484,40 @@ async function injectOpenTabs() { return n; } +// Slice 54, B-01: an address saved as plain-http localhost by an earlier version, and the +// route it is bound to, become 127.0.0.1, so the settings show where requests really go. +// Requests already went there (routes compare pinned addresses); this only rewrites them. +async function pinLoopbackAddresses() { + await ready(); + await underRoutes(async () => { + const store = await getStore(); + const s = await Local.readSettings(area); + const patch = {}; + const pinned = routeUrl("server", s.server.url); + if (pinned && pinned !== s.server.url && /^http:\/\/localhost\b/i.test(s.server.url.trim())) patch.server = { ...s.server, url: pinned }; + const lookupUrl = s.lookup.baseUrl ? globalThis.ServerUrl.pinLoopback(s.lookup.baseUrl.trim()) : null; + if (lookupUrl && lookupUrl !== s.lookup.baseUrl.trim()) patch.lookup = { ...s.lookup, baseUrl: lookupUrl }; + for (const { key, value } of await store.meta.entries("route:")) { + if (typeof value !== "string" || value.startsWith(RAW) || key === CHOSEN) continue; + const route = key.slice("route:".length); + const bound = routeUrl(route, value); + if (bound && bound !== value) await store.meta.set(key, bound); + } + if (Object.keys(patch).length) await area.set(patch); + }); +} + function onInstalled(details) { if (details?.reason === "update") { Promise.resolve(ext.alarms.clear(OLD_ALARM)).catch(() => {}); migrateHiddenLangs().catch(() => {}); + pinLoopbackAddresses().catch(() => {}); // The bases after the first-run state: writing them projects the words again, and the // first-run check reads the old list first. upgradeOnboarding().catch(() => {}).then(upgradeBases).catch(() => {}); } if (details?.reason === "install") firstInstall().catch((e) => console.warn("Kotiko couldn't open the welcome tab:", e?.message ?? e)); + else keepLegacyOnlyFrom(details).catch((e) => console.warn("Kotiko update:", e?.message ?? e)); if (details?.reason === "install" || details?.reason === "update") injectOpenTabs().catch(() => {}); ensureAlarm(); mirrorBaseRules({ force: true }).catch(() => {}); @@ -1468,10 +1618,13 @@ ext.runtime.onMessage.addListener( docsOrigin: globalThis.KotikoPKCE.DOCS_ORIGIN, handlers: { ...wordRoutes, + // Only Kotiko's own pages skip the 5 s wait (`force`): a content script's sync waits + // like a page load's, whatever it asks (slice 54, A-08). sync: { from: ["page", "content"], - async run(msg) { - await requestSync(msg.force ? { reason: "manual", force: true } : { reason: "page" }); + async run(msg, sender) { + const fromPage = globalThis.MessageRouter.senderKinds(sender, ext.runtime).has("page"); + await requestSync(msg.force === true && fromPage ? { reason: "manual", force: true } : { reason: "page" }); return { ok: true }; }, }, @@ -1664,9 +1817,13 @@ ext.runtime.onMessage.addListener( async run(m) { await ready(); if (typeof m.url === "string") { + // Saved as it will be used (localhost as 127.0.0.1, slice 54 B-01); an address + // that isn't one is kept as typed, so the settings can show it with its error. + const typed = m.url.trim() || Local.DEFAULT_SERVER; + const n = normalizeServerUrl(typed); await underRoutes(async () => { - await bindRoute("server", m.url.trim() || Local.DEFAULT_SERVER); - await area.set({ server: { url: m.url.trim() || Local.DEFAULT_SERVER } }); + await bindRoute("server", typed); + await area.set({ server: { url: n.ok ? n.url : typed } }); }); } if (typeof m.token === "string") await setSecret("server", m.token.trim() || null); @@ -1726,24 +1883,32 @@ ext.runtime.onMessage.addListener( from: ["page"], async run() { await ready(); - const { verifier, challenge } = await globalThis.KotikoPKCE.pair(); - await (await getStore()).secrets.set("pkce:pending", JSON.stringify({ verifier, expires: now() + globalThis.KotikoPKCE.PENDING_MS })); - const url = globalThis.KotikoPKCE.authUrl({ challenge }); + const { verifier, challenge, state } = await globalThis.KotikoPKCE.pair(); + await (await getStore()).secrets.set("pkce:pending", JSON.stringify({ verifier, state, expires: now() + globalThis.KotikoPKCE.PENDING_MS })); + const url = globalThis.KotikoPKCE.authUrl({ challenge, state }); await Promise.resolve(ext.tabs?.create?.({ url })).catch(() => {}); return { ok: true, url }; }, }, + // A code counts only with the state of the sign-in waiting here: any page can open + // the callback with a code of its own, and that must neither reach OpenRouter nor use + // up the learner's sign-in. The verifier is kept until a code works or it expires + // (slice 54, A-05). "oauth.code": { from: ["docs"], - check: (m) => (typeof m.code === "string" && m.code.length > 0 && m.code.length <= 512 ? null : "code must be a string"), + check: (m) => (typeof m.code !== "string" || m.code.length === 0 || m.code.length > 512 ? "code must be a string" : m.state !== undefined && (typeof m.state !== "string" || m.state.length > 128) ? "state must be a string" : null), async run(m, sender) { if (!globalThis.KotikoPKCE.isCallback(sender?.url)) throw codedError("forbidden", "Not the callback page."); const store = await getStore(); const raw = await store.secrets.get("pkce:pending"); - await store.secrets.remove("pkce:pending"); const pending = raw ? JSON.parse(raw) : null; - if (!pending || pending.expires < now()) throw codedError("key_rejected", "That sign-in has expired.", { reason: "expired", provider: "openrouter" }); + if (!pending || pending.expires < now()) { + if (raw) await store.secrets.remove("pkce:pending"); + throw codedError("key_rejected", "That sign-in has expired.", { reason: "expired", provider: "openrouter" }); + } + if (!globalThis.KotikoPKCE.sameState(m.state, pending.state)) throw codedError("key_rejected", "That code isn't from the sign-in Kotiko started.", { reason: "not_this_sign_in", provider: "openrouter" }); const key = await globalThis.KotikoPKCE.exchange({ fetch: (...a) => fetch(...a), code: m.code, verifier: pending.verifier }); + await store.secrets.remove("pkce:pending"); await setSecret("provider:openrouter", key); await underRoutes(async () => { await bindRoute("lookup:openrouter", globalThis.KotikoLLMClient.endpoint({ provider: "openrouter" }).baseUrl); @@ -1986,7 +2151,9 @@ ext.tabs?.onUpdated?.addListener((_id, change, tab) => { }); updateAllBadges(); +// test-only: start (scripts/build-extension.mjs leaves this block out of the store zips) // For tests: the parts a test drives directly. // `seed` writes the trusted copy, as Kotiko's own code does (tests can't write storage.local // and have it stay, any more than a content script can). globalThis.__kotiko = { ensureSeedSalt, adoptSync, ready, getStore, queue, refresh, projector, client, settings, currentBases, mirrorBaseRules, injectOpenTabs, toServer, toLocal, openWelcome, claimMilestone, onInstalled, area, seed: async (items) => (await ready(), area.set(items)) }; +// test-only: end diff --git a/extension/content/connect.js b/extension/content/connect.js index 91dbbb0..38e709d 100644 --- a/extension/content/connect.js +++ b/extension/content/connect.js @@ -2,11 +2,12 @@ // SPDX-License-Identifier: Apache-2.0 // "Connect OpenRouter" (slice 11 §4): OpenRouter sends the browser back to the docs site's -// return page, https://kotiko.org/connect/?code=… (slice 44). This content script runs only -// there (its own manifest entry), reads the code from the address and hands it to Kotiko's -// background as {type: "oauth.code", code}. The background checks the sender is that page -// (KotikoPKCE.isCallback) and trades the code, with the verifier it kept, for a key. The -// code goes nowhere else, and the page itself has no script. +// return page, https://kotiko.org/connect/?code=…&state=… (slice 44). This content script +// runs only there (its own manifest entry), reads the code and the sign-in's state from the +// address and hands them to Kotiko's background as {type: "oauth.code", code, state}. The +// background checks the sender is that page (KotikoPKCE.isCallback) and the state is the +// sign-in it started, then trades the code, with the verifier it kept, for a key. The code +// goes nowhere else, and the page itself has no script. // // The page's status line (#kotiko-connect-status) then says how it went, in Kotiko's // interface language. The code is taken out of the address afterwards, so reloading the @@ -15,6 +16,7 @@ const ext = globalThis.browser ?? globalThis.chrome; const here = new URL(location.href); const code = here.searchParams.get("code"); + const state = here.searchParams.get("state"); if (here.origin !== "https://kotiko.org" || here.pathname !== "/connect/" || !code) return; const status = document.getElementById("kotiko-connect-status"); @@ -28,7 +30,7 @@ }; say("connect_working"); - Promise.resolve(ext.runtime.sendMessage({ type: "oauth.code", code })) + Promise.resolve(ext.runtime.sendMessage({ type: "oauth.code", code, ...(state ? { state } : {}) })) .then((res) => say(outcome(res)), () => say("connect_failed")) .finally(() => history.replaceState(history.state, "", here.pathname)); })(); diff --git a/extension/dashboard.html b/extension/dashboard.html index 20ef7a4..1f10b02 100644 --- a/extension/dashboard.html +++ b/extension/dashboard.html @@ -173,8 +173,9 @@