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

## Unreleased

- Security: a web page could read your word list. It could hide a long list of words on
the page, where you'd never see them, and read back which ones Kotiko swapped. Kotiko
now swaps only words you can see: text a page hides isn't touched, and text below the
screen is swapped as you scroll to it, which also makes very long pages faster. One
visit to a page swaps at most 500 different words, each in at most 3 of your languages,
so no page can read your whole list at once. The privacy policy (version 3) says what
sites can and can't see.

- Security: any web page could switch Kotiko off on itself, and could tell that you use
Kotiko even on sites you paused, on sites Kotiko leaves alone such as banks, or with
Kotiko turned off. Where Kotiko doesn't swap, it now adds nothing to the page.

- Security: a web page could open a word's card with a fake click and search the card's
text. The card now opens only when you point at, click, tap or press a key on a word.

- Security: the popup's "This page is in …" line showed the page's own language label as
written, so a page could put any sentence it liked in Kotiko's popup. Kotiko now shows
only a language name your browser knows, and nothing otherwise.

- 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
Expand Down
24 changes: 19 additions & 5 deletions docs/privacy/en.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Kotiko privacy policy

Version 2 · 6 October 2026
Version 3 · 6 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 @@ -103,10 +103,21 @@ Any request to another computer also tells it your IP address, as every web requ
## What websites can see

The words Kotiko swaps in are part of the page, so the website's own scripts, including
any session-recording tools it uses, can see them, and can tell that you use Kotiko and
which language each swapped word is in. They can't see the original words, their
meanings or your word list. To stop this on a site, pause Kotiko there from its toolbar
button, or turn it off.
any session-recording tools it uses, can see them. From them a site can tell that you use
Kotiko and, for each word it swapped, which of the site's own words you have a word for,
that word, and its language.

Kotiko limits this to what it shows you. It swaps only text that is on your screen or
near it, never text the page hides, and in one visit to a page it swaps at most 500
different words, each in at most 3 of your languages. So a site can't read your whole
word list at once, but one you visit often, or that keeps reloading itself, can learn
more of it over time. Sites can't open a word's card. While you have one open, a site
that searches its page for a word it guesses could find the card's text.

On a site you paused, on sites Kotiko leaves alone (such as banks), and while Kotiko is
turned off, it adds nothing to the page and changes nothing on it, so the site has nothing
of Kotiko's to see. To stop this on a site, pause Kotiko there from its toolbar button, or
turn it off.

## Your own server (optional)

Expand Down Expand Up @@ -170,6 +181,9 @@ everything in Kotiko's Settings.

## Changelog

- **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.
- **Version 2, 6 October 2026.** Added "Connecting OpenRouter": the one-time sign-in code
passes through kotiko.org.
- **Version 1, 5 October 2026.** First version.
26 changes: 20 additions & 6 deletions docs/privacy/inventory.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,26 @@ dictionaries, the model list and words are data, checked by the word spec before
- The swapped words themselves: they are in the page, so the page's own scripts, and any
session-recording tool it runs, can read them, and can tell from the `<kotiko-w>`
element and its `lang` attribute that Kotiko is installed and which language each word
is in.
- Not the original words, not the meanings, not word ids: the swaps carry only `lang`,
`dir`, `translate="no"` and `class="notranslate"`; the word card is a
`<kotiko-popover>` element with a closed shadow root (slice 19). Checked by
`test/e2e/popover.spec.mjs` and `test/e2e/privacy.spec.mjs`.
- Pausing Kotiko on a site, or turning it off, stops the swaps there.
is in. The page knows its own text, so each swap also tells it which of its words the
learner has a word for (security review A-01).
- How much: only text the learner can see is swapped (rendered, on screen or within a
screen of it, not clipped away; `content/engine.js` `sight()`), so text a page hides
reveals nothing. One page view swaps at most `max_page_concepts` (500) distinct
concepts, each in at most `max_page_langs_per_concept` (3) languages
(`spec/rules.json`, `content.js` `reveal()`). The cap is per page view: a page that
reloads itself, or one the learner visits often, can learn more over time. Checked by
`test/security/page.spec.mjs` (A-01) and `test/dom/content.test.mjs`.
- Not the swaps' details: the swaps carry only `lang`, `dir`, `translate="no"` and
`class="notranslate"`; the word card is a `<kotiko-popover>` element with a closed
shadow root (slice 19) that only the learner's own input opens (A-04). While the
learner has a card open, `window.find()` on the page can still match its text. Checked
by `test/e2e/popover.spec.mjs`, `test/e2e/privacy.spec.mjs` and
`test/security/page.spec.mjs`.
- The swap stylesheet (`ui/swap-style.js`) is added with the first swap and removed when
Kotiko is paused or off there.
- Pausing Kotiko on a site, a sensitive site Kotiko leaves alone, or turning Kotiko off:
Kotiko adds nothing to the page and changes nothing on it (no swaps, no stylesheet, no
events), so the page can't tell Kotiko is installed (A-03, C-04).

## 3. What Kotiko's own page scripts (content scripts) can reach

