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
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: a web page could restyle a swapped word (it sits in the page) so that it
covered the page invisibly; then your pointer resting anywhere, or your click on one of
the page's own buttons, opened the word's card, and the page could search the card's
text for guesses. The card now opens only on a swapped word you can see, and from the
mouse or a tap only when you point at the word itself. Enter on a focused word still
opens it. The privacy policy (version 5) says so.

- Security: Kotiko swapped words in text a page had hidden with a see-through filter, a
clipping shape or a mask, so the page could read those swaps and learn your words. Text
like that, and text faded to under 10 % opacity, is no longer swapped. Kotiko also
leaves text under any clipping shape or mask alone, even where you can see it, since it
can't tell how much of it shows.

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

Version 4 · 7 October 2026
Version 5 · 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 @@ -110,11 +110,20 @@ Kotiko and, for each word it swapped, which of the site's own words you have a w
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.
near it, never text the page hides: not text it doesn't display, makes see-through or
nearly so (also with a filter), or puts under a clipping shape or a mask (Kotiko leaves
text under those alone even where you can see it). In one visit to a page it swaps at
most 500 different words, each in at most 3 of your languages. Text you can't make out
still counts as shown: very small text, text in the page's background colour, blurred
text, or text under something else. 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.

Only you open a word's card: by pointing at, clicking, tapping or pressing Enter on a
swapped word you can see, where it's drawn. A site can't open one by itself, or by
stretching an invisible word over its page; one that moves a word under your pointer and
disguises it (in the page's background colour, or under something else) could still get
its card opened while your pointer rests there. While you have a card 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
Expand Down Expand Up @@ -183,6 +192,9 @@ everything in Kotiko's Settings.

## Changelog

- **Version 5, 7 October 2026.** "What websites can see" now says which hidden text
Kotiko never swaps (also text made see-through with a filter, clipped or masked), what
text still counts as shown, and when a word's card opens.
- **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
Expand Down
15 changes: 10 additions & 5 deletions docs/privacy/inventory.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,17 +47,22 @@ dictionaries, the model list and words are data, checked by the word spec before
element and its `lang` attribute that Kotiko is installed and which language each word
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
- How much: only text the learner can see is swapped (rendered, at least 10 % opaque
counting `filter: opacity()`, under no `clip-path`, mask or `url()` filter, on screen or
within a screen of it, not clipped away; `content/engine.js` `sight()`, security review
E-04), so text a page hides reveals nothing; text the learner can't make out (tiny, the
background's color, blurred, covered) still counts as shown. 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
shadow root (slice 19) that only the learner's own input opens (A-04), and only on a
swap they can see, from a pointer only on its drawn text (E-05; `content/popover.js`
`seen()`). A page that keeps a disguised swap under the pointer (the background's
color, covered) can still have it opened. 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
Expand Down
21 changes: 15 additions & 6 deletions docs/security/assurance-case.md
Original file line number Diff line number Diff line change
Expand Up @@ -217,13 +217,22 @@ These match "What you can't expect" in the [requirements](requirements.md).
- **The model provider sees every lookup**, under its own terms.
- **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
only text the learner can see (rendered; at least 10 % opaque counting every
ancestor's `opacity` and `filter: opacity()`; under no `clip-path`, mask or `url()`
filter, E-04; on screen or within a screen of it; not clipped away by an ancestor's
overflow) 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
can't make out (tiny, the color of the background, blurred or otherwise filtered
without `opacity()`, 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. The word card opens only from
the learner's own input on a swap drawn at least 10 % opaque, under no clip-path or
mask, whose box isn't stretched far past its text, and from a pointer only within 4 px
of that text (E-05), so a page can't stretch an invisible swap under the learner's
pointer or click; one that keeps a swap under the pointer disguised another way (the
background's color, under an element with `pointer-events: none`) can still have its
card opened while the pointer rests there, and while a card is 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
Expand Down
64 changes: 58 additions & 6 deletions extension/content/engine.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,18 @@
const HYDRATION_MARKERS = "#__next, [data-reactroot], #__nuxt, [data-sveltekit-hydrated], [ng-version], [data-server-rendered]";
// Only text the learner can see is swapped (security review A-01): a page reads its own
// DOM, so a swap in text nobody sees would tell it the learner's words for nothing. Text
// is swapped when it's rendered (no display:none, visibility, zero opacity or
// content-visibility) and its block is on screen or within a screen of it, at least 2 px
// each way after every ancestor's clipping; the rest waits until it is (lazily, which is
// also what keeps very long pages fast).
// is swapped when it's rendered (no display:none, visibility, or content-visibility), at
// least FAINT opaque (every ancestor's opacity and filter: opacity() multiplied), under
// no clip-path, mask or url() filter (veil(), E-04), and its block is on screen or within
// a screen of it, at least 2 px each way after every ancestor's overflow clipping; the
// rest waits until it is (lazily, which is also what keeps very long pages fast).
const SHOWN = { contentVisibilityAuto: true, opacityProperty: true, visibilityProperty: true };
// Security review E-04: checkVisibility() misses text a filter, clip-path or mask hides
// (a page got all 300 of 300 words that way). A clip-path's shape or a mask's image can't
// be measured cheaply, a mask image not at all, so text under any clip-path or mask isn't
// swapped, even where the shape leaves it in view (decorative angled sections, fade-out
// edges); a filter with url() likewise. Faint text (under 10 % opacity) can't be read.
const FAINT = 0.1;
const MIN_PX = 2;
const NEAR = "100% 0px";
// Whether a block is near comes from an IntersectionObserver, which costs no layout. A
Expand Down Expand Up @@ -200,6 +207,9 @@
let io = null;
let maskCheck = 0;
let syncSpent = 0;
// element -> how opaque its content is drawn (veil()), for one slice: no page script runs
// within a slice, so no style changes either.
const veils = new Map();
// Roots the site just added or changed (handle()), and whether the walk is in one.
const fromSite = new WeakSet();
let siteChange = false;
Expand Down Expand Up @@ -282,11 +292,52 @@
return ok;
}

// The parent an element is drawn in: its slot, its parent, or its shadow root's host.
const drawnParent = (e) => e.assignedSlot ?? e.parentElement ?? e.parentNode?.host ?? null;

// One element's own share of how visible its content is (popover.js `ownOpacity()` is the
// same rule for opening the word card, E-05).
function ownOpacity(st) {
if (st.display === "contents") return 1;
const set = (v) => !!v && v !== "none";
if (set(st.clipPath) || set(st.maskImage) || set(st.webkitMaskBoxImage)) return 0;
const own = parseFloat(st.opacity);
let o = own >= 0 ? own : 1;
if (set(st.filter)) {
if (/url\(/i.test(st.filter)) return 0;
for (const m of st.filter.matchAll(/opacity\(\s*([\d.]+)(%?)\s*\)/gi)) o *= Math.min(1, parseFloat(m[1]) / (m[2] ? 100 : 1));
}
return o;
}

// How opaque el's content is drawn: its own share times every ancestor's, 0 under a
// clip-path or mask. Each element's style is read once a slice, so text nodes sharing
// ancestors cost one read of each.
function veil(el) {
const chain = [];
let o = 1;
for (let e = el; e; e = drawnParent(e)) {
const known = veils.get(e);
if (known !== undefined) {
o = known;
break;
}
chain.push(e);
}
for (let i = chain.length - 1; i >= 0; i--) {
if (o > 0) o *= ownOpacity(win.getComputedStyle(chain[i]));
veils.set(chain[i], o);
}
return o;
}

const styleHides = (el) => !el.checkVisibility(SHOWN) || veil(el) < FAINT;

// "shown", or why not: "hidden" (a style hides it) or "away" (not near, too small, or
// not known yet).
function sight(el) {
if (!sees) return "shown";
if (!el.checkVisibility(SHOWN)) return "hidden";
if (styleHides(el)) return "hidden";
const b = blockOf(el);
const v = near.get(b);
// Text the site just added is measured even in a block that was near: the page may
Expand All @@ -307,7 +358,7 @@
function wait(el, why) {
if (why === "hidden") {
let top = el;
for (let a = el.parentElement; a && a !== doc.body && a !== doc.documentElement && !a.checkVisibility(SHOWN); a = a.parentElement) top = a;
for (let a = el.parentElement; a && a !== doc.body && a !== doc.documentElement && styleHides(a); a = a.parentElement) top = a;
// The observer already said it's near: a style still hides it.
if (near.get(top) === true || near.get(blockOf(el)) === true) mask(top, "style");
else watch(top);
Expand Down Expand Up @@ -458,6 +509,7 @@
// Runs queued work until the deadline; true when the queue is empty.
function run(deadline) {
syncSpent = 0;
veils.clear();
for (;;) {
// Blocks that just came near go first: they're what the learner is about to see.
// A long walk (a big page's first pass) waits for them.
Expand Down
Loading