Expand Down
14 changes: 12 additions & 2 deletions docs/security/assurance-case.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ its argument and evidence.
| Attacker | Can reach | Main defences |
|---|---|---|
| A malicious web page | Its own DOM, which the content script reads and changes; HTTP requests from the browser to any address, including the server | Text-only DOM; no CORS headers, so it can't read server answers or send the `Authorization` header; Host check against DNS rebinding; the content script can't add or delete words |
| A script injected into a page | The same as the page; it can see swapped words | Same; nothing secret is put in the page |
| A script injected into a page | The same as the page; it can see swapped words | Same; nothing secret is put in the page; only text the learner sees is swapped, at most 500 concepts a page view; page-made events can't open the word card or switch Kotiko off |
| 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 |
Expand Down Expand Up @@ -180,7 +180,17 @@ These match "What you can't expect" in the [requirements](requirements.md).
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.
- **Pages can see swapped words** in their own DOM, and, since they know their own text,
which of their words the learner has a word for (security review A-01). Kotiko swaps
only text the learner can see (rendered, on screen or within a screen of it, not
clipped away) and at most 500 distinct concepts, each in at most 3 languages, per page
view (`spec/rules.json` `max_page_*`). What's left: a page that shows text the learner
can't make out (tiny, the color of the background, under something else, or shown for
one frame) still gets swaps within that cap; the cap is per page view, so a page that
reloads itself or a site visited often can learn more of a long list over time; and
while the learner has a word card open, `window.find()` can match its text. Where
Kotiko is paused, off or stepping back from a sensitive site it adds nothing to the page
(A-03, C-04); where it swaps, the swaps and their stylesheet show it's installed.
- **A subverted content script** (code running in Kotiko's content-script world) can't
change settings, the word list or the add queue (SCR-448), but it can still: add words
to the never-swap list one at a time (the word card's action lives in the page; the list
Expand Down
7 changes: 4 additions & 3 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -157,8 +157,9 @@ export default [
},
{ rules: STRICTER },
{
// extension/spec/spec.js is generated (spec/tools/sync-extension.mjs) as compact JSON.
ignores: ["extension/spec/spec.js"],
// extension/spec/spec.js and page.js are generated (spec/tools/sync-extension.mjs) as
// compact JSON.
ignores: ["extension/spec/spec.js", "extension/spec/page.js"],
plugins: { "@stylistic": stylistic },
rules: LAYOUT,
},
Expand Down Expand Up @@ -202,7 +203,7 @@ export default [
// destructure their first argument, even when it's empty. Such a callback can't see
// the test's variables, so it takes them as an argument under the same name
// (`page.evaluate((id) => ..., id)`): shadowing is the idiom there, not a mistake.
files: ["test/e2e/**/*.mjs", "test/visual/**/*.mjs", "test/helpers/axe.mjs", "test/helpers/a11y-checks.mjs", "site/tests/**/*.spec.mjs"],
files: ["test/e2e/**/*.mjs", "test/security/**/*.mjs", "test/visual/**/*.mjs", "test/helpers/axe.mjs", "test/helpers/a11y-checks.mjs", "site/tests/**/*.spec.mjs"],
languageOptions: { globals: { ...globals.node, ...globals.browser, ...globals.webextensions } },
rules: {
"no-empty-pattern": ["error", { allowObjectPatternsAsParameters: true }],
Expand Down
35 changes: 21 additions & 14 deletions extension/background.js
Original file line number Diff line number Diff line change
Expand Up @@ -1377,8 +1377,11 @@ async function discardPlanted() {
const fromBeforeStore = (version) => /^0\.[0-2]\./.test(String(version ?? ""));
async function keepLegacyOnlyFrom(details) {
await ready();
if (!adoptedLegacy || (details?.reason === "update" && fromBeforeStore(details.previousVersion))) return;
if (!adoptedLegacy) return;
// Decided once, by the first event after the set-up: a browser update that follows an
// update from 0.2 in the same worker must not drop the words that update kept.
adoptedLegacy = false;
if (details?.reason === "update" && fromBeforeStore(details.previousVersion)) return;
await discardPlanted();
}

Expand Down Expand Up @@ -1464,17 +1467,17 @@ function claimMilestone(key) {
}

// Tabs open before an install or update get Kotiko without a reload (slice 15): the same
// files the manifest injects, top frame only, as it does. On an update the new copy tells
// the old one to stand down (the handoff in content.js). A tab that can't take scripts (a
// store page, a browser page) is skipped.
// scripts the manifest injects, top frame only, as it does. On an update the new copy finds
// the old one and makes it stand down (content.js), with nothing a page can see; the swap
// style comes with the first swap (ui/swap-style.js). A tab that can't take scripts (a store
// page, a browser page) is skipped.
async function injectOpenTabs() {
const cs = ext.runtime.getManifest?.()?.content_scripts?.[0];
if (!ext.scripting?.executeScript || !cs) return 0;
const tabs = await Promise.resolve(ext.tabs.query({ url: ["http://*/*", "https://*/*"] })).catch(() => []);
let n = 0;
for (const tab of tabs) {
try {
if (cs.css?.length) await ext.scripting.insertCSS({ target: { tabId: tab.id }, files: cs.css });
await ext.scripting.executeScript({ target: { tabId: tab.id }, files: cs.js });
n++;
} catch {
Expand Down Expand Up @@ -1508,16 +1511,20 @@ async function pinLoopbackAddresses() {
}

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));
else {
// What storage.local held is dropped first, so no upgrade step writes something the
// drop then erases (it once raced the first-run state, and lost it).
const kept = keepLegacyOnlyFrom(details).catch((e) => console.warn("Kotiko update:", e?.message ?? e));
if (details?.reason === "update") {
Promise.resolve(ext.alarms.clear(OLD_ALARM)).catch(() => {});
kept.then(migrateHiddenLangs).catch(() => {});
kept.then(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.
kept.then(upgradeOnboarding).catch(() => {}).then(upgradeBases).catch(() => {});
}
}
if (details?.reason === "install" || details?.reason === "update") injectOpenTabs().catch(() => {});
ensureAlarm();
mirrorBaseRules({ force: true }).catch(() => {});
Expand Down
43 changes: 0 additions & 43 deletions extension/content.css

This file was deleted.

Loading