From 0048fe850bf51cbadf8e1e55b96d994945e85347 Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Wed, 7 Oct 2026 12:29:38 -0400 Subject: [PATCH 1/7] fix(server): no local program or web page can lock the extension out (D-02) Slice 54, finding D-02 (reviewer D, rc.2). Every program on the computer, and any web page open in a browser there, reaches the server from 127.0.0.1. A page could send 35 no-cors POSTs to /api/v1/proof (no Content-Type, so they reached the counter) and use up the 30-a-minute proof limit, and any local program could send 10 wrong tokens and lock 127.0.0.1 out, the extension included. The proof route now refuses a request without Content-Type: application/json (415) and a malformed nonce (400) before counting, so only well-formed proofs count. This computer's own addresses (127.0.0.0/8, ::1, IPv4-mapped 127.x) are exempt from the proof limit and the wrong-token lockout; other addresses keep both. A generated token can't be guessed, and a weak chosen one is already warned about at start. Regression tests that fail without this: 31 malformed proofs then a JSON one gets 200; 10 wrong tokens from 127.0.0.1, ::1 or ::ffff:127.0.0.1 then the right one gets 200; a non-loopback address is still locked out. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Ayla Croft --- server/lib/kotiko/auth_throttle.ex | 19 +++++-- server/lib/kotiko/rate_limit.ex | 10 ++++ server/lib/kotiko/router.ex | 38 ++++++++++--- server/test/kotiko/router_proof_test.exs | 60 +++++++++++++++++++++ server/test/kotiko/router_throttle_test.exs | 19 +++++++ 5 files changed, 134 insertions(+), 12 deletions(-) diff --git a/server/lib/kotiko/auth_throttle.ex b/server/lib/kotiko/auth_throttle.ex index 01247aa..6c872f6 100644 --- a/server/lib/kotiko/auth_throttle.ex +++ b/server/lib/kotiko/auth_throttle.ex @@ -9,13 +9,20 @@ defmodule Kotiko.AuthThrottle do right token included, until the minute is over: a guesser can't tell a right guess from a wrong one while it waits. Other addresses are never affected. - Only a wrong `Bearer` token counts; a request with no token or another scheme can't be - a guess. Addresses are counted as `Kotiko.RateLimit.client/1` says (an IPv6 /64 is one). + A wrong `Bearer` token counts, and so does a signed request (`Kotiko.RequestAuth`) that + fails; a request with no token, another scheme or a malformed signature can't be a + guess. + + This computer's own addresses (`Kotiko.RateLimit.loopback?/1`) are never locked out + (slice 54, D-02): every local program shares them, so any of them, or a web page in a + browser here, could otherwise lock the extension out. A token the server made (256 + random bits) can't be guessed anyway, and a weak chosen one gets a warning at start. Addresses are counted as `Kotiko.RateLimit.client/1` says (an IPv6 /64 is one). Each lockout is logged once an hour per address, for at most 100 addresses an hour (`Kotiko.Log.Limiter`). `auth_failures_per_minute: nil` turns the throttle off (tests). Behind a reverse proxy on the same machine every request comes from the proxy's - address, so a lockout applies to everyone behind it for the rest of that minute. + loopback address, so the lockout doesn't apply there: limit attempts at the proxy, and + keep the token the server made. """ require Logger alias Kotiko.Log.Limiter @@ -40,7 +47,8 @@ defmodule Kotiko.AuthThrottle do @doc "`:ok`, or `{:locked, seconds_left}` when `remote_ip` sent too many wrong tokens." def check(remote_ip) do - with limit when is_integer(limit) <- limit(), + with false <- RateLimit.loopback?(remote_ip), + limit when is_integer(limit) <- limit(), {count, seconds} when count >= limit <- RateLimit.peek(@kind, RateLimit.client(remote_ip)) do {:locked, seconds} @@ -51,7 +59,8 @@ defmodule Kotiko.AuthThrottle do @doc "Counts a wrong token from `remote_ip`; logs the lockout when it starts." def failed(remote_ip) do - with limit when is_integer(limit) <- limit() do + with false <- RateLimit.loopback?(remote_ip), + limit when is_integer(limit) <- limit() do client = RateLimit.client(remote_ip) {count, _seconds} = RateLimit.hit(@kind, client, @window_ms) if count == limit, do: log_lockout(client, limit) diff --git a/server/lib/kotiko/rate_limit.ex b/server/lib/kotiko/rate_limit.ex index c97d85b..3918002 100644 --- a/server/lib/kotiko/rate_limit.ex +++ b/server/lib/kotiko/rate_limit.ex @@ -39,6 +39,16 @@ defmodule Kotiko.RateLimit do :ok end + @doc """ + Whether `remote_ip` is this computer: 127.0.0.0/8, `::1`, or an IPv4-mapped 127.x. + Such peers are exempt from the proof limit and the wrong-token lockout (slice 54, + D-02): every local program shares them, so one could lock the extension out. + """ + def loopback?({127, _, _, _}), do: true + def loopback?({0, 0, 0, 0, 0, 0, 0, 1}), do: true + def loopback?({0, 0, 0, 0, 0, 0xFFFF, ab, _}), do: ab >>> 8 == 127 + def loopback?(_), do: false + @doc "The key `remote_ip` is counted under." def client({_, _, _, _} = ip), do: ip def client({0, 0, 0, 0, 0, 0xFFFF, ab, cd}), do: {ab >>> 8, ab &&& 0xFF, cd >>> 8, cd &&& 0xFF} diff --git a/server/lib/kotiko/router.ex b/server/lib/kotiko/router.ex index 8dba582..9279112 100644 --- a/server/lib/kotiko/router.ex +++ b/server/lib/kotiko/router.ex @@ -6,6 +6,7 @@ defmodule Kotiko.Router do use Plug.ErrorHandler require Logger alias Kotiko.{AddRequests, AuthThrottle, I18n, Lookup, RateLimit, Token, Word, Words} + alias Plug.Conn.Utils # First, on every route including /health: refuse names we don't answer to (DNS rebinding). plug Kotiko.Plug.HostCheck @@ -46,20 +47,43 @@ defmodule Kotiko.Router do # Open (no token): proves this server holds the API token without revealing it, so the # extension can check an address before it sends the token there (slice 54, B-01). post "/api/v1/proof" do - limit = Application.get_env(:kotiko, :proof_requests_per_minute, 30) - {count, seconds} = RateLimit.hit(:proof, RateLimit.client(conn.remote_ip), 60_000) + nonce = conn.body_params["nonce"] + # Checked before counting (slice 54, D-02): a web page can send a POST without + # Content-Type, or one that isn't JSON, but not application/json (that needs CORS). cond do - is_integer(limit) and count > limit -> + not json?(conn) -> + error(conn, true, 415, "invalid_request", %{reason: "content_type"}) + + not Token.nonce?(nonce) -> + error(conn, true, 400, "invalid_request", %{field: "nonce"}) + + seconds = too_many_proofs(conn.remote_ip) -> rate_limited(conn, "too_many_proofs", seconds) - Token.nonce?(conn.body_params["nonce"]) -> + true -> token = Application.fetch_env!(:kotiko, :api_token) - proof = Token.proof(token, conn.body_params["nonce"]) + proof = Token.proof(token, nonce) conn |> put_resp_header("cache-control", "no-store") |> json(200, %{proof: proof}) + end + end - true -> - error(conn, true, 400, "invalid_request", %{field: "nonce"}) + defp json?(conn) do + case get_req_header(conn, "content-type") do + [type] -> match?({:ok, "application", "json", _}, Utils.media_type(type)) + _ -> false + end + end + + # Seconds to wait, or nil. This computer's own addresses aren't limited (slice 54, D-02): + # every local program shares 127.0.0.1, so any of them could use up the extension's + # proofs. Strangers on the network keep the limit. + defp too_many_proofs(ip) do + limit = Application.get_env(:kotiko, :proof_requests_per_minute, 30) + + if is_integer(limit) and not RateLimit.loopback?(ip) do + {count, seconds} = RateLimit.hit(:proof, RateLimit.client(ip), 60_000) + if count > limit, do: seconds end end diff --git a/server/test/kotiko/router_proof_test.exs b/server/test/kotiko/router_proof_test.exs index 7d6d471..e38ce9a 100644 --- a/server/test/kotiko/router_proof_test.exs +++ b/server/test/kotiko/router_proof_test.exs @@ -86,6 +86,66 @@ defmodule Kotiko.RouterProofTest do assert status == 413 end + # Slice 54, D-02: a web page can send POSTs with no Content-Type or a text one (no CORS + # needed), and every local program shares 127.0.0.1, so neither may use up the proofs. + test "a proof needs Content-Type: application/json (415), checked before counting" do + conn = + Plug.Test.conn("POST", "http://localhost/api/v1/proof", Jason.encode!(%{nonce: @nonce})) + |> Kotiko.Router.call(Kotiko.Router.init([])) + + assert conn.status == 415 + + assert %{"code" => "invalid_request", "details" => %{"reason" => "content_type"}} = + json_body(conn)["error"] + end + + test "31 malformed proofs from one address, then a JSON proof: 200 (only good nonces count)" do + put_app_env(:proof_requests_per_minute, 30) + ip = {10, 79, 0, 1} + + for i <- 1..31 do + malformed = + case rem(i, 3) do + # What a page's no-cors fetch with a Blob sends: no Content-Type at all. + 0 -> + Plug.Test.conn("POST", "http://localhost/api/v1/proof", "x") + + # A bad nonce, as JSON. + 1 -> + build_conn(%{nonce: "short"}, ip: ip) + + # A nonce that isn't a string. + 2 -> + build_conn(%{nonce: 42}, ip: ip) + + build_conn(%{}, ip: ip) + |> Map.put(:body_params, %Plug.Conn.Unfetched{aspect: :body_params}) + end + + conn = Kotiko.Router.call(%{malformed | remote_ip: ip}, Kotiko.Router.init([])) + assert conn.status in [400, 415] + end + + assert prove(%{nonce: @nonce}, ip: ip).status == 200 + end + + test "this computer's addresses aren't limited; others are" do + put_app_env(:proof_requests_per_minute, 3) + + for ip <- [ + {127, 0, 0, 1}, + {127, 8, 0, 2}, + {0, 0, 0, 0, 0, 0, 0, 1}, + {0, 0, 0, 0, 0, 0xFFFF, 0x7F00, 1} + ] do + for _ <- 1..10, do: assert(prove(%{nonce: @nonce}, ip: ip).status == 200, inspect(ip)) + end + + ip = {192, 168, 7, 7} + for _ <- 1..3, do: assert(prove(%{nonce: @nonce}, ip: ip).status == 200) + assert prove(%{nonce: @nonce}, ip: ip).status == 429 + end + test "an unknown Host is refused before the proof" do conn = request("POST", "/api/v1/proof", [], body: %{nonce: @nonce}, host: "evil.example") assert conn.status == 421 diff --git a/server/test/kotiko/router_throttle_test.exs b/server/test/kotiko/router_throttle_test.exs index 90ba6da..243968c 100644 --- a/server/test/kotiko/router_throttle_test.exs +++ b/server/test/kotiko/router_throttle_test.exs @@ -53,6 +53,25 @@ defmodule Kotiko.RouterThrottleTest do refute log =~ "wrong-token" end + # Slice 54, D-02: every local program, and a web page in a browser here, reaches the + # server from 127.0.0.1, so a lockout there would lock the extension out too. + test "this computer's addresses are never locked out" do + log = + capture_log(fn -> + for ip <- [{127, 0, 0, 1}, {0, 0, 0, 0, 0, 0, 0, 1}, {0, 0, 0, 0, 0, 0xFFFF, 0x7F00, 1}] do + for _ <- 1..10, do: assert(from(ip, @wrong).status == 401) + assert from(ip, @wrong).status == 401 + assert from(ip, auth()).status == 200, inspect(ip) + end + end) + + refute log =~ "wrong API tokens from" + + # Another address still is. + capture_log(fn -> for _ <- 1..10, do: from({198, 51, 100, 99}, @wrong) end) + assert from({198, 51, 100, 99}, auth()).status == 429 + end + test "requests without a token don't count; the lockout line is logged once" do ip = {203, 0, 113, 10} for _ <- 1..20, do: assert(from(ip, []).status == 401) From 9078269bffeccf8c11135c79b31b173cdda6d706 Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Wed, 7 Oct 2026 12:30:10 -0400 Subject: [PATCH 2/7] fix(server): accept signed requests and sign every answer to them (D-01) Slice 54, finding D-01 (reviewer D, rc.2; it reopened B-01). The extension proved the server once per worker life and then sent the token with every request, so after the server stopped and another account took its port, the next sync handed that program the token. The server now accepts a request signed with the token instead of carrying it: Authorization: Kotiko-HMAC v1 ts=, nonce=, body=, mac=, where mac is HMAC-SHA256(token, "kotiko-req-v1\n" METHOD "\n" path?query "\n" ts "\n" nonce "\n" sha256-hex(body)). It compares the MAC in constant time, refuses a time more than 120 s off or from before the server started, a nonce it has seen (kept in ETS for the window, only after the MAC matched, at most 50,000; when full it refuses new signed requests rather than forget a live nonce), and a body whose hash isn't the signed one (read and checked before Plug.Parsers, which gets the same bytes through a caching body reader). A failed signature counts towards the lockout like a wrong Bearer token. Every answer to an accepted request, errors included, carries X-Kotiko-Server: v1 mac=HMAC(token, "kotiko-resp-v1\n" nonce "\n" status), so the client can tell its server's answers from anyone else's. A refused one says why in its challenge (Bearer, Kotiko-HMAC error="..."). Bearer stays for curl and tools. Tests: the documented vectors, a valid request and its signed answer, wrong MAC, token, path, method and query, stale and future times and one from before the start, a replayed nonce, a tampered body (nothing saved), a full nonce table, malformed headers, the lockout, signed 404/413/415/400, and every route the routers define. docs/reference/http-api.md describes the scheme with its test vectors. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Ayla Croft --- docs/reference/http-api.md | 175 +++++++-- server/lib/kotiko/application.ex | 1 + server/lib/kotiko/request_auth.ex | 213 +++++++++++ server/lib/kotiko/router.ex | 103 +++++- server/test/kotiko/router_auth_test.exs | 2 +- server/test/kotiko/router_signed_test.exs | 423 ++++++++++++++++++++++ 6 files changed, 876 insertions(+), 41 deletions(-) create mode 100644 server/lib/kotiko/request_auth.ex create mode 100644 server/test/kotiko/router_signed_test.exs diff --git a/docs/reference/http-api.md b/docs/reference/http-api.md index 0c1b01e..ace90fa 100644 --- a/docs/reference/http-api.md +++ b/docs/reference/http-api.md @@ -1,6 +1,6 @@ # HTTP API reference -Last reviewed: 2026-10-06. +Last reviewed: 2026-10-07. The Kotiko server answers HTTP on `http://127.0.0.1:4747` by default, and on `http://[::1]:4747` too (`BIND` and `PORT` in [configuration.md](configuration.md#bind)). The browser extension and `curl` use this API. This @@ -8,6 +8,7 @@ page lists every route the server has. A test (`server/test/kotiko/docs_test.exs a route is added or removed without updating this page. - [Requests](#requests) +- [Signed requests](#signed-requests) - [Errors](#errors) - [Versions](#versions) - [Routes](#routes): [health](#get-health), [words](#get-apiv1words), [the lookup @@ -18,7 +19,12 @@ a route is added or removed without updating this page. ## Requests **Authentication.** Every route except `GET /health`, `HEAD /health` and -[`POST /api/v1/proof`](#post-apiv1proof) needs the API token: +[`POST /api/v1/proof`](#post-apiv1proof) needs the API token, in one of two ways: + +- `Authorization: Bearer `, for `curl` and other tools you run yourself; +- a [signed request](#signed-requests), `Authorization: Kotiko-HMAC v1 ...`, which proves + the sender holds the token without sending it. The browser extension uses only this, so + the token never leaves the browser. ``` Authorization: Bearer @@ -26,18 +32,27 @@ Authorization: Bearer The scheme is case-sensitive (`Bearer`, not `bearer`) and only one `Authorization` header is accepted. The token is compared in constant time. Without the right token, every method -and path answers `401` with `www-authenticate: Bearer` and the error `server_key_rejected`, -before the server reads the body or looks at the route. `mix kotiko.token` (in `server/`) -prints the token; [configuration.md](configuration.md#api_token) says where it comes from. - -**Wrong tokens are throttled.** After 10 wrong `Bearer` tokens from one address within a -minute, every request from that address that needs the token gets `429` with `Retry-After` -and the error `rate_limited` (`details.reason`: `auth_failures`) until the minute is over, -even with the right token, so guessing learns nothing while it waits. Other addresses are -not affected. A request with no token, or another scheme, doesn't count. An IPv6 address -counts by its /64 network. The server logs the lockout once an hour per address. Behind a -reverse proxy on the same machine, every request comes from the proxy's address, so a -lockout there applies to everyone for the rest of that minute. +and path answers `401` with `www-authenticate: Bearer, Kotiko-HMAC` and the error +`server_key_rejected`, before the server reads the body or looks at the route. A signed +request that fails says why: `www-authenticate: Bearer, Kotiko-HMAC error=""` and +`details.reason` (see [Signed requests](#signed-requests)). `mix kotiko.token` (in +`server/`) prints the token; [configuration.md](configuration.md#api_token) says where it +comes from. + +**Wrong tokens are throttled.** After 10 wrong `Bearer` tokens or failed signatures from +one address within a minute, every request from that address that needs the token gets +`429` with `Retry-After` and the error `rate_limited` (`details.reason`: `auth_failures`) +until the minute is over, even with the right token, so guessing learns nothing while it +waits. Other addresses are not affected. A request with no token, another scheme or a +malformed signature doesn't count. An IPv6 address counts by its /64 network. The server +logs the lockout once an hour per address. This computer's own addresses (127.0.0.0/8, +`::1` and IPv4-mapped 127.x) are never locked out: every program on the computer, and any +web page open in a browser there, reaches the server from them, so any of them could +otherwise lock the extension out. A token the server made (256 random bits) can't be +guessed in any case; a weak token you chose can, and the server warns about it at start +([`API_TOKEN`](configuration.md#api_token)). Behind a reverse proxy on the same machine, +every request comes from the proxy's loopback address, so the lockout doesn't apply there: +limit attempts at the proxy and keep the generated token. **Host names.** Before anything else, the server checks the `Host` header, to stop web pages reaching it through DNS rebinding. It answers to `localhost`, IP addresses, this machine's @@ -62,6 +77,113 @@ answer to a request with that id is kept for 24 hours; sending the same id again the same answer without asking the model or saving again. An id that isn't a UUID is a `400` (`details.field`: `client_request_id`). +## Signed requests + +Slice 54, D-01. A signed request proves that its sender holds the API token without +sending it, and the answer proves that the server holds it too. Another program that +listens at the server's address (say, one that took the port while the server was +stopped) gets neither the token nor a request it could play to the server later, and the +client can tell its answers aren't the server's. The extension signs every request; tools +may keep using `Bearer`. + +``` +Authorization: Kotiko-HMAC v1 ts=, nonce=, body=, mac= +``` + +Exactly this form: one space after `Kotiko-HMAC`, `v1`, and after each comma; the four +fields in this order. + +| Field | Value | +|---|---| +| `ts` | The client's time, whole seconds since 1970-01-01 UTC (1 to 12 digits) | +| `nonce` | 22 to 128 base64url characters (`A-Z a-z 0-9 - _`), new for every request; the extension sends 16 random bytes (22 characters) | +| `body` | SHA-256 of the request body's bytes, 64 lowercase hex digits; of the empty string when there is no body | +| `mac` | base64url, without padding (43 characters), of HMAC-SHA256 with the token's UTF-8 bytes as the key, over the canonical request below | + +The canonical request is six lines joined by `\n` (LF), with no newline at the end: + +``` +kotiko-req-v1 + + + + + +``` + +- `METHOD` in capitals (`GET`, `PUT`). +- `path` is the request target as the server receives it: the path, raw (as sent, not + percent-decoded), then `?` and the query string if there is one (`/api/v1/words?status=active,paused`). + An address with a path prefix (a reverse proxy that mounts the server under `/kotiko` + and strips it) signs the path the server sees, without the prefix. +- `ts`, `nonce` and `body` exactly as in the header. + +The server accepts the request when the MAC matches (compared in constant time), `ts` is +within 120 seconds of its own clock and not earlier than the second it started, it hasn't +seen the nonce before, and the body it reads has the signed hash. It remembers each nonce +for the 120 seconds, once the MAC has matched (so only a token holder can fill that +memory), for at most 50,000 requests at once; beyond that it refuses signed requests with +`429 rate_limited` (`details.reason`: `too_many_requests`, with `Retry-After`) rather +than forget a nonce that could then be replayed. Refusing a `ts` from before the server +started covers the nonces a restart forgot: a request someone else received while the +server was stopped can't be played to it once it is back. + +Every answer to an accepted request, errors included, carries the server's signature: + +``` +X-Kotiko-Server: v1 mac= +``` + +where `nonce` is the request's and `status` the answer's HTTP status in decimal (`200`). +It covers the status, not the body. A client checks it before it reads anything else from +the answer. The extension treats an answer without it, or with a wrong one, as not from +its server (`not_kotiko_server`): it uses nothing from it, forgets the server's +[proof](#post-apiv1proof), and sends nothing more until the server proves itself again. + +A signed request that is refused gets `401 server_key_rejected` without `X-Kotiko-Server`; +`details.reason` and the challenge's `error` say why: `malformed` (not the form above; +doesn't count towards the [lockout](#requests)), `bad_mac`, `stale` (outside the 120 +seconds: check both clocks), `replayed` or `body_mismatch`. A server from before signed +requests answers them `401` with `www-authenticate: Bearer` alone; the extension then asks +you to update the server. + +Before a request that carries your words or settings (anything but `GET`), the extension +also asks for a [proof](#post-apiv1proof) when its last one is more than 30 seconds old. + +**Test vectors**, with the token `example-token-0123456789abcdef`: + +`PUT /api/v1/profile` with the body `{"base_langs":["es","en"],"ui_lang":null}` (those +exact bytes), `ts` 1791331200, nonce `q1aP3n0ZKcB1x5mW0u7S9b`: + +``` +kotiko-req-v1 +PUT +/api/v1/profile +1791331200 +q1aP3n0ZKcB1x5mW0u7S9b +40921055f301e073c3aa377b5ac7d8827ce72c5443e994014c2087b2895f51ff +``` + +``` +Authorization: Kotiko-HMAC v1 ts=1791331200, nonce=q1aP3n0ZKcB1x5mW0u7S9b, body=40921055f301e073c3aa377b5ac7d8827ce72c5443e994014c2087b2895f51ff, mac=osa_O2pBaygJz8c64WTOrTLSiY_6vV70NHtPyZ2CZpA +``` + +and the server's `200` answer to it: + +``` +X-Kotiko-Server: v1 mac=CAmhh5A5suXJTIJNSarztt97Q-RO5eA9K0baiuiw1u0 +``` + +`GET /api/v1/words?status=active,paused`, no body, `ts` 1791331200, nonce +`dE8gH0iL2nO4pQ6rS8tU0v`: + +``` +Authorization: Kotiko-HMAC v1 ts=1791331200, nonce=dE8gH0iL2nO4pQ6rS8tU0v, body=e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855, mac=uHVRpP6SeDO3nxpRjs_zp8V9NYSCV2XP15w9BtwCdPg +``` + +(`ts` this old is refused by a live server; the vectors are for checking an +implementation. Both the server's and the extension's tests check them.) + ## Errors Routes under `/api/v1` answer errors in one shape: @@ -85,7 +207,7 @@ Routes under `/api/v1` answer errors in one shape: | 400 | `invalid_request` | A parameter or body field is missing or wrong; `details.field` names it (and `details.max` for a limit) | | 400 | `invalid_word` | An edit doesn't make a valid word; `details.field` and `details.reason` | | 400 | `empty_input`, `input_too_long` | The add text is empty, or longer than 200 characters | -| 401 | `server_key_rejected` | Missing or wrong token (every route but `/health`) | +| 401 | `server_key_rejected` | Missing or wrong token, or a signed request that failed (`details.reason`); every route but `/health` and the proof | | 404 | `word_gone` | No word with that id, or it was deleted | | 404 | `not_found` | No such route under `/api/v1` | | 409 | `word_conflict` | `details.reason`: `stale` (changed since `if_updated_at`, with the current `details.word`) or `duplicate` (another word has the same language, text and meaning, `details.other_id`) | @@ -94,7 +216,7 @@ Routes under `/api/v1` answer errors in one shape: | 415 | `invalid_request` | The body isn't JSON (`details.reason`: `content_type`) | | 421 | `server_address_invalid` | The `Host` header names a host the server doesn't answer to | | 429 | `rate_limited`, `quota_exhausted` | The model provider is busy, or today's free lookups are used up; `Retry-After` (seconds) and `details.retry_at` when known; `details.reason` is `payment_required` when the provider wants credit | -| 429 | `rate_limited` | Too many wrong tokens from this address (`details.reason`: `auth_failures`), or too many proofs (`too_many_proofs`); `Retry-After` (seconds) | +| 429 | `rate_limited` | Too many wrong tokens from this address (`details.reason`: `auth_failures`), too many proofs (`too_many_proofs`), or too many signed requests at once (`too_many_requests`); `Retry-After` (seconds) | | 502 | `key_rejected`, `model_unavailable`, `bad_lookup_result` | The provider refused the server's key, no model could answer, or the answer wasn't usable | | 503 | `lookup_timeout`, `lookup_not_set_up` | No answer within the add's deadline, or no model key is set | | 500 | `internal` | A bug; `details.ref` is a reference to find in the server log. No stack trace is sent | @@ -155,9 +277,10 @@ The same check as `GET /health`, with the same status (`200` or `503`) and no bo ### POST /api/v1/proof Proves that this server holds your API token, without sending it. No token needed. The -extension can call it before it sends the token to an address, so another program -listening at that address (say, on `[::1]` while the server listens on `127.0.0.1`) gets -nothing it can use. +extension calls it before its first request to an address, and again before a request +that carries your words or settings when its last proof is more than 30 seconds old, so +another program listening at that address (say, one that took the port while the server +was stopped) gets nothing it can use. Send a fresh random nonce: 32 to 128 base64url characters (`A-Z`, `a-z`, `0-9`, `-`, `_`, no `=` padding), for example 32 random bytes encoded as base64url. @@ -177,13 +300,19 @@ check your own implementation. `proof` is `base64url(HMAC-SHA256(key = the API token, message = "kotiko-proof-v1:" + nonce))`, without padding (43 characters). The key is the token's UTF-8 bytes as you'd send them in `Authorization`; the message is the ASCII prefix followed by the nonce exactly as sent. -Compute the same value with the token you have and compare in constant time; send the -token only if they match. Use a new nonce each time. +Compute the same value with the token you have and compare in constant time; send +requests only if they match. Use a new nonce each time. +- `415 invalid_request` (`details.reason`: `content_type`): the request has no + `Content-Type: application/json`. A web page can't send that type to another site + without the server's consent (CORS), so it can't use up the limit below. - `400 invalid_request` (`details.field`: `nonce`): the nonce is missing or not 32 to 128 base64url characters. The body may be at most 1,000 bytes (`413`). -- `429 rate_limited` (`details.reason`: `too_many_proofs`): more than 30 proofs from one - address within a minute; `Retry-After` says when to try again. +- `429 rate_limited` (`details.reason`: `too_many_proofs`): more than 30 proofs with a + well-formed nonce from one address within a minute; `Retry-After` says when to try + again. Refused requests (`415`, `400`) don't count, and neither do this computer's own + addresses (127.0.0.0/8, `::1`, IPv4-mapped 127.x), which every local program and web + page shares. - The answer never contains the token and is sent with `cache-control: no-store`. Like `/health`, it is checked against the `Host` names the server answers to. - Only this exact path and `POST` are open: any other method or spelling diff --git a/server/lib/kotiko/application.ex b/server/lib/kotiko/application.ex index b3bc663..e60e8bb 100644 --- a/server/lib/kotiko/application.ex +++ b/server/lib/kotiko/application.ex @@ -34,6 +34,7 @@ defmodule Kotiko.Application do Redact.put_secrets(Redact.configured_secrets()) HostCheck.init_table() Kotiko.AuthThrottle.init() + Kotiko.RequestAuth.init() words = Kotiko.Migrations.run!() http = http_settings() log_summary(token_source, words, http) diff --git a/server/lib/kotiko/request_auth.ex b/server/lib/kotiko/request_auth.ex new file mode 100644 index 0000000..d89467b --- /dev/null +++ b/server/lib/kotiko/request_auth.ex @@ -0,0 +1,213 @@ +# SPDX-FileCopyrightText: 2026 ScriptKittyOS and the Kotiko contributors +# SPDX-License-Identifier: Apache-2.0 + +defmodule Kotiko.RequestAuth do + @moduledoc """ + Signed requests (slice 54, D-01): the extension never sends the API token. It signs each + request with it instead, and the server signs its answer, so a program that took the + port while the server was stopped gets neither the token nor a usable request. + + A signed request carries one header: + + Authorization: Kotiko-HMAC v1 ts=, nonce=, body=, mac= + + * `ts`: the client's time, in whole seconds since 1970 (1 to 12 digits). + * `nonce`: 22 to 128 base64url characters (16 or more random bytes), new for every request. + * `body`: the SHA-256 of the request body's bytes, 64 lowercase hex digits (of the empty + string when there is no body). + * `mac`: base64url, without padding, of HMAC-SHA256 with the token's UTF-8 bytes as the + key over the canonical request: + + "kotiko-req-v1\\n" <> METHOD <> "\\n" <> path <> "\\n" <> ts <> "\\n" <> nonce <> "\\n" <> body + + METHOD is the method in capitals; `path` is the request target as the server receives + it, raw (not percent-decoded), with `?` and the query string when there is one; `ts`, + `nonce` and `body` are the header's values exactly as sent. + + It is accepted when the MAC matches (compared in constant time), `ts` is within + 120 seconds of the server's clock and not before the server started, the nonce wasn't + seen before, and the body's hash is the one signed. Every answer to an accepted request + carries + + X-Kotiko-Server: v1 mac= nonce <> "\\n" <> status)> + + so the client knows the answer came from a server that holds the token. + + Nonces are remembered for the window in one ETS table, only once the MAC matched, so + strangers can't fill it. It holds at most 50,000 (`:max_nonces`); when full after dropping the + expired ones, new signed requests are refused (`:full`, 429) rather than forgetting a live + nonce, which would let that request be replayed. Only a token holder can fill it, and + only by sending that many requests within four minutes. + + A server restarted between a request and its replay has forgotten the nonce; refusing a + `ts` from before the server started closes that gap: a request a squatter caught while + the server was stopped can't be played to the server once it is back. + """ + import Plug.Conn + + @window 120 + @max_nonces 50_000 + @purge_every 1_000 + @table Kotiko.RequestAuth.Nonces + @started {__MODULE__, :started} + @request_label "kotiko-req-v1" + @response_label "kotiko-resp-v1" + @scheme "Kotiko-HMAC " + @params ~r/\Av1 ts=(\d{1,12}), nonce=([A-Za-z0-9_-]{22,128}), body=([0-9a-f]{64}), mac=([A-Za-z0-9_-]{43})\z/ + + @doc "The window, in seconds, either side of the server's clock." + def window, do: @window + + @doc "Creates the nonce table and notes when the server started (at boot)." + def init do + if :ets.whereis(@table) == :undefined do + :ets.new(@table, [:named_table, :public, :set, write_concurrency: true]) + end + + :persistent_term.put(@started, System.system_time(:second)) + :ok + end + + @doc "Forgets every nonce (for tests); `started` sets the start time." + def reset(started \\ 0) do + if :ets.whereis(@table) != :undefined, do: :ets.delete_all_objects(@table) + :persistent_term.put(@started, started) + :ok + end + + @doc "Whether an `Authorization` value uses this scheme." + def signed?(value), do: String.starts_with?(value, @scheme) + + @doc """ + The header's fields, or `:error` when it isn't exactly the form above (one space after + the scheme and each comma, the fields in this order, nothing else). + """ + def parse(@scheme <> params) do + case Regex.run(@params, params) do + [_, ts, nonce, body, mac] -> + {:ok, %{ts: String.to_integer(ts), ts_text: ts, nonce: nonce, body: body, mac: mac}} + + nil -> + :error + end + end + + def parse(_), do: :error + + @doc "The canonical request the MAC is made over." + def canonical(method, path, ts_text, nonce, body_hash), + do: Enum.join([@request_label, method, path, ts_text, nonce, body_hash], "\n") + + @doc "The request target as the server got it: the raw path, then `?query` if any." + def target(%Plug.Conn{request_path: path, query_string: ""}), do: path + def target(%Plug.Conn{request_path: path, query_string: q}), do: path <> "?" <> q + + @doc "base64url(HMAC-SHA256(token, message)), without padding." + def mac(token, message), + do: :hmac |> :crypto.mac(:sha256, token, message) |> Base.url_encode64(padding: false) + + @doc "The SHA-256 of `body`, as 64 lowercase hex digits." + def body_hash(body), do: :sha256 |> :crypto.hash(body) |> Base.encode16(case: :lower) + + @doc "The `X-Kotiko-Server` value for an answer with `status` to the request `nonce`." + def response_header(token, nonce, status), + do: "v1 mac=" <> mac(token, Enum.join([@response_label, nonce, to_string(status)], "\n")) + + @doc """ + Checks a parsed header against `conn` and `token`. `:ok`, or `{:error, reason}`: + `:bad_mac`, `:stale` (outside the window, or from before the server started), + `:replayed`, or `:full` (the nonce table is full; the request was genuine). + """ + def verify(conn, token, %{} = h) do + expected = mac(token, canonical(conn.method, target(conn), h.ts_text, h.nonce, h.body)) + now = System.system_time(:second) + + cond do + not Plug.Crypto.secure_compare(expected, h.mac) -> {:error, :bad_mac} + abs(now - h.ts) > @window or h.ts < started() -> {:error, :stale} + true -> remember(h.nonce, h.ts + @window + 1, now) + end + end + + defp started, do: :persistent_term.get(@started, 0) + + # insert_new is atomic: of two requests with one nonce, exactly one gets in. + defp remember(nonce, expires, now) do + cond do + :ets.whereis(@table) == :undefined -> {:error, :full} + not room?(now) -> {:error, :full} + :ets.insert_new(@table, {nonce, expires}) -> :ok + true -> {:error, :replayed} + end + end + + defp room?(now) do + max = max_nonces() + size = :ets.info(@table, :size) + if size >= max or (size > 0 and rem(size, @purge_every) == 0), do: purge(now) + :ets.info(@table, :size) < max + end + + defp max_nonces, do: Application.get_env(:kotiko, :max_nonces, @max_nonces) + + defp purge(now), + do: :ets.select_delete(@table, [{{:_, :"$1"}, [{:<, :"$1", now}], [true]}]) + + @doc "Seconds until the oldest nonce expires (for `Retry-After` when the table is full)." + def retry_after do + now = System.system_time(:second) + + case :ets.select(@table, [{{:_, :"$1"}, [], [:"$1"]}]) do + [] -> 1 + ends -> max(1, Enum.min(ends) - now) + end + rescue + ArgumentError -> 1 + end + + @doc """ + Marks `conn` as signed: every answer gets `X-Kotiko-Server`, and the body is checked + against the signed hash when it is read (`check_body/2`). + """ + def accept(conn, token, %{} = h) do + conn + |> put_private(:kotiko_signed, h.body) + |> register_before_send(fn conn -> + put_resp_header(conn, "x-kotiko-server", response_header(token, h.nonce, conn.status)) + end) + end + + @doc """ + For a signed request, reads the whole body (at most `length` bytes) and checks its hash. + Returns `{:ok, conn}` with the body kept for `read_body/2`, or `{:error, reason}`: + `:too_large`, `:body_mismatch` or `:unreadable`. Any other request is returned as it is. + """ + def check_body(%Plug.Conn{private: %{kotiko_signed: hash}} = conn, length) do + # One read of at most `length` bytes, as Plug.Parsers reads: more is too large. + case Plug.Conn.read_body(conn, length: length) do + {:ok, body, conn} -> + if Plug.Crypto.secure_compare(body_hash(body), hash), + do: {:ok, put_private(conn, :kotiko_raw_body, body)}, + else: {:error, :body_mismatch} + + {:more, _partial, _conn} -> + {:error, :too_large} + + {:error, _reason} -> + {:error, :unreadable} + end + end + + def check_body(conn, _length), do: {:ok, conn} + + @doc """ + `Plug.Parsers`' body reader: the body `check_body/2` already read and checked, or the + connection's own. + """ + def read_body(%Plug.Conn{private: %{kotiko_raw_body: nil}} = conn, _opts), do: {:ok, "", conn} + + def read_body(%Plug.Conn{private: %{kotiko_raw_body: body}} = conn, _opts), + do: {:ok, body, put_private(conn, :kotiko_raw_body, nil)} + + def read_body(conn, opts), do: Plug.Conn.read_body(conn, opts) +end diff --git a/server/lib/kotiko/router.ex b/server/lib/kotiko/router.ex index 9279112..2a0dd4c 100644 --- a/server/lib/kotiko/router.ex +++ b/server/lib/kotiko/router.ex @@ -5,8 +5,20 @@ defmodule Kotiko.Router do use Plug.Router use Plug.ErrorHandler require Logger - alias Kotiko.{AddRequests, AuthThrottle, I18n, Lookup, RateLimit, Token, Word, Words} - alias Plug.Conn.Utils + + alias Kotiko.{ + AddRequests, + AuthThrottle, + I18n, + Lookup, + RateLimit, + RequestAuth, + Token, + Word, + Words + } + + alias Plug.Conn.{Utils, WrapperError} # First, on every route including /health: refuse names we don't answer to (DNS rebinding). plug Kotiko.Plug.HostCheck @@ -17,20 +29,36 @@ defmodule Kotiko.Router do plug :dispatch # Only JSON: a body of another type is refused with 415 (slice 25), never passed on unread. - @parsers [parsers: [:json], json_decoder: Jason] + # The body reader hands over the body a signed request's check already read (D-01). + @parsers [ + parsers: [:json], + json_decoder: Jason, + body_reader: {RequestAuth, :read_body, []} + ] @small_body Plug.Parsers.init([length: 64_000] ++ @parsers) @batch_body Plug.Parsers.init([length: 1_000_000] ++ @parsers) @proof_body Plug.Parsers.init([length: 1_000] ++ @parsers) # 64 KB everywhere, except the batch add, which takes up to 500 words (slice 07), and the - # proof, which anyone may send: a nonce fits in 1 KB. - defp parse_body(%{path_info: ["api", "v1", "words", "batch"]} = conn, _opts), - do: Plug.Parsers.call(conn, @batch_body) - - defp parse_body(%{path_info: ["api", "v1", "proof"]} = conn, _opts), - do: Plug.Parsers.call(conn, @proof_body) + # proof, which anyone may send: a nonce fits in 1 KB. A signed request's body is read + # first and checked against the hash it was signed with (Kotiko.RequestAuth). + defp parse_body(conn, _opts) do + {length, parsers} = body_limit(conn.path_info) + + case RequestAuth.check_body(conn, length) do + {:ok, conn} -> Plug.Parsers.call(conn, parsers) + {:error, :too_large} -> raise Plug.Parsers.RequestTooLargeError + {:error, :unreadable} -> raise Plug.BadRequestError + {:error, :body_mismatch} -> signature_failed(conn, :body_mismatch) + end + rescue + # With this conn, so handle_errors' answer to a signed request is signed too. + e -> WrapperError.reraise(conn, :error, e, __STACKTRACE__) + end - defp parse_body(conn, _opts), do: Plug.Parsers.call(conn, @small_body) + defp body_limit(["api", "v1", "words", "batch"]), do: {1_000_000, @batch_body} + defp body_limit(["api", "v1", "proof"]), do: {1_000, @proof_body} + defp body_limit(_), do: {64_000, @small_body} # Open (no token): which server this is, its version and whether the database works. # Clients that only check for a 200 keep working. @@ -45,7 +73,7 @@ defmodule Kotiko.Router do end # Open (no token): proves this server holds the API token without revealing it, so the - # extension can check an address before it sends the token there (slice 54, B-01). + # extension can check an address before it sends requests there (slice 54, B-01, D-01). post "/api/v1/proof" do nonce = conn.body_params["nonce"] @@ -266,6 +294,9 @@ defmodule Kotiko.Router do end end + # `Bearer ` (curl and other tools), or a signed request (the extension, which + # never sends the token: slice 54, D-01, Kotiko.RequestAuth). A wrong token or a failed + # signature counts towards the lockout; a missing or malformed header can't be a guess. defp check_token(conn) do expected = Application.fetch_env!(:kotiko, :api_token) @@ -278,11 +309,41 @@ defmodule Kotiko.Router do deny(conn) end + [value] -> + if RequestAuth.signed?(value), do: check_signed(conn, value, expected), else: deny(conn) + _ -> deny(conn) end end + defp check_signed(conn, value, token) do + case RequestAuth.parse(value) do + {:ok, header} -> + case RequestAuth.verify(conn, token, header) do + :ok -> + RequestAuth.accept(conn, token, header) + + # Genuine, but too many at once to remember their nonces: refused, and signed. + {:error, :full} -> + conn + |> RequestAuth.accept(token, header) + |> rate_limited("too_many_requests", RequestAuth.retry_after()) + + {:error, reason} -> + signature_failed(conn, reason) + end + + :error -> + deny(conn, "malformed") + end + end + + defp signature_failed(conn, reason) do + AuthThrottle.failed(conn.remote_ip) + deny(conn, Atom.to_string(reason)) + end + defp rate_limited(conn, reason, seconds) do conn |> put_resp_header("retry-after", to_string(seconds)) @@ -290,17 +351,25 @@ defmodule Kotiko.Router do |> halt() end - defp deny(conn) do - body = %{ - error: %{ + # The challenge names both schemes; for a signed request, why it failed (`malformed`, + # `bad_mac`, `stale`, `replayed`, `body_mismatch`). The extension tells a server from + # before signed requests by its plain `Bearer` challenge. + defp deny(conn, reason \\ nil) do + error = + %{ code: "server_key_rejected", message: I18n.error_message(I18n.locale(conn), "server_key_rejected") } - } + + {challenge, error} = + if reason, + do: + {~s(Bearer, Kotiko-HMAC error="#{reason}"), Map.put(error, :details, %{reason: reason})}, + else: {"Bearer, Kotiko-HMAC", error} conn - |> put_resp_header("www-authenticate", "Bearer") - |> json(401, body) + |> put_resp_header("www-authenticate", challenge) + |> json(401, %{error: error}) |> halt() end end diff --git a/server/test/kotiko/router_auth_test.exs b/server/test/kotiko/router_auth_test.exs index c4c763f..be59871 100644 --- a/server/test/kotiko/router_auth_test.exs +++ b/server/test/kotiko/router_auth_test.exs @@ -55,7 +55,7 @@ defmodule Kotiko.RouterAuthTest do assert conn.status == 401, "#{method} #{path} answered #{conn.status} without the right token" - assert get_resp_header(conn, "www-authenticate") == ["Bearer"] + assert get_resp_header(conn, "www-authenticate") == ["Bearer, Kotiko-HMAC"] assert %{"error" => %{"code" => "server_key_rejected"}} = json_body(conn) assert get_resp_header(conn, "access-control-allow-origin") == [] end diff --git a/server/test/kotiko/router_signed_test.exs b/server/test/kotiko/router_signed_test.exs new file mode 100644 index 0000000..a4ce6f8 --- /dev/null +++ b/server/test/kotiko/router_signed_test.exs @@ -0,0 +1,423 @@ +# SPDX-FileCopyrightText: 2026 ScriptKittyOS and the Kotiko contributors +# SPDX-License-Identifier: Apache-2.0 + +defmodule Kotiko.RouterSignedTest do + # Signed requests (slice 54, D-01): the extension never sends the token; it signs each + # request with it (Kotiko.RequestAuth), and the server signs every answer to a signed + # request, so a program that took the port while the server was stopped gets nothing it + # can use, and is found out at its first answer. + use Kotiko.ConnCase, async: false + import ExUnit.CaptureLog + alias Kotiko.RequestAuth + + @root Path.expand("../../..", __DIR__) + + setup do + RequestAuth.reset() + Kotiko.AuthThrottle.reset() + on_exit(fn -> RequestAuth.reset() end) + :ok + end + + # The headers of a request signed with `token`, as the extension makes them. + defp sign(method, target, body, opts \\ []) do + token = Keyword.get(opts, :token, token()) + ts = Keyword.get(opts, :ts, System.system_time(:second)) |> to_string() + + nonce = + Keyword.get(opts, :nonce, Base.url_encode64(:crypto.strong_rand_bytes(16), padding: false)) + + hash = RequestAuth.body_hash(Keyword.get(opts, :signed_body, body)) + mac = RequestAuth.mac(token, RequestAuth.canonical(method, target, ts, nonce, hash)) + + {[{"authorization", "Kotiko-HMAC v1 ts=#{ts}, nonce=#{nonce}, body=#{hash}, mac=#{mac}"}], + nonce} + end + + # Sends a signed request; returns the conn and its nonce. + defp signed(method, target, body \\ nil, opts \\ []) do + {encoded, req_opts} = + case body do + nil -> {"", []} + b when is_map(b) -> {Jason.encode!(b), [body: Jason.encode!(b)]} + b -> {b, [body: b]} + end + + {headers, nonce} = sign(method, target, encoded, opts) + + conn = + method + |> build_signed(target, headers ++ Keyword.get(opts, :headers, []), req_opts) + |> Map.put(:remote_ip, Keyword.get(opts, :ip, {127, 0, 0, 1})) + |> Kotiko.Router.call(Kotiko.Router.init([])) + + {conn, nonce} + end + + defp build_signed(method, target, headers, opts) do + {body, headers} = + case Keyword.get(opts, :body) do + nil -> {nil, headers} + b -> {b, [{"content-type", "application/json"} | headers]} + end + + conn = Plug.Test.conn(method, "http://localhost" <> target, body) + %{conn | req_headers: conn.req_headers ++ headers} + end + + defp server_header(conn, nonce), + do: + get_resp_header(conn, "x-kotiko-server") == [ + RequestAuth.response_header(token(), nonce, conn.status) + ] + + describe "the scheme" do + # The same vectors are in docs/reference/http-api.md and the extension's tests + # (test/unit/server-auth.test.mjs). + test "matches the examples in docs/reference/http-api.md" do + token = "example-token-0123456789abcdef" + body = ~s({"base_langs":["es","en"],"ui_lang":null}) + hash = RequestAuth.body_hash(body) + assert hash == "40921055f301e073c3aa377b5ac7d8827ce72c5443e994014c2087b2895f51ff" + + canonical = + RequestAuth.canonical( + "PUT", + "/api/v1/profile", + "1791331200", + "q1aP3n0ZKcB1x5mW0u7S9b", + hash + ) + + assert canonical == + "kotiko-req-v1\nPUT\n/api/v1/profile\n1791331200\nq1aP3n0ZKcB1x5mW0u7S9b\n" <> hash + + assert RequestAuth.mac(token, canonical) == "osa_O2pBaygJz8c64WTOrTLSiY_6vV70NHtPyZ2CZpA" + + assert RequestAuth.response_header(token, "q1aP3n0ZKcB1x5mW0u7S9b", 200) == + "v1 mac=CAmhh5A5suXJTIJNSarztt97Q-RO5eA9K0baiuiw1u0" + + empty = RequestAuth.body_hash("") + + get = + RequestAuth.canonical( + "GET", + "/api/v1/words?status=active,paused", + "1791331200", + "dE8gH0iL2nO4pQ6rS8tU0v", + empty + ) + + assert empty == "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + assert RequestAuth.mac(token, get) == "uHVRpP6SeDO3nxpRjs_zp8V9NYSCV2XP15w9BtwCdPg" + + doc = File.read!(Path.join(@root, "docs/reference/http-api.md")) + + for value <- [ + "osa_O2pBaygJz8c64WTOrTLSiY_6vV70NHtPyZ2CZpA", + "CAmhh5A5suXJTIJNSarztt97Q-RO5eA9K0baiuiw1u0", + "uHVRpP6SeDO3nxpRjs_zp8V9NYSCV2XP15w9BtwCdPg", + hash + ], + do: assert(doc =~ value, value) + end + + test "a valid signed request is answered, and the answer is signed" do + word_fixture() + {conn, nonce} = signed("GET", "/api/v1/words?status=active") + + assert conn.status == 200 + assert [%{"native" => "да"}] = json_body(conn)["words"] + assert server_header(conn, nonce) + # The answer's MAC is over the nonce and the status, under the token. + assert [ + "v1 mac=" <> + RequestAuth.mac(token(), "kotiko-resp-v1\n#{nonce}\n200") + ] == get_resp_header(conn, "x-kotiko-server") + end + + test "a request with a body: the body is checked against the signed hash" do + body = %{word: %{lang: "ru", native: "кот", base_lang: "en", gloss: "cat"}} + {conn, nonce} = signed("POST", "/api/v1/words", body) + assert conn.status == 200 + assert [%{"result" => "created"}] = json_body(conn)["results"] + assert server_header(conn, nonce) + end + + test "a tampered body is refused, and nothing is saved" do + before = length(Kotiko.Words.list([])) + + signed_body = + Jason.encode!(%{word: %{lang: "ru", native: "кот", base_lang: "en", gloss: "cat"}}) + + sent = %{word: %{lang: "ru", native: "пёс", base_lang: "en", gloss: "dog"}} + + {conn, _nonce} = signed("POST", "/api/v1/words", sent, signed_body: signed_body) + + assert conn.status == 401 + + assert get_resp_header(conn, "www-authenticate") == [ + ~s(Bearer, Kotiko-HMAC error="body_mismatch") + ] + + assert %{"code" => "server_key_rejected", "details" => %{"reason" => "body_mismatch"}} = + json_body(conn)["error"] + + assert length(Kotiko.Words.list([])) == before + end + + test "a wrong MAC, another token, another path or method: 401, unsigned" do + cases = [ + {"another token", + fn -> signed("GET", "/api/v1/words", nil, token: "another-token-0123456789abcdefgh") end}, + {"a MAC for another path", + fn -> + {[{_, value}], _} = sign("GET", "/api/v1/profile", "") + {request("GET", "/api/v1/words", [{"authorization", value}]), nil} + end}, + {"a MAC for another method", + fn -> + {[{_, value}], _} = sign("GET", "/api/v1/words/" <> Ecto.UUID.generate(), "") + + {request("DELETE", "/api/v1/words/" <> Ecto.UUID.generate(), [{"authorization", value}]), + nil} + end}, + {"a MAC for another query", + fn -> + {[{_, value}], _} = sign("GET", "/api/v1/words?status=active", "") + {request("GET", "/api/v1/words?status=paused", [{"authorization", value}]), nil} + end} + ] + + for {label, run} <- cases do + {conn, _} = run.() + assert conn.status == 401, label + + assert get_resp_header(conn, "www-authenticate") == [ + ~s(Bearer, Kotiko-HMAC error="bad_mac") + ], + label + + assert get_resp_header(conn, "x-kotiko-server") == [], label + end + end + + test "a stale or future ts, or one from before the server started, is refused" do + now = System.system_time(:second) + + for ts <- [now - 121, now + 121, 0] do + {conn, _} = signed("GET", "/api/v1/words", nil, ts: ts) + assert conn.status == 401, "ts #{ts}" + + assert get_resp_header(conn, "www-authenticate") == [ + ~s(Bearer, Kotiko-HMAC error="stale") + ] + end + + for ts <- [now - 119, now + 119] do + assert {%{status: 200}, _} = signed("GET", "/api/v1/words", nil, ts: ts) + end + + # A request made before this server started (one a squatter kept while the server was + # stopped) is refused even inside the window: the restarted server forgot its nonces. + RequestAuth.reset(now) + assert {%{status: 401}, _} = signed("GET", "/api/v1/words", nil, ts: now - 1) + assert {%{status: 200}, _} = signed("GET", "/api/v1/words", nil, ts: now) + end + + test "a nonce is accepted once" do + nonce = "replayed_nonce-0123456789" + assert {%{status: 200}, _} = signed("GET", "/api/v1/words", nil, nonce: nonce) + {conn, _} = signed("GET", "/api/v1/words", nil, nonce: nonce) + assert conn.status == 401 + + assert get_resp_header(conn, "www-authenticate") == [ + ~s(Bearer, Kotiko-HMAC error="replayed") + ] + + # Even with another method and path: the nonce is spent. + assert {%{status: 401}, _} = signed("GET", "/api/v1/profile", nil, nonce: nonce) + end + + test "a failed MAC doesn't spend the nonce, so strangers can't fill the table" do + nonce = "kept_nonce-0123456789abcd" + + assert {%{status: 401}, _} = + signed("GET", "/api/v1/words", nil, + nonce: nonce, + token: "x-token-0123456789abcdefghijk" + ) + + assert {%{status: 200}, _} = signed("GET", "/api/v1/words", nil, nonce: nonce) + end + + test "when the nonce table is full, new signed requests are refused (signed 429), never forgotten" do + put_app_env(:max_nonces, 3) + for _ <- 1..3, do: assert({%{status: 200}, _} = signed("GET", "/api/v1/words")) + + {conn, nonce} = signed("GET", "/api/v1/words") + assert conn.status == 429 + + assert %{"code" => "rate_limited", "details" => %{"reason" => "too_many_requests"}} = + json_body(conn)["error"] + + assert [retry] = get_resp_header(conn, "retry-after") + assert String.to_integer(retry) in 1..241 + assert server_header(conn, nonce) + # Bearer still works. + assert request("GET", "/api/v1/words", auth()).status == 200 + end + + test "malformed headers are 401 and don't count as guesses" do + put_app_env(:auth_failures_per_minute, 2) + {[{_, good}], _} = sign("GET", "/api/v1/words", "") + + for bad <- [ + "Kotiko-HMAC", + "Kotiko-HMAC v2 " <> String.replace_prefix(good, "Kotiko-HMAC v1 ", ""), + String.replace(good, ", ", ","), + String.replace(good, "body=", "hash="), + good <> ", extra=1", + String.replace(good, ~r/nonce=[^,]+/, "nonce=short"), + "kotiko-hmac " <> String.replace_prefix(good, "Kotiko-HMAC ", "") + ] do + conn = request("GET", "/api/v1/words", [{"authorization", bad}]) + assert conn.status == 401, bad + end + + for _ <- 1..5 do + conn = + Plug.Test.conn("GET", "http://localhost/api/v1/words") + |> Map.put(:remote_ip, {203, 0, 113, 50}) + |> put_req_header("authorization", "Kotiko-HMAC v1 nonsense") + |> Kotiko.Router.call(Kotiko.Router.init([])) + + assert get_resp_header(conn, "www-authenticate") == [ + ~s(Bearer, Kotiko-HMAC error="malformed") + ] + end + + assert {%{status: 200}, _} = signed("GET", "/api/v1/words", nil, ip: {203, 0, 113, 50}) + end + + test "a failed signature counts towards the lockout like a wrong token (not from loopback)" do + put_app_env(:auth_failures_per_minute, 10) + ip = {203, 0, 113, 60} + + capture_log(fn -> + for _ <- 1..10 do + assert {%{status: 401}, _} = + signed("GET", "/api/v1/words", nil, + ip: ip, + token: "x-token-0123456789abcdefghijk" + ) + end + end) + + assert {%{status: 429}, _} = signed("GET", "/api/v1/words", nil, ip: ip) + assert {%{status: 200}, _} = signed("GET", "/api/v1/words", nil, ip: {198, 51, 100, 60}) + end + + test "errors after the check are signed too: 404, 413, 415, 400" do + {conn, nonce} = signed("GET", "/api/v1/nope") + assert conn.status == 404 + assert server_header(conn, nonce) + + {conn, nonce} = signed("GET", "/api/v1/words/" <> Ecto.UUID.generate()) + assert conn.status == 404 + assert server_header(conn, nonce) + + for {body, headers, status} <- [ + {String.duplicate("x", 70_000), [], 413}, + {"text=hi", [{"content-type", "application/x-www-form-urlencoded"}], 415}, + {"{not json", [], 400} + ] do + {[{_, value}], nonce} = sign("POST", "/api/v1/words", body) + + conn = + Plug.Test.conn("POST", "http://localhost/api/v1/words", body) + |> then(&%{&1 | req_headers: &1.req_headers ++ headers}) + |> then(fn c -> + if headers == [], do: put_req_header(c, "content-type", "application/json"), else: c + end) + |> put_req_header("authorization", value) + + {Plug.Adapters.Test.Conn, %{ref: ref}} = conn.adapter + + try do + Kotiko.Router.call(conn, Kotiko.Router.init([])) + rescue + _ -> :ok + end + + assert_received {^ref, {^status, resp_headers, _body}} + + assert {"x-kotiko-server", RequestAuth.response_header(token(), nonce, status)} in resp_headers, + "#{status}" + end + end + end + + # Every route the server has (read from the router sources, as docs_test does), signed: + # none answers 401, and every answer carries the server's signature. So a new route can't + # skip the check, and no route is left that only takes `Bearer`. + describe "every route" do + @route ~r/^\s*(get|post|put|patch|delete)\s+"(\/[^"]*)"/m + @forward ~r/^\s*forward\s+"(\/[^"]*)",\s*to:\s*([\w.]+)/m + + defp routes(file, prefix \\ "") do + source = File.read!(file) + + own = + for [_, verb, path] <- Regex.scan(@route, source), + do: {String.upcase(verb), prefix <> path} + + forwarded = + for [_, path, "Kotiko." <> name] <- Regex.scan(@forward, source) do + routes( + Path.join(@root, "server/lib/kotiko/#{Macro.underscore(name)}.ex"), + prefix <> path + ) + end + + own ++ List.flatten(forwarded) + end + + test "answer a signed request, sign their answer, and refuse it unsigned" do + w = word_fixture() + all = routes(Path.join(@root, "server/lib/kotiko/router.ex")) + assert {"PATCH", "/api/v1/words/:id"} in all + assert {"DELETE", "/api/words/:id"} in all + + for {method, path} <- all, path not in ["/health", "/api/v1/proof"] do + target = + path + |> String.replace( + ":id", + if(String.starts_with?(path, "/api/v1"), do: w.uuid, else: "#{w.id}") + ) + + body = if method in ~w(POST PUT PATCH DELETE), do: body_for(method, path) + + {conn, nonce} = signed(method, target, body) + refute conn.status == 401, "#{method} #{path} refused a signed request" + assert server_header(conn, nonce), "#{method} #{path} didn't sign its answer" + + unsigned = request(method, target, [], if(body, do: [body: body], else: [])) + assert unsigned.status == 401, "#{method} #{path} answered without auth" + end + end + + defp body_for("PUT", "/api/v1/profile"), do: %{base_langs: ["en"]} + defp body_for("PATCH", _), do: %{note: "signed"} + defp body_for("DELETE", "/api/v1/words"), do: %{confirm: "nope"} + defp body_for("POST", "/api/v1/jobs/pronunciation-refresh"), do: %{action: "pause"} + defp body_for("POST", "/api/v1/words/batch"), do: %{words: []} + + defp body_for("POST", "/api/v1/words"), + do: %{word: %{lang: "ru", native: "кот", base_lang: "en", gloss: "cat"}} + + defp body_for("POST", "/api/words"), do: %{} + defp body_for(_, _), do: %{} + end +end From f2e87d82915799ee574f9b39f935ca3a0b7fed42 Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Wed, 7 Oct 2026 12:30:20 -0400 Subject: [PATCH 3/7] fix(extension): never send the server's token; sign each request (D-01) Slice 54, finding D-01 (reviewer D, rc.2). With the proof cached for the worker's life, a program that took the server's port after a crash or a restart got the real token with the next sync (the lead reproduced three requests carrying it) and, with an add, the learner's word. The extension no longer sends the token anywhere. lib/server-auth.js signs every request to the server with it (WebCrypto HMAC-SHA256 over the method, the path and query as the server sees them, the time, 16 random bytes and the body's SHA-256) and checks the server's X-Kotiko-Server signature on every answer before reading it. An answer without it, or with a wrong one, is never used: the server reads as not_kotiko_server, the proof is forgotten, and nothing more is sent until a new proof succeeds, which a squatter can't give. Anything but a GET (adds, edits, imports, deletes, the profile) waits for a proof under 30 s old. The proof is also forgotten on server.connect, an address or token change and any connection error. A server from before signed requests answers them 401 with a plain Bearer challenge, and the extension asks to update it; today's server refusing a time more than two minutes off says so ("clock"). Regression tests that fail without this: loopback.spec.mjs replays D-01 in Chromium (prove and sync, stop the server, a listener takes 127.0.0.1 and ::1 at its port, sync and add a word): before, the listener logged "GET /kotiko/api/words auth=Bearer " and the word's POST with the token; now only the signed sync and a proof it couldn't give. Background tests cover signing, the 30 s proof, forgetting the proof, and answers that can't be trusted; unit tests check the documented vectors against a separate node:crypto implementation. The test servers sign their answers. The privacy policy (version 4) and inventory say the token isn't sent. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Ayla Croft --- docs/privacy/en.md | 10 +- docs/privacy/inventory.md | 4 +- extension/_locales/en/messages.json | 4 + extension/_locales/es/messages.json | 4 + extension/background.js | 102 +++++++++------ extension/lib/errors.js | 2 +- extension/lib/server-auth.js | 89 +++++++++++++ extension/manifest.json | 2 +- extension/privacy/en.md | 10 +- test/bg/background.test.mjs | 186 ++++++++++++++++++++++++---- test/bg/local-mode.test.mjs | 2 +- test/bg/privacy.test.mjs | 10 +- test/e2e/local.spec.mjs | 6 +- test/e2e/loopback.spec.mjs | 76 ++++++++++++ test/e2e/smoke.spec.mjs | 3 +- test/e2e/sync.spec.mjs | 8 +- test/helpers/fixture-server.mjs | 47 +++++-- test/helpers/kotiko-auth.mjs | 40 ++++++ test/unit/errors.test.mjs | 6 +- test/unit/fixtures.test.mjs | 2 +- test/unit/server-auth.test.mjs | 84 +++++++++++++ 21 files changed, 611 insertions(+), 86 deletions(-) create mode 100644 extension/lib/server-auth.js create mode 100644 test/helpers/kotiko-auth.mjs create mode 100644 test/unit/server-auth.test.mjs diff --git a/docs/privacy/en.md b/docs/privacy/en.md index f298641..ee2fd18 100644 --- a/docs/privacy/en.md +++ b/docs/privacy/en.md @@ -1,6 +1,6 @@ # Kotiko privacy policy -Version 3 · 6 October 2026 +Version 4 · 7 October 2026 Kotiko is a browser extension that swaps words on the web pages you read for words you're learning in other languages. This page says exactly what it keeps, what it sends, and to @@ -87,8 +87,10 @@ and once in the background for words saved earlier. Wikimedia's privacy policy: . **Your key and your server's access key** are kept in a part of the extension's storage -that web pages and Kotiko's own page scripts can't read. Each is sent only to the service -it belongs to, at the address you chose in Kotiko's settings. +that web pages and Kotiko's own page scripts can't read. Your key is sent only to the +service it belongs to, at the address you chose in Kotiko's settings. Your server's access +key is never sent at all: Kotiko signs each request to your server with it, and uses an +answer only if the server signed it with the same key. **Listening to a word.** Kotiko uses the voices on your computer. If you turn on "Allow online voices" in Settings (it's off by default), your browser may send the word you play @@ -181,6 +183,8 @@ everything in Kotiko's Settings. ## Changelog +- **Version 4, 7 October 2026.** Your server's access key no longer leaves your browser: + Kotiko signs each request to your server with it instead of sending it. - **Version 3, 6 October 2026.** "What websites can see" now says what a site can learn from the words Kotiko swaps: Kotiko swaps only text on your screen, at most 500 different words in one visit to a page, and adds nothing where it's paused or off. diff --git a/docs/privacy/inventory.md b/docs/privacy/inventory.md index 7fa320c..c9f467e 100644 --- a/docs/privacy/inventory.md +++ b/docs/privacy/inventory.md @@ -22,7 +22,7 @@ Last checked against the code: 2026-10-06 (extension 0.2.0, spec 2.0.0). | Text you type or paste to add words (the popup, the dashboard, the welcome page, bulk add), plus your base languages, up to five of your recent target languages and a language hint | Add jobs in IndexedDB (`meta`), with a copy in `storage.local` for the popup (kept 7 days after they finish, at most 20); the checked result in a lookup cache in IndexedDB (30 days, at most 5,000) | Yes, when you add a word that needs a lookup. Never for "word = meaning" lines | The provider you chose (OpenRouter by default; OpenAI, Anthropic, Google Gemini, Groq, a local Ollama or LM Studio, or any address you enter), or your Kotiko server, which asks its own provider | `lib/llm/client.js` `call()`, `background.js` `lookupJob()` | | Your saved words (word, meaning, sense, base language), to write their pronunciations | Nothing new | Yes, for words saved without a pronunciation (added by hand, imported, or from an older version), at most twice per word, in the background, when words live in this browser and a provider is set up | The provider you chose | `lib/refresh-job.js` → `client.respell()` | | A word you add in a language with word stress (Russian, Ukrainian, Spanish and others in `spec/pronunciation.json`): the word only | The Wiktionary page's pronunciation section, 30 days, in the lookup cache | Yes, when you add the word with a lookup or as "word = meaning", and once in the background for words saved earlier | The Wikimedia Foundation (en.wiktionary.org), with a `Api-User-Agent` header naming Kotiko and `hello@scriptkittyos.com` | `background.js` `wiktionaryPage()`, `lib/wiktionary-pass.js` | -| Your provider key and your server's access token | IndexedDB (`secrets`), which only the background reads. Never in `storage.local`, `storage.sync` or `storage.session`; never shown again in full (the settings show the first six and last four characters) | Only to the service it belongs to, and only at an address chosen on one of Kotiko's own pages (slice 28 §7) | Your provider; your Kotiko server | `background.js` `secret()`, `secretFor()`, `routeAllows()` | +| Your provider key and your server's access token | IndexedDB (`secrets`), which only the background reads. Never in `storage.local`, `storage.sync` or `storage.session`; never shown again in full (the settings show the first six and last four characters) | The provider key: only to the service it belongs to, and only at an address chosen on one of Kotiko's own pages (slice 28 §7). The server's token: never; each request to the server is signed with it (`Authorization: Kotiko-HMAC v1 ts, nonce, body hash, mac`) and the server's answers must carry its signature (slice 54, D-01) | Your provider; nobody (your server gets signatures made with the token) | `background.js` `secret()`, `secretFor()`, `routeAllows()`, `request()`; `lib/server-auth.js` | | Which provider, model and server address you use; whether a key is saved (yes or no, never the key) | IndexedDB (`meta`), with a copy in `storage.local` that pages read | Each request goes to that address | Your provider or your server | `lib/local-mode.js` `readSettings()` | | The model list (any provider whose models Kotiko lists) and your remaining free lookups (OpenRouter only) | IndexedDB `meta`; the list refreshed at most daily, the count at most every 5 minutes while lookups run | The request itself, with your key | Your provider (`/models`); OpenRouter (`/key`) | `lib/llm/client.js`, `lib/llm/catalog.js` | | The Test button's lookup | Nothing | The word "hello" and your base languages | Your provider | `lib/llm/client.js` `test()` | @@ -156,7 +156,7 @@ differs from this table. | File | Count | What they fetch | |---|---|---| -| `extension/background.js` | 8 | `importScripts` of Kotiko's own library files; Kotiko's own `stopwords.json` and `sensitive-sites.json` (from the package, no network); the server (`request()`), and before the token goes there, its proof that it holds the same token (`askProof()`: a random value out, a keyed hash back, no token); the provider client's `fetch`; Wiktionary (`wiktionaryPage()`); OpenRouter's key exchange for the sign-in | +| `extension/background.js` | 8 | `importScripts` of Kotiko's own library files; Kotiko's own `stopwords.json` and `sensitive-sites.json` (from the package, no network); the server (`request()`, each request signed with the token and each answer's signature checked, `lib/server-auth.js`; the token itself is never sent), and before the first request and before any write whose last proof is over 30 s old, its proof that it holds the same token (`askProof()`: a random value out, a keyed hash back, no token); the provider client's `fetch`; Wiktionary (`wiktionaryPage()`); OpenRouter's key exchange for the sign-in | | `extension/lib/llm/client.js` | 1 | The provider: `/chat/completions`, `/models`, `/key` (OpenRouter) | | `extension/lib/pkce.js` | 1 | OpenRouter's `/api/v1/auth/keys` (the sign-in's code exchange) | | `extension/lib/i18n.js` | 1 | Kotiko's own `_locales/*/messages.json` (from the package) | diff --git a/extension/_locales/en/messages.json b/extension/_locales/en/messages.json index 10bfcd8..f380cd2 100644 --- a/extension/_locales/en/messages.json +++ b/extension/_locales/en/messages.json @@ -458,6 +458,10 @@ "message": "Your Kotiko server didn't accept the access key. Paste it again in Connection settings.", "description": "The server answered that the access key is wrong." }, + "error_server_key_rejected_stale": { + "message": "Your Kotiko server turned Kotiko away because this device's clock and the server's are more than two minutes apart. Set both to the right time.", + "description": "Slice 25 error (code server_key_rejected, reason stale; slice 54 D-01): each request is signed with the time, and the server refuses one more than two minutes off its own clock." + }, "error_server_address_invalid": { "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." diff --git a/extension/_locales/es/messages.json b/extension/_locales/es/messages.json index fcef1cb..8820de7 100644 --- a/extension/_locales/es/messages.json +++ b/extension/_locales/es/messages.json @@ -458,6 +458,10 @@ "message": "Tu servidor de Kotiko no aceptó la clave de acceso. Pégala de nuevo en los ajustes de conexión.", "description": "The server answered that the access key is wrong." }, + "error_server_key_rejected_stale": { + "message": "Tu servidor de Kotiko rechazó a Kotiko porque el reloj de este dispositivo y el del servidor se diferencian en más de dos minutos. Pon los dos en la hora correcta.", + "description": "Slice 25 error (code server_key_rejected, reason stale; slice 54 D-01): each request is signed with the time, and the server refuses one more than two minutes off its own clock." + }, "error_server_address_invalid": { "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." diff --git a/extension/background.js b/extension/background.js index e48438c..bfdb938 100644 --- a/extension/background.js +++ b/extension/background.js @@ -15,7 +15,7 @@ // manifest's background.scripts list (before this file) in Firefox's event page. if (!globalThis.SyncController && typeof importScripts === "function") { importScripts( - "lib/url.js", "lib/errors.js", "lib/validate-words.js", "lib/sync-controller.js", "lib/messages.js", "lib/i18n.js", "lib/badge.js", + "lib/url.js", "lib/server-auth.js", "lib/errors.js", "lib/validate-words.js", "lib/sync-controller.js", "lib/messages.js", "lib/i18n.js", "lib/badge.js", "spec/spec.js", "lib/text.js", "lib/lang.js", "lib/words-v1.js", "lib/wordspec.js", "lib/word-merge.js", "lib/store.js", "lib/settings.js", "lib/backup.js", "lib/projection.js", "lib/llm/policy.js", "lib/llm/catalog.js", "lib/llm/client.js", "lib/add-queue.js", "lib/refresh-job.js", "lib/local-mode.js", "lib/pkce.js", "lib/celebrations.js", "lib/welcome-model.js", @@ -25,6 +25,7 @@ if (!globalThis.SyncController && typeof importScripts === "function") { const ext = globalThis.browser ?? globalThis.chrome; const { normalizeServerUrl } = globalThis.ServerUrl; +const ServerAuth = globalThis.KotikoServerAuth; const { fromStatus } = globalThis.KotikoErrors; const { validateWordsResponse, filterWords } = globalThis.WordValidator; const { createSyncController } = globalThis.SyncController; @@ -127,6 +128,7 @@ async function setSecret(id, value) { if (value) await store.secrets.set(id, value); else await store.secrets.remove(id); secretCache.set(id, value || null); + if (id === "server") forgetProof(); const s = await Local.readSettings(area); const keys = { server: s.keys.server, providers: { ...s.keys.providers } }; if (id === "server") keys.server = !!value; @@ -343,6 +345,7 @@ async function lookupAllowed(providerId, baseUrl) { const RAW = "raw:"; async function bindRoute(route, url) { const value = routeUrl(route, url) ?? (url ? `${RAW}${String(url).trim()}` : null); + if (route === "server") forgetProof(); await (await getStore()).meta.set(routeKey(route), value); } const sameAddress = (route, url, trusted) => (routeUrl(route, url) ?? `${RAW}${String(url ?? "").trim()}`) === trusted; @@ -474,7 +477,7 @@ async function connection() { if (!stored) throw codedError("server_key_rejected", "Paste your API token to connect.", { reason: "no_token" }); const n = normalizeServerUrl((await Local.readSettings(area)).server.url); if (!n.ok) throw codedError(n.code, n.hint, { hint: n.hint }); - // Only the address a Kotiko page named gets the token (slice 28 §7). + // Only the address a Kotiko page named gets requests signed with 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). @@ -482,14 +485,20 @@ async function connection() { 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). +// Slice 54, B-01 and D-01. The token never leaves this browser: every request to the +// server is signed with it (lib/server-auth.js), and every answer must carry the server's +// signature, made with the same token, before Kotiko reads it. Before the first request to +// an address, and again whenever its proof is older than 30 s before a request that carries +// the learner's words or settings (anything but a GET), the server proves it holds the +// token without either side sending it: Kotiko sends a fresh random nonce to +// `POST /api/v1/proof`, the server answers base64url(HMAC-SHA256(token, "kotiko-proof-v1:" +// + nonce)), and Kotiko checks it with WebCrypto (`subtle.verify`, which compares in +// constant time). So another program listening at the address (on [::1], or on 127.0.0.1 +// while Kotiko's server is stopped) gets no token, can't answer for the server, and gets a +// word only if it took the port within 30 s of the last proof and before any other request +// found it out. The proof is forgotten on `server.connect`, when the address or token +// changes, when the server can't be reached, and after any answer the server didn't sign; +// nothing more is sent until a new proof succeeds. // - 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 @@ -497,33 +506,25 @@ async function connection() { // 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 +// How old a proof may be before a request that carries the learner's data. +const PROOF_FRESH_MS = 30_000; +let proven = null; // {base, token, at} 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 forgetProof() { + proven = null; + proving = null; } -function proveServer(base, token) { - if (proven?.base === base && proven.token === token) return Promise.resolve(); +// Resolves once `base` has proved it holds `token`, within `maxAge` ms when given. +function proveServer(base, token, maxAge = Infinity) { + if (proven?.base === base && proven.token === token && now() - proven.at < maxAge) return Promise.resolve(); if (proving?.base === base && proving.token === token) return proving.promise; const promise = askProof(base, token) .then(() => { - proven = { base, token }; + if (proving?.promise === promise) proven = { base, token, at: now() }; }) .finally(() => { if (proving?.promise === promise) proving = null; @@ -548,7 +549,7 @@ async function askProof(base, token) { 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 }); + const noProof = (status) => codedError("not_kotiko_server", `${base} didn't prove it holds this token, so nothing was 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) { @@ -561,22 +562,45 @@ async function askProof(base, token) { 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)))) { + if (!(await crypto.subtle.verify("HMAC", key, ServerAuth.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. +// An answer the server didn't sign: nothing in it is used. A 401 from a server from before +// signed requests (its challenge is `Bearer` alone) means it needs an update ("no_proof"); +// today's server names `Kotiko-HMAC` and why it refused (`stale`: the clocks differ by +// over two minutes). Neither is authenticated, so they only choose the message. Anything +// else (a captive portal, another program) isn't a Kotiko server ("unsigned"). +function unsignedAnswer(base, res) { + const c = ServerAuth.challenge(res.headers.get("www-authenticate")); + if (res.status === 401 && c.signs) return codedError("server_key_rejected", `${base} refused the request's signature.`, { reason: c.reason ?? "signature", status: 401 }); + if (res.status === 401) return codedError("not_kotiko_server", `${base} can't check signed requests.`, { reason: "no_proof", status: 401 }); + if (res.status === 429) return codedError("rate_limited", `${base} answered 429.`, { status: 429 }); + return codedError("not_kotiko_server", `${base} didn't sign its answer, so it wasn't used.`, { reason: "unsigned", status: res.status }); +} + +// Calls the server, signed, and returns its answer once the server's signature on it is +// checked. Network failures and unsigned answers become coded errors. async function request(conn, path, init = {}) { + const method = String(init.method ?? "GET").toUpperCase(); + // The learner's words and settings go only to a server that proved itself just now. + if (method !== "GET") await proveServer(conn.base, conn.token, PROOF_FRESH_MS); + const url = `${conn.base}${path}`; + const body = typeof init.body === "string" ? init.body : ""; + const { header, nonce } = await ServerAuth.sign(conn.token, { method, path: ServerAuth.target(conn.base, url), body, ts: Math.floor(now() / 1000) }); + let res; try { - return await fetch(`${conn.base}${path}`, { + res = await fetch(url, { ...init, - headers: { Authorization: `Bearer ${conn.token}`, ...init.headers }, + method, + headers: { ...init.headers, Authorization: header }, cache: "no-store", + credentials: "omit", }); } 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 !== "AbortError") forgetProof(); if (e?.name === "TimeoutError") { throw codedError("server_unreachable", `${conn.base} took too long to answer.`, { reason: "timeout" }); } @@ -585,6 +609,12 @@ async function request(conn, path, init = {}) { reason: "network", }); } + if (!(await ServerAuth.verifyResponse(conn.token, nonce, res.status, res.headers.get("x-kotiko-server")))) { + forgetProof(); + res.body?.cancel?.().catch?.(() => {}); + throw unsignedAnswer(conn.base, res); + } + return res; } // For add and remove: the parsed body, or an error. A failed lookup carries slice 25's code @@ -1823,6 +1853,8 @@ ext.runtime.onMessage.addListener( check: (m) => (m.url !== undefined && (typeof m.url !== "string" || m.url.length > 500) ? "url must be a string" : m.token !== undefined && (typeof m.token !== "string" || m.token.length > MAX_SECRET) ? "token must be a string" : null), async run(m) { await ready(); + // Whatever was proved before, this connection proves itself again. + forgetProof(); 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. diff --git a/extension/lib/errors.js b/extension/lib/errors.js index 4949b82..6d4cd12 100644 --- a/extension/lib/errors.js +++ b/extension/lib/errors.js @@ -122,7 +122,7 @@ case "server_key_rejected": // No access key saved yet: not an error on a banner; an add says to connect. if (d.reason === "no_token") return ctx.surface === "banner" ? null : { key: "error_add_not_connected" }; - return { key: "error_server_key_rejected" }; + return { key: d.reason === "stale" ? "error_server_key_rejected_stale" : "error_server_key_rejected" }; case "server_address_invalid": return { key: d.reason === "host_not_allowed" ? "error_server_address_invalid_host_not_allowed" : "error_server_address_invalid" }; case "not_kotiko_server": diff --git a/extension/lib/server-auth.js b/extension/lib/server-auth.js new file mode 100644 index 0000000..18a9b6a --- /dev/null +++ b/extension/lib/server-auth.js @@ -0,0 +1,89 @@ +// SPDX-FileCopyrightText: 2026 ScriptKittyOS and the Kotiko contributors +// SPDX-License-Identifier: Apache-2.0 + +// Signed requests to a Kotiko server (slice 54, D-01). Kotiko never sends the server's +// token: it signs each request with it, and checks the server's signature on each answer, +// so a program that took the server's port while the server was stopped gets neither the +// token nor a request it could play to the server later, and is found out at its first +// answer. The server's side is server/lib/kotiko/request_auth.ex; the scheme and its test +// vectors are in docs/reference/http-api.md ("Signed requests"). +// +// Authorization: Kotiko-HMAC v1 ts=, nonce=, body=, mac= +// mac = base64url(HMAC-SHA256(token, "kotiko-req-v1\n" + METHOD + "\n" + path + "\n" + ts +// + "\n" + nonce + "\n" + body)) +// X-Kotiko-Server: v1 mac= +// +// `path` is the request target from the server's root: the path and query as sent, without +// the prefix of an address that has one (a reverse proxy that mounts the server under +// /kotiko hands it /api/words). `body` is the hex SHA-256 of the body's UTF-8 bytes. +(function () { + const REQUEST = "kotiko-req-v1"; + const RESPONSE = "kotiko-resp-v1"; + const NONCE_BYTES = 16; + const RESPONSE_HEADER = /^v1 mac=([A-Za-z0-9_-]{43})$/; + const utf8 = (text) => new TextEncoder().encode(text); + const hex = (buf) => Array.from(new Uint8Array(buf), (b) => b.toString(16).padStart(2, "0")).join(""); + const b64url = (bytes) => btoa(String.fromCharCode(...new Uint8Array(bytes))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); + const B64URL = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + + // Bytes from base64url already checked to be 43 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); + } + + // The request target the server sees, for `url` on the server at `base`. + function target(base, url) { + const u = new URL(url); + const prefix = new URL(base).pathname.replace(/\/+$/, ""); + const path = prefix && u.pathname.startsWith(prefix) ? u.pathname.slice(prefix.length) || "/" : u.pathname; + return path + u.search; + } + + const canonical = ({ method, path, ts, nonce, bodyHash }) => [REQUEST, method, path, String(ts), nonce, bodyHash].join("\n"); + + const hmacKey = (subtle, token, usage) => subtle.importKey("raw", utf8(token), { name: "HMAC", hash: "SHA-256" }, false, [usage]); + + // The Authorization value for one request, and its nonce (to check the answer with). + // `ts` and `nonce` are for tests; a request gets the time and 16 random bytes. + async function sign(token, { method, path, body = "", ts, nonce }, crypto = globalThis.crypto) { + const n = nonce ?? b64url(crypto.getRandomValues(new Uint8Array(NONCE_BYTES))); + const t = String(ts ?? Math.floor(Date.now() / 1000)); + const bodyHash = hex(await crypto.subtle.digest("SHA-256", utf8(body))); + const key = await hmacKey(crypto.subtle, token, "sign"); + const mac = b64url(await crypto.subtle.sign("HMAC", key, utf8(canonical({ method: method.toUpperCase(), path, ts: t, nonce: n, bodyHash })))); + return { header: `Kotiko-HMAC v1 ts=${t}, nonce=${n}, body=${bodyHash}, mac=${mac}`, nonce: n }; + } + + // Whether `header` (X-Kotiko-Server) is the server's signature of an answer with `status` + // to the request `nonce`. subtle.verify compares in constant time. + async function verifyResponse(token, nonce, status, header, crypto = globalThis.crypto) { + const m = RESPONSE_HEADER.exec(String(header ?? "")); + if (!m) return false; + const key = await hmacKey(crypto.subtle, token, "verify"); + return crypto.subtle.verify("HMAC", key, fromB64url(m[1]), utf8([RESPONSE, nonce, String(status)].join("\n"))); + } + + // What a signed request's 401 says, when its answer isn't signed: a server from before + // signed requests challenges with `Bearer` alone; today's names `Kotiko-HMAC` and why + // (`stale`, `bad_mac`, ...). Neither is authenticated: it only picks the message. + function challenge(header) { + const value = String(header ?? ""); + if (!/(^|[\s,])Kotiko-HMAC(\s|,|$)/.test(value)) return { signs: false, reason: null }; + return { signs: true, reason: /Kotiko-HMAC error="([a-z_]{1,32})"/.exec(value)?.[1] ?? null }; + } + + const api = { canonical, target, sign, verifyResponse, challenge, fromB64url }; + globalThis.KotikoServerAuth = api; + if (typeof module === "object" && module.exports) module.exports = api; +})(); diff --git a/extension/manifest.json b/extension/manifest.json index 2ddb940..c388368 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -10,7 +10,7 @@ "background": { "service_worker": "background.js", "scripts": [ - "lib/url.js", "lib/errors.js", "lib/validate-words.js", "lib/sync-controller.js", "lib/messages.js", "lib/i18n.js", "lib/badge.js", + "lib/url.js", "lib/server-auth.js", "lib/errors.js", "lib/validate-words.js", "lib/sync-controller.js", "lib/messages.js", "lib/i18n.js", "lib/badge.js", "spec/spec.js", "lib/text.js", "lib/lang.js", "lib/words-v1.js", "lib/wordspec.js", "lib/word-merge.js", "lib/store.js", "lib/settings.js", "lib/backup.js", "lib/projection.js", "lib/llm/policy.js", "lib/llm/catalog.js", "lib/llm/client.js", "lib/add-queue.js", "lib/refresh-job.js", "lib/local-mode.js", "lib/pkce.js", "lib/celebrations.js", "lib/welcome-model.js", diff --git a/extension/privacy/en.md b/extension/privacy/en.md index f298641..ee2fd18 100644 --- a/extension/privacy/en.md +++ b/extension/privacy/en.md @@ -1,6 +1,6 @@ # Kotiko privacy policy -Version 3 · 6 October 2026 +Version 4 · 7 October 2026 Kotiko is a browser extension that swaps words on the web pages you read for words you're learning in other languages. This page says exactly what it keeps, what it sends, and to @@ -87,8 +87,10 @@ and once in the background for words saved earlier. Wikimedia's privacy policy: . **Your key and your server's access key** are kept in a part of the extension's storage -that web pages and Kotiko's own page scripts can't read. Each is sent only to the service -it belongs to, at the address you chose in Kotiko's settings. +that web pages and Kotiko's own page scripts can't read. Your key is sent only to the +service it belongs to, at the address you chose in Kotiko's settings. Your server's access +key is never sent at all: Kotiko signs each request to your server with it, and uses an +answer only if the server signed it with the same key. **Listening to a word.** Kotiko uses the voices on your computer. If you turn on "Allow online voices" in Settings (it's off by default), your browser may send the word you play @@ -181,6 +183,8 @@ everything in Kotiko's Settings. ## Changelog +- **Version 4, 7 October 2026.** Your server's access key no longer leaves your browser: + Kotiko signs each request to your server with it instead of sending it. - **Version 3, 6 October 2026.** "What websites can see" now says what a site can learn from the words Kotiko swaps: Kotiko swaps only text on your screen, at most 500 different words in one visit to a page, and adds nothing where it's paused or off. diff --git a/test/bg/background.test.mjs b/test/bg/background.test.mjs index 65be9f3..de15420 100644 --- a/test/bg/background.test.mjs +++ b/test/bg/background.test.mjs @@ -7,6 +7,7 @@ import { after, before, describe, test } from "node:test"; import assert from "node:assert/strict"; import { createFakeChrome } from "../helpers/fake-chrome.mjs"; import { proofFor, startFixtureServer } from "../helpers/fixture-server.mjs"; +import { serverSignature, verifySigned } from "../helpers/kotiko-auth.mjs"; import { webcrypto } from "node:crypto"; import { manifest, readExt, runInVm, sleep } from "../helpers/load-script.mjs"; @@ -18,28 +19,47 @@ const WORDS = [ const json = (status, body) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }); +// Slice 54, D-01: Kotiko never sends the token; it signs each request with it. Whether a +// recorded request was signed with `token` (and doesn't carry it). +const targetOf = (url) => { + const u = new URL(url); + return u.pathname + u.search; +}; +const signedWith = (r, token) => + verifySigned(token, { method: r.method ?? "GET", target: targetOf(r.url), body: r.body ?? "", authorization: r.headers?.Authorization }).ok && !JSON.stringify(r).includes(token); +// A server's answer to a request signed with `token`, signed back as the real server does. +// A request that isn't signed with it gets the server's 401. +async function signedAnswer(token, url, init, answer) { + const v = verifySigned(token, { method: init.method ?? "GET", target: targetOf(url), body: init.body ?? "", authorization: init.headers?.Authorization }); + if (!v.ok) return new Response(JSON.stringify({ error: { code: "server_key_rejected" } }), { status: 401, headers: { "www-authenticate": `Bearer, Kotiko-HMAC error="${v.reason}"` } }); + const res = await answer(); + res.headers.set("x-kotiko-server", serverSignature(token, v.nonce, res.status)); + return res; +} + // A fetch stub that records requests and answers with `handler(url, init)`. The profile // the background sends after a sync (slice 41 §9) is recorded apart, in `profile`, and // answered by `onProfile` (an echo by default), so the sync tests count only their own. -// The proof a Kotiko server gives before the token is sent (slice 54, B-01) is answered here +// The proof a Kotiko server gives before it gets requests (slice 54, B-01) is answered here // too, for `token` (the harness's, or a function for a test that changes it), and recorded in -// `proofs`. +// `proofs`; every answer is signed with that token, as the real server signs (D-01). function stubFetch(handler, { onProfile = (init) => json(200, JSON.parse(init.body)), token = "good-token" } = {}) { const requests = []; const profile = []; const proofs = []; + const held = () => (typeof token === "function" ? token() : token); const fetch = async (url, init = {}) => { - const r = { url: String(url), method: init.method ?? "GET", headers: { ...init.headers }, body: init.body, cache: init.cache }; + const r = { url: String(url), method: init.method ?? "GET", headers: { ...init.headers }, body: init.body, cache: init.cache, credentials: init.credentials }; if (r.url.endsWith("/api/v1/proof")) { proofs.push(r); - return json(200, { proof: proofFor(typeof token === "function" ? token() : token, JSON.parse(init.body).nonce) }); + return json(200, { proof: proofFor(held(), JSON.parse(init.body).nonce) }); } if (r.url.endsWith("/api/v1/profile")) { profile.push(r); - return onProfile(init); + return signedAnswer(held(), url, init, () => onProfile(init)); } requests.push(r); - return handler(String(url), init); + return signedAnswer(held(), url, init, () => handler(String(url), init)); }; return { fetch, requests, profile, proofs }; } @@ -124,7 +144,7 @@ describe("sync", () => { assert.equal(store.lastSync, fake.clock.now()); assert.equal(store.syncError, null); assert.deepEqual(requests.map((r) => [r.method, r.url]), [["GET", "http://127.0.0.1:4999/api/words"]]); - assert.equal(requests[0].headers.Authorization, "Bearer good-token"); + assert.ok(signedWith(requests[0], "good-token")); assert.equal(requests[0].cache, "no-store"); }); @@ -133,7 +153,7 @@ describe("sync", () => { const { send } = loadBackground({ fetch, local: { serverUrl: " http://127.0.0.1:4999// ", token: " tok \n" } }); await send({ type: "sync", force: true }, POPUP); assert.equal(requests[0].url, "http://127.0.0.1:4999/api/words"); - assert.equal(requests[0].headers.Authorization, "Bearer tok"); + assert.ok(signedWith(requests[0], "tok")); }); test("doesn't rewrite unchanged words, so open tabs don't redo work", async () => { @@ -265,7 +285,7 @@ describe("the learner's languages on the server (slice 41 §9)", () => { await send({ type: "sync", force: true }, POPUP); await settle(fake); assert.deepEqual(sentProfiles(profile), [["PUT", "http://127.0.0.1:4999/api/v1/profile", { base_langs: ["es", "en"], ui_lang: null }]]); - assert.equal(profile[0].headers.Authorization, "Bearer good-token"); + assert.ok(signedWith(profile[0], "good-token")); // Nothing changed: the next sync sends nothing. fake.clock.advance(60_000); @@ -383,7 +403,7 @@ describe("triggers", () => { await send({ type: "server.connect", token: "new-token" }, POPUP); await requested(fake, requests, 1); assert.equal(requests.length, 1); - assert.equal(requests[0].headers.Authorization, "Bearer new-token"); + assert.ok(signedWith(requests[0], "new-token")); }); test("ignores messages it doesn't know", async () => { @@ -417,7 +437,7 @@ describe("add and remove relay", () => { assert.deepEqual(posts[0], ["/api/v1/words", { text: "dog in japanese", preview: true, base_langs: ["en"] }]); assert.equal(posts[1][0], "/api/v1/words/batch"); assert.equal(posts[1][1].client_request_id, UUID, "the job's id is the idempotency key"); - assert.equal(requests[0].headers.Authorization, "Bearer good-token"); + assert.ok(signedWith(requests[0], "good-token")); }); test("a lookup that fails reads as the job's state: a 502 fails with its status, a used-up quota waits for its retry time", async () => { @@ -494,7 +514,7 @@ describe("add and remove relay", () => { const res = await send({ type: "llmStatus" }, POPUP); assert.equal(res.quota.remaining, 38); assert.deepEqual(store.lookupStatus, { provider: "openrouter", quota, at: fake.clock.now() }); - assert.equal(requests.at(-1).headers.Authorization, "Bearer good-token"); + assert.ok(signedWith(requests.at(-1), "good-token")); await send({ type: "add", text: "x" }, POPUP); await sleep(20); @@ -704,7 +724,8 @@ describe("sync correctness (slice 26)", () => { // The profile sent after connecting (slice 41 §9) isn't the sync this test watches. if (String(url).endsWith("/api/v1/profile")) return Promise.resolve(json(200, {})); if (String(url).endsWith("/api/v1/proof")) return Promise.resolve(json(200, { proof: proofFor(serverToken, JSON.parse(init.body).nonce) })); - const token = init.headers.Authorization.slice("Bearer ".length); + // Which token signed it (slice 54, D-01: the token itself is never sent). + const token = ["bad", "good"].find((t) => signedWith({ url: String(url), method: init.method, headers: init.headers, body: init.body }, t)); const entry = { token, aborted: false }; seen.push(entry); return new Promise((resolve, reject) => { @@ -713,7 +734,7 @@ describe("sync correctness (slice 26)", () => { reject(Object.assign(new Error("The operation was aborted."), { name: "AbortError" })); }); setTimeout( - () => resolve(token === "bad" ? new Response("unauthorized", { status: 401 }) : json(200, { words: WORDS })), + () => resolve(token === "bad" ? new Response("unauthorized", { status: 401 }) : signedAnswer(token, url, init, () => json(200, { words: WORDS }))), token === "bad" ? 100 : 5, ); }); @@ -751,7 +772,7 @@ describe("sync correctness (slice 26)", () => { await fake.idle(); await sleep(150); assert.deepEqual(requests.map((r) => r.url), ["http://127.0.0.1:4999/api/words", "http://127.0.0.1:5000/api/words"]); - assert.equal(requests[1].headers.Authorization, "Bearer good-token"); + assert.ok(signedWith(requests[1], "good-token")); assert.deepEqual(store.words, WORDS); }); @@ -766,7 +787,7 @@ describe("sync correctness (slice 26)", () => { await send({ type: "sync", force: true }, POPUP); await fake.idle(); assert.ok(requests.every((r) => !r.url.includes(":6666")), JSON.stringify(patch)); - assert.ok(requests.every((r) => r.headers.Authorization === "Bearer good-token"), JSON.stringify(patch)); + assert.ok(requests.every((r) => signedWith(r, "good-token")), JSON.stringify(patch)); // The trusted address is put back; the 0.2 keys are removed, not adopted. assert.equal(store.server.url, "http://127.0.0.1:4999"); assert.equal(store.token, undefined); @@ -777,7 +798,7 @@ describe("sync correctness (slice 26)", () => { await send({ type: "sync", force: true }, POPUP); await fake.idle(); assert.equal(requests.at(-1).url, "http://127.0.0.1:6666/api/words"); - assert.equal(requests.at(-1).headers.Authorization, "Bearer good-token"); + assert.ok(signedWith(requests.at(-1), "good-token")); }); test("F31: the fixture server's captive portal is not a word list", async () => { @@ -1083,7 +1104,7 @@ describe("toolbar badge (slice 20 §5)", () => { describe("the server proves it holds the token before it gets it", () => { const withToken = (requests) => requests.filter((r) => r.headers?.Authorization); - test("the server's own test vector (fix/sec-server): the token is sent only after it matches", async () => { + test("the server's own test vector (fix/sec-server): requests are signed only after it matches", async () => { const TOKEN = "example-token-0123456789abcdef"; const NONCE = "q1aP3n0ZKcB1x5mW0u7S9bJ2rV4yT6dE8gH0iL2nO4p"; const PROOF = "E5CHaF60VqTTPPduGD1yAlfWMF5xf9_hmV2qCufV-ek"; @@ -1097,9 +1118,9 @@ describe("the server proves it holds the token before it gets it", () => { proofs.push({ headers: { ...init.headers }, body, credentials: init.credentials }); return json(200, { proof: proofFor(TOKEN, body.nonce) }); } - if (String(url).endsWith("/api/v1/profile")) return json(200, {}); - requests.push({ url: String(url), headers: { ...init.headers } }); - return json(200, { words: WORDS }); + if (String(url).endsWith("/api/v1/profile")) return signedAnswer(TOKEN, url, init, () => json(200, {})); + requests.push({ url: String(url), method: init.method, headers: { ...init.headers } }); + return signedAnswer(TOKEN, url, init, () => json(200, { words: WORDS })); }; // The vector's 32 random bytes, from the worker's random source. (The vector's nonce has // its last character's two spare bits set; Kotiko writes the same bytes as "…O4o".) @@ -1109,7 +1130,7 @@ describe("the server proves it holds the token before it gets it", () => { const { send, store } = loadBackground({ fetch, local: { token: TOKEN }, globals: { crypto } }); assert.deepEqual(await send({ type: "sync", force: true }, POPUP), { ok: true }); assert.deepEqual(proofs, [{ headers: { "Content-Type": "application/json" }, body: { nonce: sent }, credentials: "omit" }], "the nonce goes alone, with no token"); - assert.equal(requests[0]?.headers.Authorization, `Bearer ${TOKEN}`); + assert.ok(signedWith(requests[0], TOKEN)); assert.equal(store.syncError, null); assert.deepEqual(store.words, WORDS); }); @@ -1156,3 +1177,124 @@ describe("the server proves it holds the token before it gets it", () => { assert.equal(store.syncError, null); }); }); + +// Slice 54, D-01: a proof held for the worker's life let a program that took the port after +// the server stopped get the token with the next request. Now the token never leaves the +// browser: each request is signed with it, each answer must carry the server's signature, +// and anything that carries the learner's words or settings waits for a proof under 30 s old. +describe("signed requests: the token never leaves the browser (D-01)", () => { + const word = { lang: "ru", native: "кот", base_lang: "en", gloss: "cat" }; + const saved = { ...word, id: "01900000-0000-7000-8000-0000000c0001", forms: [{ text: "cat", enabled: true }] }; + const save = (send) => send({ type: "words.save", words: [word] }, POPUP); + const serverFor = () => + stubFetch((url, init) => { + if (url.endsWith("/api/v1/words") && init.method === "POST") return json(200, { results: [{ result: "created", word: saved }], rejected: [] }); + if (url.endsWith("/api/v1/llm/status")) return json(404, { error: { code: "not_found" } }); + return json(200, { words: WORDS }); + }); + + test("every request is signed, none carries the token, and every answer's signature is checked", async () => { + const { fetch, requests, profile, proofs } = serverFor(); + const { send, store } = loadBackground({ fetch }); + assert.deepEqual(await send({ type: "sync", force: true }, POPUP), { ok: true }); + assert.equal((await save(send)).results[0].result, "created"); + assert.deepEqual(store.words, WORDS); + const all = [...requests, ...profile, ...proofs]; + assert.ok(requests.length >= 2); + assert.deepEqual(all.filter((r) => JSON.stringify(r).includes("good-token")), [], "nothing carried the token"); + assert.ok([...requests, ...profile].every((r) => signedWith(r, "good-token") && r.credentials === "omit")); + assert.ok(all.every((r) => !/Bearer/.test(JSON.stringify(r.headers)))); + }); + + // The reviewer's replay (d-b01.mjs) without a browser: prove and sync, then the server stops + // and another program answers at its address. + test("after the server stops, a squatter gets no token, and no word once it is found out", async () => { + let squatter = false; + const heard = []; + const server = serverFor(); + const fetch = async (url, init = {}) => { + if (!squatter) return server.fetch(url, init); + heard.push({ url: String(url), method: init.method ?? "GET", headers: { ...init.headers }, body: init.body ?? null }); + // It can't know the token: a made-up proof, and unsigned answers. + if (String(url).endsWith("/api/v1/proof")) return json(200, { proof: "A".repeat(43) }); + return json(200, { words: [] }); + }; + const { send, store, fake } = loadBackground({ fetch }); + assert.deepEqual(await send({ type: "sync", force: true }, POPUP), { ok: true }); + // What follows a sync (the languages sent to the server, the lookup status) is done. + await requested(fake, server.profile, 1); + squatter = true; + await send({ type: "sync", force: true }, POPUP); + await fake.idle(); + assert.deepEqual([store.syncError?.code, store.syncError?.details?.reason], ["not_kotiko_server", "unsigned"]); + assert.deepEqual(store.words, WORDS, "its answer wasn't used"); + const saving = await save(send); + assert.equal(saving.code, "server_key_rejected"); + assert.equal(saving.details?.reason, "wrong_proof"); + assert.deepEqual(heard.filter((r) => JSON.stringify(r).includes("good-token")), [], "no request carried the token"); + assert.deepEqual(heard.filter((r) => r.body && /кот|cat/.test(r.body)), [], "the word never went out"); + assert.deepEqual(heard.map((r) => `${r.method} ${new URL(r.url).pathname}`), ["GET /api/words", "POST /api/v1/proof"], "only the signed sync, then a proof it couldn't give"); + }); + + test("a request that carries the learner's data waits for a proof under 30 s old", async () => { + const order = []; + const server = serverFor(); + const fetch = (url, init = {}) => { + order.push(`${init.method ?? "GET"} ${new URL(url).pathname}`); + return server.fetch(url, init); + }; + const { send, fake } = loadBackground({ fetch }); + await send({ type: "sync", force: true }, POPUP); + await fake.idle(); + await save(send); + assert.equal(server.proofs.length, 1, "a proof just made is fresh enough"); + fake.clock.advance(31_000); + order.length = 0; + await send({ type: "sync", force: true }, POPUP); + assert.equal(server.proofs.length, 1, "a read doesn't need a fresh proof"); + await save(send); + assert.equal(server.proofs.length, 2); + const proof = order.indexOf("POST /api/v1/proof"); + assert.ok(proof >= 0 && proof < order.indexOf("POST /api/v1/words"), order.join(", ")); + }); + + test("server.connect, and a changed token or address, forget the proof", async () => { + const { fetch, proofs } = serverFor(); + const { send } = loadBackground({ fetch }); + await send({ type: "sync", force: true }, POPUP); + assert.equal(proofs.length, 1); + await send({ type: "server.connect", url: "http://127.0.0.1:4999", token: "good-token" }, POPUP); + await send({ type: "sync", force: true }, POPUP); + assert.ok(proofs.length >= 2, "connecting again proves again"); + }); + + const unsigned = [ + ["a server from before signed requests (401, Bearer challenge)", () => new Response("unauthorized", { status: 401, headers: { "www-authenticate": "Bearer" } }), "not_kotiko_server", "no_proof"], + ["today's server refusing a stale signature", () => new Response("{}", { status: 401, headers: { "www-authenticate": 'Bearer, Kotiko-HMAC error="stale"' } }), "server_key_rejected", "stale"], + ["a 200 signed with another token", (_url, init) => { + const r = json(200, { words: [] }); + r.headers.set("x-kotiko-server", serverSignature("another-token", /nonce=([\w-]+)/.exec(init.headers.Authorization)[1], 200)); + return r; + }, "not_kotiko_server", "unsigned"], + ["a 500 with no signature", () => json(500, { error: "x" }), "not_kotiko_server", "unsigned"], + ["a captive portal's page", () => new Response("Wi-Fi", { status: 200, headers: { "content-type": "text/html" } }), "not_kotiko_server", "unsigned"], + ]; + for (const [name, answer, code, reason] of unsigned) { + test(`an answer it can't trust is never used: ${name}`, async () => { + const proofs = []; + const fetch = async (url, init = {}) => { + if (String(url).endsWith("/api/v1/proof")) { + proofs.push(url); + return json(200, { proof: proofFor("good-token", JSON.parse(init.body).nonce) }); + } + return answer(url, init); + }; + const { send, store } = loadBackground({ fetch, local: { words: WORDS } }); + await send({ type: "sync", force: true }, POPUP); + assert.deepEqual([store.syncError?.code, store.syncError?.details?.reason], [code, reason]); + assert.deepEqual(store.words, WORDS); + await send({ type: "sync", force: true }, POPUP); + assert.equal(proofs.length, 2, "the proof was forgotten: proved again before the next request"); + }); + } +}); diff --git a/test/bg/local-mode.test.mjs b/test/bg/local-mode.test.mjs index fc553f3..ae9f4e6 100644 --- a/test/bg/local-mode.test.mjs +++ b/test/bg/local-mode.test.mjs @@ -382,7 +382,7 @@ describe("upgrading an existing install (slice 11 §8)", () => { const res = await bg.send({ type: "add", text: "sobaka" }, POPUP); const job = await bg.until(() => bg.store.addJobs?.find((j) => j.id === res.job.id && j.state === "done")); assert.equal(job.results[0].word.native, "собака"); - assert.ok(srv.state.log.some((r) => r.method === "POST" && r.path === "/kotiko/api/v1/words" && r.auth === `Bearer ${srv.token}`)); + assert.ok(srv.state.log.some((r) => r.method === "POST" && r.path === "/kotiko/api/v1/words" && r.signed)); assert.ok(srv.state.log.some((r) => r.method === "POST" && r.path === "/kotiko/api/v1/words/batch")); assert.equal(srv.state.log.filter((r) => r.path.startsWith("/llm/")).length, 0, "no model asked from this browser"); await bg.until(() => bg.store.words.some((w) => w.native === "собака")); diff --git a/test/bg/privacy.test.mjs b/test/bg/privacy.test.mjs index 8ecc478..d3bcf19 100644 --- a/test/bg/privacy.test.mjs +++ b/test/bg/privacy.test.mjs @@ -251,7 +251,9 @@ describe("after slice 12's delete everything, the routes start over like a new i await bg.send({ type: "server.connect", url: srv.kotikoUrl, token: srv.token }); await bg.send({ type: "sync", force: true }); await bg.fake.idle(); - assert.ok(srv.state.log.some((r) => r.auth === `Bearer ${srv.token}`)); + // Signed with the token, which itself never goes out (slice 54, D-01). + assert.ok(srv.state.log.some((r) => r.signed)); + assert.ok(!srv.state.log.some((r) => r.auth?.includes(srv.token))); }); }); @@ -312,7 +314,8 @@ describe("localhost becomes 127.0.0.1 (slice 54, B-01)", () => { assert.equal(await store.meta.get("route:lookup:ollama"), "http://127.0.0.1:11434/v1"); sent.length = 0; assert.deepEqual(await bg.send({ type: "sync", force: true }, POPUP), { ok: true }); - await bg.until(() => sent.some((r) => r.auth === `Bearer ${srv.token}`)); + await bg.until(() => sent.some((r) => r.auth?.startsWith("Kotiko-HMAC ")) && srv.state.log.some((r) => r.signed)); + assert.ok(!sent.some((r) => r.auth?.includes(srv.token))); assert.deepEqual(sent.filter((r) => new URL(r.url).hostname === "localhost"), [], "nothing goes to localhost"); assert.equal(bg.store.syncError ?? null, null); }); @@ -331,7 +334,8 @@ describe("localhost becomes 127.0.0.1 (slice 54, B-01)", () => { assert.equal(bg.store.server.url, `http://127.0.0.1:${port}/kotiko`); assert.equal(await (await bg.k.getStore()).meta.get("route:server"), `http://127.0.0.1:${port}/kotiko`); await bg.send({ type: "sync", force: true }, POPUP); - await bg.until(() => sent.some((x) => x.auth === `Bearer ${srv.token}`)); + await bg.until(() => sent.some((x) => x.auth?.startsWith("Kotiko-HMAC ")) && srv.state.log.some((x) => x.signed)); + assert.ok(!sent.some((x) => x.auth?.includes(srv.token))); assert.deepEqual(sent.filter((x) => new URL(x.url).hostname === "localhost"), []); }); }); diff --git a/test/e2e/local.spec.mjs b/test/e2e/local.spec.mjs index 1355588..9639374 100644 --- a/test/e2e/local.spec.mjs +++ b/test/e2e/local.spec.mjs @@ -237,8 +237,10 @@ test("an existing server install upgrades with nothing changed: same words on pa const log = (await server.state()).log; expect(log.some((r) => r.method === "POST" && r.path === "/kotiko/api/v1/words")).toBe(true); expect(log.some((r) => r.method === "POST" && r.path === "/kotiko/api/v1/words/batch")).toBe(true); - // Every request but the server's proof (which carries no token: slice 54, B-01) has the token. - expect(log.filter((r) => r.path.startsWith("/kotiko/") && r.path !== "/kotiko/api/v1/proof").every((r) => r.auth === `Bearer ${server.token}`)).toBe(true); + // Every request but the server's proof (slice 54, B-01) is signed with the token, and none + // carries it (D-01). + expect(log.filter((r) => r.path.startsWith("/kotiko/") && r.path !== "/kotiko/api/v1/proof").every((r) => r.signed)).toBe(true); + expect(log.filter((r) => r.auth?.includes(server.token))).toEqual([]); expect(log.filter((r) => r.path === "/kotiko/api/v1/proof").every((r) => r.auth === null)).toBe(true); expect(log.filter((r) => r.path.startsWith("/llm/"))).toEqual([]); }); diff --git a/test/e2e/loopback.spec.mjs b/test/e2e/loopback.spec.mjs index 6f53317..4674461 100644 --- a/test/e2e/loopback.spec.mjs +++ b/test/e2e/loopback.spec.mjs @@ -9,6 +9,7 @@ // hears nothing. import http from "node:http"; import { test, expect } from "./fixtures.mjs"; +import { startFixtureServer } from "../helpers/fixture-server.mjs"; // Another account's listener: records every request, answers like a 0.2 Kotiko server. async function squatter(host = "::1") { @@ -55,6 +56,31 @@ test("a server typed as http://localhost:PORT is asked at 127.0.0.1, so a listen } }); +// Another account's listener at a given port, as d-squatter.py: logs every request with its +// headers and body, answers a proof with one it made up (it can't know the token), and +// anything else like a server with no words, without the server's signature. +async function squatterAt(host, port, heard) { + const server = http.createServer((req, res) => { + const chunks = []; + req.on("data", (c) => chunks.push(c)); + req.on("end", () => { + heard.push({ host, method: req.method, url: req.url, auth: req.headers.authorization ?? null, body: Buffer.concat(chunks).toString("utf8") }); + const body = req.url.endsWith("/proof") ? `{"proof":"${"A".repeat(43)}"}` : '{"words":[],"ok":true,"name":"kotiko"}'; + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(body); + }); + }); + try { + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(port, host, resolve); + }); + } catch { + return null; + } + return { close: () => new Promise((r) => server.close(r)) }; +} + // The same listener on 127.0.0.1, as when Kotiko's server is stopped and the port is free: the // address is right, but nothing there can prove it holds the token, so it never gets it. test("a listener at the server's own address, while the server is stopped, gets no token either", async ({ context, extensionId }) => { @@ -72,3 +98,53 @@ test("a listener at the server's own address, while the server is stopped, gets await squat.close(); } }); + +// Slice 54, D-01 (reviewer D's d-b01.mjs, as a test): the extension proved the server once +// per worker life and then sent the token with every request, so when the server stopped +// (a crash, a restart, an update) and another account took its port, the next sync handed +// that listener the token. Now the token never leaves the browser: requests are signed, an +// unsigned answer is found out at once, and a word goes out only after a fresh proof. +test("after the server stops and a listener takes its port, the listener gets no token and no word", async ({ context, extensionId }) => { + const token = "d01-test-token-0123456789abcdefghijklmn"; + const srv = await startFixtureServer({ token }); + const port = srv.port; + const heard = []; + const listeners = []; + try { + const page = await context.newPage(); + await page.goto(`chrome-extension://${extensionId}/privacy.html`); + const send = (m) => page.evaluate((m) => chrome.runtime.sendMessage(m), m); + const syncError = () => page.evaluate(async () => (await chrome.storage.local.get("syncError")).syncError?.code ?? null); + + // 1. Connected and synced: the server proved it holds the token. + expect((await send({ type: "server.connect", url: srv.kotikoUrl, token })).ok).toBe(true); + expect(await send({ type: "sync", force: true })).toEqual({ ok: true }); + expect(srv.state.log.some((r) => r.path === "/kotiko/api/v1/proof")).toBe(true); + expect(srv.state.log.some((r) => r.path === "/kotiko/api/words" && r.auth)).toBe(true); + + // 2. The server stops; another program takes 127.0.0.1 (and [::1]) at its port at once. + await srv.close(); + for (const host of ["127.0.0.1", "::1"]) { + const l = await squatterAt(host, port, heard); + if (l) listeners.push(l); + } + expect(listeners.length).toBeGreaterThan(0); + + // 3. The next sync, in the same worker, and then a word the learner adds. + await send({ type: "sync", force: true }); + await expect.poll(() => heard.length, { message: "the sync reached the listener" }).toBeGreaterThan(0); + const saved = await send({ type: "words.save", words: [{ lang: "ru", native: "котик", base_lang: "en", gloss: "kitten" }] }); + + const log = heard.map((r) => `${r.method} ${r.url} auth=${r.auth ?? "-"} body=${r.body}`); + expect(log.filter((l) => l.includes(token)), "requests that carried the token").toEqual([]); + expect(heard.filter((r) => /Bearer/.test(r.auth ?? "")), "requests with a Bearer token").toEqual([]); + expect(log.filter((l) => l.includes("котик") || l.includes("kitten")), "the word went out").toEqual([]); + // Its first answer gave it away, and the word waited for a proof it couldn't give. + expect(await syncError()).toBe("not_kotiko_server"); + expect(saved.ok).not.toBe(true); + expect(heard.map((r) => `${r.method} ${r.url}`)).toEqual(["GET /kotiko/api/words", "POST /kotiko/api/v1/proof"]); + } finally { + for (const l of listeners) await l.close(); + await srv.close().catch(() => {}); + } +}); diff --git a/test/e2e/smoke.spec.mjs b/test/e2e/smoke.spec.mjs index f6aa0c1..883a2ad 100644 --- a/test/e2e/smoke.spec.mjs +++ b/test/e2e/smoke.spec.mjs @@ -54,7 +54,8 @@ test("connects to the server, syncs, swaps words on a page and restores them whe const requests = (await server.state()).log.filter((r) => r.path === "/kotiko/api/words"); expect(requests.length).toBeGreaterThan(0); - expect(requests.every((r) => r.auth === `Bearer ${server.token}`)).toBe(true); + // Signed with the token, never carrying it (slice 54, D-01). + expect(requests.every((r) => r.signed && !r.auth.includes(server.token))).toBe(true); }); test("hiding a language restores its words on the page", async ({ context, server, popup }) => { diff --git a/test/e2e/sync.spec.mjs b/test/e2e/sync.spec.mjs index 0ece380..999643c 100644 --- a/test/e2e/sync.spec.mjs +++ b/test/e2e/sync.spec.mjs @@ -51,10 +51,12 @@ test("fixing the token during a slow sync shows the new result, never the old 40 const shown = await p.evaluate(() => window.__statuses); expect(shown.filter((t) => /didn't accept/i.test(t))).toEqual([]); - // The server never saw the wrong token: it couldn't prove it holds that one, so it was - // never sent (slice 54, B-01). The refusal of that superseded sync was dropped too. + // The server never got a request signed with the wrong token: it couldn't prove it holds + // that one (slice 54, B-01); no token is ever sent (D-01). The refusal of that superseded + // sync was dropped too. const log = (await server.state()).log; - expect(log.some((r) => r.auth === "Bearer wrong-token")).toBe(false); + expect(log.some((r) => r.auth && !r.signed)).toBe(false); + expect(log.some((r) => r.auth?.includes("wrong-token") || r.auth?.includes(server.token))).toBe(false); expect(log.some((r) => r.path === "/kotiko/api/v1/proof")).toBe(true); }); diff --git a/test/helpers/fixture-server.mjs b/test/helpers/fixture-server.mjs index 8002928..b4938b6 100644 --- a/test/helpers/fixture-server.mjs +++ b/test/helpers/fixture-server.mjs @@ -14,7 +14,10 @@ // jobs/pronunciation-refresh (GET, POST pause/resume), profile (GET, PUT; // slice 41 §9, kept as sent in `state.profile`) and llm/status // (slice 10: the quota is `llmRemaining` of 50; each add uses one), and -// POST /api/v1/proof (slice 54 B-01, no token: the HMAC of the nonce) +// POST /api/v1/proof (slice 54 B-01, no token: the HMAC of the nonce). +// Requests are signed (slice 54 D-01, helpers/kotiko-auth.mjs) and every +// answer to one is signed back; `Bearer ` works too, unsigned, as for +// curl. The log notes `signed: true` for a request whose signature held // /llm/v1/* a fake OpenAI-compatible model: /models, /key, /chat/completions // /__control POST to switch behaviours, GET to read state and the request log // @@ -32,10 +35,11 @@ // "job": {state, done, total, retry_at} the pronunciation-refresh job // "failNext": {method, path, status, code, details} one v1 request (or a legacy POST // /api/words, with a 0.2 string error) answers this error -// "token": "..." } the bearer token /kotiko/api expects +// "token": "..." } the token /kotiko/api expects (signed or bearer) // // Run it by hand with `node test/helpers/fixture-server.mjs [port]`. import { createHmac } from "node:crypto"; +import { serverSignature, verifySigned } from "./kotiko-auth.mjs"; import fs from "node:fs"; import http from "node:http"; import path from "node:path"; @@ -139,11 +143,16 @@ function send(res, status, body, headers = {}) { res.end(isText ? body : JSON.stringify(body)); } -async function readBody(req) { +async function rawBody(req) { // Buffers joined before decoding: a character split across two chunks stays whole. const chunks = []; for await (const chunk of req) chunks.push(chunk); - const raw = Buffer.concat(chunks).toString("utf8"); + return Buffer.concat(chunks).toString("utf8"); +} + +async function readBody(req) { + // A signed request's body was read to check its hash. + const raw = req.rawBody ?? (await rawBody(req)); if (!raw) return {}; try { return JSON.parse(raw); @@ -349,6 +358,26 @@ export async function startFixtureServer({ port = 0, host = "127.0.0.1", token = return v1Error(res, 404, "not_found", "No such route."); } + // A signed request whose signature holds (its answer is then signed back), or the right + // `Bearer` token. Nonces are remembered for the server's life. + const seen = new Set(); + async function authorized(req, res) { + const auth = req.headers.authorization ?? ""; + if (auth === `Bearer ${state.token}`) return true; + if (!auth.startsWith("Kotiko-HMAC ")) return false; + req.rawBody = await rawBody(req); + const v = verifySigned(state.token, { method: req.method, target: req.url.slice("/kotiko".length), body: req.rawBody, authorization: auth, seen }); + const entry = state.log.findLast((r) => r.req === req); + if (entry) entry.signed = v.ok; + if (!v.ok) return false; + const writeHead = res.writeHead.bind(res); + res.writeHead = (status, ...rest) => { + res.setHeader("x-kotiko-server", serverSignature(state.token, v.nonce, status)); + return writeHead(status, ...rest); + }; + return true; + } + // Finds the canned model answer for a user's text. const answerFor = (text) => answers[norm(text)] ?? { intent: "add", words: [] }; @@ -364,14 +393,15 @@ export async function startFixtureServer({ port = 0, host = "127.0.0.1", token = const held = state.kotiko === "401" ? `another-${state.token}` : state.token; return send(res, 200, { proof: proofFor(held, nonce) }, { "cache-control": "no-store" }); } - if (state.kotiko === "401") return send(res, 401, "unauthorized"); + // As today's server answers a request signed with another token (slice 54, D-01). + if (state.kotiko === "401") return send(res, 401, "unauthorized", { "www-authenticate": 'Bearer, Kotiko-HMAC error="bad_mac"' }); if (state.kotiko === "500") return send(res, 500, { error: "Something broke on the fake server." }); if (state.kotiko === "html") { return send(res, 200, "Sign in to the Wi-Fi

Captive portal

", { "content-type": "text/html; charset=utf-8", }); } - if (req.headers.authorization !== `Bearer ${state.token}`) return send(res, 401, "unauthorized"); + if (!(await authorized(req, res))) return send(res, 401, "unauthorized", { "www-authenticate": "Bearer, Kotiko-HMAC" }); if (route.startsWith("/api/v1/")) return v1(req, res, route.slice("/api/v1".length)); if (route === "/api/words" && req.method === "GET") return send(res, 200, { words: state.words }); @@ -481,7 +511,10 @@ export async function startFixtureServer({ port = 0, host = "127.0.0.1", token = if (pathname.startsWith("/vendor/")) return serveStatic(res, path.join(FIXTURES, "vendor"), pathname.slice(8)); if (pathname.startsWith("/npm/") && NPM[pathname.slice(5)]) return serveStatic(res, NODE_MODULES, NPM[pathname.slice(5)]); if (pathname.startsWith("/kotiko/")) { - state.log.push({ method: req.method, path: pathname, auth: req.headers.authorization ?? null, host: req.headers.host ?? null }); + const entry = { method: req.method, path: pathname, auth: req.headers.authorization ?? null, host: req.headers.host ?? null, signed: false }; + // Not shown in the log (GET /__control): only to mark the entry once checked. + Object.defineProperty(entry, "req", { value: req, enumerable: false }); + state.log.push(entry); return await kotiko(req, res, pathname.slice("/kotiko".length)); } if (pathname.startsWith("/llm/v1/")) { diff --git a/test/helpers/kotiko-auth.mjs b/test/helpers/kotiko-auth.mjs new file mode 100644 index 0000000..2258520 --- /dev/null +++ b/test/helpers/kotiko-auth.mjs @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: 2026 ScriptKittyOS and the Kotiko contributors +// SPDX-License-Identifier: Apache-2.0 + +// The server's side of signed requests (slice 54, D-01), written again with node:crypto +// for the fake servers in tests, apart from the extension's lib/server-auth.js, so the two +// are checked against each other and against the vectors in docs/reference/http-api.md. +import { createHash, createHmac, timingSafeEqual } from "node:crypto"; + +export const SIGNED = /^Kotiko-HMAC v1 ts=(\d{1,12}), nonce=([A-Za-z0-9_-]{22,128}), body=([0-9a-f]{64}), mac=([A-Za-z0-9_-]{43})$/; + +export const bodyHash = (body = "") => createHash("sha256").update(body).digest("hex"); +const mac = (token, text) => createHmac("sha256", token).update(text).digest("base64url"); + +export const canonical = ({ method, target, ts, nonce, hash }) => ["kotiko-req-v1", method, target, ts, nonce, hash].join("\n"); + +// The X-Kotiko-Server value for an answer with `status` to the request `nonce`. +export const serverSignature = (token, nonce, status) => `v1 mac=${mac(token, ["kotiko-resp-v1", nonce, String(status)].join("\n"))}`; + +// An Authorization value, as a client that holds `token` makes it. +export function signRequest(token, { method = "GET", target, body = "", ts = Math.floor(Date.now() / 1000), nonce }) { + const hash = bodyHash(body); + return `Kotiko-HMAC v1 ts=${ts}, nonce=${nonce}, body=${hash}, mac=${mac(token, canonical({ method, target, ts: String(ts), nonce, hash }))}`; +} + +// Checks a request's Authorization against `token`: {ok, nonce} or {ok: false, reason}. +// The time isn't checked (tests run the extension on a fake clock); `seen`, a Set, refuses +// a nonce used before. +export function verifySigned(token, { method, target, body = "", authorization, seen = null }) { + const m = SIGNED.exec(String(authorization ?? "")); + if (!m) return { ok: false, reason: "malformed" }; + const [, ts, nonce, hash, got] = m; + const want = mac(token, canonical({ method, target, ts, nonce, hash })); + if (!timingSafeEqual(Buffer.from(want), Buffer.from(got))) return { ok: false, reason: "bad_mac" }; + if (hash !== bodyHash(body)) return { ok: false, reason: "body_mismatch" }; + if (seen) { + if (seen.has(nonce)) return { ok: false, reason: "replayed" }; + seen.add(nonce); + } + return { ok: true, nonce, ts: Number(ts) }; +} diff --git a/test/unit/errors.test.mjs b/test/unit/errors.test.mjs index 4a217c9..66defef 100644 --- a/test/unit/errors.test.mjs +++ b/test/unit/errors.test.mjs @@ -26,7 +26,7 @@ const en = readMessages("en"); // Every context and detail that changes which key a code reads as. const CTX = [{}, { n: 1 }, { n: 42 }, { local: true }, { text: "perro", base: "Spanish", language: "Russian" }, { surface: "banner" }, { surface: "line", n: 3, local: true, text: "x", base: "German" }]; -const DETAILS = [{}, { provider: "openrouter" }, { reason: "payment_required" }, { reason: "payment_required", provider: "openrouter" }, { retry_at: "2026-10-06T00:00:00.000Z" }, { reason: "no_token" }, { reason: "host_not_allowed" }, { reason: "no_proof" }, { reason: "wrong_proof" }, { reason: "duplicate" }, { route: "lookup:openrouter" }, ...Object.keys(E.STORAGE).map((reason) => ({ reason }))]; +const DETAILS = [{}, { provider: "openrouter" }, { reason: "payment_required" }, { reason: "payment_required", provider: "openrouter" }, { retry_at: "2026-10-06T00:00:00.000Z" }, { reason: "no_token" }, { reason: "host_not_allowed" }, { reason: "no_proof" }, { reason: "wrong_proof" }, { reason: "stale" }, { reason: "unsigned" }, { reason: "duplicate" }, { route: "lookup:openrouter" }, ...Object.keys(E.STORAGE).map((reason) => ({ reason }))]; function everyKey() { const keys = new Set(); @@ -81,6 +81,10 @@ describe("the catalog", () => { assert.equal(E.message({ code: "rejected_same_as_gloss" }, { text: "perro", base: "español" }), "“perro” is already a word in español. Try naming the language you want it in."); assert.equal(E.message({ code: "rejected_same_as_gloss" }, { text: "perro" }), "Couldn't find a word in “perro”. Try the word on its own, or add it yourself.", "no base named: never a guessed one"); assert.equal(E.message({ code: "word_conflict", details: { reason: "duplicate" } }, { language: "Russian" }), "Another Russian word is already spelled like that."); + // Slice 54, D-01: a signed request the server found more than two minutes off its clock. + assert.match(E.message({ code: "server_key_rejected", details: { reason: "stale" } }), /clock/); + assert.equal(E.message({ code: "not_kotiko_server", details: { reason: "unsigned" } }), en.error_not_kotiko_server.message); + assert.equal(E.message({ code: "server_key_rejected", details: { reason: "bad_mac" } }), en.error_server_key_rejected.message); assert.equal(E.message({ code: "server_unreachable" }, { n: 3, online: false }), "You're offline. Your 3 words still work on pages; new words will be looked up when you're back."); }); diff --git a/test/unit/fixtures.test.mjs b/test/unit/fixtures.test.mjs index a5270ba..5c44188 100644 --- a/test/unit/fixtures.test.mjs +++ b/test/unit/fixtures.test.mjs @@ -144,6 +144,6 @@ describe("fixture server", () => { await control({ reset: true }); await fetch(`${srv.kotikoUrl}/health`); const state = await (await fetch(`${srv.url}/__control`)).json(); - assert.deepEqual(state.log, [{ method: "GET", path: "/kotiko/health", auth: null, host: new URL(srv.url).host }]); + assert.deepEqual(state.log, [{ method: "GET", path: "/kotiko/health", auth: null, host: new URL(srv.url).host, signed: false }]); }); }); diff --git a/test/unit/server-auth.test.mjs b/test/unit/server-auth.test.mjs new file mode 100644 index 0000000..0748694 --- /dev/null +++ b/test/unit/server-auth.test.mjs @@ -0,0 +1,84 @@ +// SPDX-FileCopyrightText: 2026 ScriptKittyOS and the Kotiko contributors +// SPDX-License-Identifier: Apache-2.0 + +// Signed requests (slice 54, D-01): the extension's lib/server-auth.js against the vectors +// in docs/reference/http-api.md (the server checks the same ones in router_signed_test.exs) +// and against the tests' own node:crypto server (helpers/kotiko-auth.mjs). +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { requireExt, ROOT } from "../helpers/load-script.mjs"; +import { serverSignature, signRequest, verifySigned } from "../helpers/kotiko-auth.mjs"; + +const Auth = requireExt("lib/server-auth.js"); +const TOKEN = "example-token-0123456789abcdef"; +const DOC = fs.readFileSync(path.join(ROOT, "docs/reference/http-api.md"), "utf8"); +const BODY = '{"base_langs":["es","en"],"ui_lang":null}'; +const PUT = { + header: "Kotiko-HMAC v1 ts=1791331200, nonce=q1aP3n0ZKcB1x5mW0u7S9b, body=40921055f301e073c3aa377b5ac7d8827ce72c5443e994014c2087b2895f51ff, mac=osa_O2pBaygJz8c64WTOrTLSiY_6vV70NHtPyZ2CZpA", + response: "v1 mac=CAmhh5A5suXJTIJNSarztt97Q-RO5eA9K0baiuiw1u0", +}; +const GET = "Kotiko-HMAC v1 ts=1791331200, nonce=dE8gH0iL2nO4pQ6rS8tU0v, body=e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855, mac=uHVRpP6SeDO3nxpRjs_zp8V9NYSCV2XP15w9BtwCdPg"; + +describe("signed requests: lib/server-auth.js", () => { + test("the documented vectors, which the server checks too", async () => { + const put = await Auth.sign(TOKEN, { method: "put", path: "/api/v1/profile", body: BODY, ts: 1791331200, nonce: "q1aP3n0ZKcB1x5mW0u7S9b" }); + assert.deepEqual(put, { header: PUT.header, nonce: "q1aP3n0ZKcB1x5mW0u7S9b" }); + const get = await Auth.sign(TOKEN, { method: "GET", path: "/api/v1/words?status=active,paused", ts: 1791331200, nonce: "dE8gH0iL2nO4pQ6rS8tU0v" }); + assert.equal(get.header, GET); + for (const value of [PUT.header, PUT.response, GET]) assert.ok(DOC.includes(value), value); + assert.equal(await Auth.verifyResponse(TOKEN, "q1aP3n0ZKcB1x5mW0u7S9b", 200, PUT.response), true); + assert.equal(serverSignature(TOKEN, "q1aP3n0ZKcB1x5mW0u7S9b", 200), PUT.response); + assert.equal(signRequest(TOKEN, { method: "PUT", target: "/api/v1/profile", body: BODY, ts: 1791331200, nonce: "q1aP3n0ZKcB1x5mW0u7S9b" }), PUT.header); + }); + + test("a request it signs verifies on the server's side; a changed part doesn't", async () => { + const { header, nonce } = await Auth.sign(TOKEN, { method: "POST", path: "/api/v1/words", body: '{"text":"кот"}' }); + assert.match(nonce, /^[A-Za-z0-9_-]{22}$/, "16 random bytes"); + const req = { method: "POST", target: "/api/v1/words", body: '{"text":"кот"}', authorization: header }; + assert.deepEqual(verifySigned(TOKEN, req), { ok: true, nonce, ts: Number(/ts=(\d+)/.exec(header)[1]) }); + assert.equal(Math.abs(Number(/ts=(\d+)/.exec(header)[1]) - Date.now() / 1000) < 5, true); + for (const [part, change] of [ + ["method", { method: "PUT" }], + ["path", { target: "/api/v1/words/batch" }], + ["body", { body: '{"text":"пёс"}' }], + ["token", {}, "another-token-0123456789"], + ]) { + const out = verifySigned(part === "token" ? "another-token-0123456789" : TOKEN, { ...req, ...change }); + assert.equal(out.ok, false, part); + } + // A new nonce every time. + assert.notEqual((await Auth.sign(TOKEN, { method: "GET", path: "/" })).nonce, (await Auth.sign(TOKEN, { method: "GET", path: "/" })).nonce); + }); + + test("an answer's signature must be the server's, for this nonce and status", async () => { + const good = serverSignature(TOKEN, "nonce-0123456789abcdefgh", 200); + assert.equal(await Auth.verifyResponse(TOKEN, "nonce-0123456789abcdefgh", 200, good), true); + for (const [label, args] of [ + ["missing", [TOKEN, "nonce-0123456789abcdefgh", 200, null]], + ["another status", [TOKEN, "nonce-0123456789abcdefgh", 404, good]], + ["another nonce", [TOKEN, "nonce-0123456789abcdefgX", 200, good]], + ["another token", ["another-token-0123456789", "nonce-0123456789abcdefgh", 200, good]], + ["malformed", [TOKEN, "nonce-0123456789abcdefgh", 200, good.replace("v1 ", "v2 ")]], + ["padded", [TOKEN, "nonce-0123456789abcdefgh", 200, `${good}=`]], + ]) { + assert.equal(await Auth.verifyResponse(...args), false, label); + } + }); + + test("the path is the server's: an address with a prefix (a reverse proxy) signs without it", () => { + assert.equal(Auth.target("http://127.0.0.1:4747", "http://127.0.0.1:4747/api/words?lang=ru,ar"), "/api/words?lang=ru,ar"); + assert.equal(Auth.target("https://box.example/kotiko", "https://box.example/kotiko/api/v1/words/a%20b"), "/api/v1/words/a%20b"); + assert.equal(Auth.target("https://box.example/kotiko", "https://box.example/kotiko"), "/"); + assert.equal(Auth.target("http://127.0.0.1:4747", "http://127.0.0.1:4747/api/v1/words?"), "/api/v1/words", "an empty query is no query, on both sides"); + }); + + test("a 401's challenge tells a server from before signed requests from today's", () => { + assert.deepEqual(Auth.challenge("Bearer"), { signs: false, reason: null }); + assert.deepEqual(Auth.challenge(null), { signs: false, reason: null }); + assert.deepEqual(Auth.challenge("Bearer, Kotiko-HMAC"), { signs: true, reason: null }); + assert.deepEqual(Auth.challenge('Bearer, Kotiko-HMAC error="stale"'), { signs: true, reason: "stale" }); + assert.deepEqual(Auth.challenge("Bearer, Kotiko-HMACX"), { signs: false, reason: null }); + }); +}); From 4d7fb900d54a964c3b66acc099ec7de6bb0f2396 Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Wed, 7 Oct 2026 12:30:26 -0400 Subject: [PATCH 4/7] docs: signed requests and this computer's addresses in the assurance case (D-01, D-02) Slice 54, findings D-01 and D-02. The assurance case now says the token never leaves the browser and what is left: a program that takes the port after the server stops gets no token and can't answer for the server; it can read the body of a write only if it took the port within 30 s of the last proof and before any other request found it out, and can play nothing to the server later. It also says what a signature doesn't do (no encryption; the answer's signature covers the status, not the body; clocks must agree within two minutes) and that a weak chosen token can be tried without limit from this computer now that loopback is exempt from the lockout. The security requirements, configuration (API_TOKEN), architecture and best-practices answers follow, and CHANGELOG says it in plain words. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Ayla Croft --- CHANGELOG.md | 13 ++++++++ docs/ARCHITECTURE.md | 11 ++++--- docs/best-practices.md | 4 +-- docs/reference/configuration.md | 20 +++++++----- docs/security/assurance-case.md | 55 ++++++++++++++++++++++++--------- docs/security/requirements.md | 6 ++-- 6 files changed, 80 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 61cc9ca..affb50a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ Conventional Commits by release-please. ## Unreleased +- Security: Kotiko no longer sends your server's access key; it signs each request. Your + server signs each answer too, and Kotiko uses an answer only when it carries that + signature, so if another program takes your server's place (say, while the server + restarts) it gets neither your key nor your words. Update the server and extension + together: an older server turns signed requests away, and Kotiko asks you to update it. + Tools such as `curl` can still send the key as before. The privacy policy (version 4) + says so. + +- Security: a web page, or any program on your computer, could lock Kotiko out of your + server for a minute by sending it a burst of wrong keys or malformed checks. Requests + from your own computer are no longer locked out; requests from other computers still + are. + - 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 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e35329b..9ff642c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -25,7 +25,7 @@ The extension works in one of two modes, chosen in its settings (`wordsHome` in ``` web page ── content script ◀── storage.local (a copy of the words and settings pages need) ▲ - popup, dashboard, welcome ──▶ background ──HTTP + token──▶ Kotiko server ──▶ model API + popup, dashboard, welcome ──▶ background ──HTTP, signed──▶ Kotiko server ──▶ model API │ (Elixir, ──▶ en.wiktionary.org Telegram (your phone) ◀──── long polling ────────────────────┤ SQLite) ──▶ transcription ▼ (optional) @@ -126,9 +126,12 @@ database migrations (with a backup first), a startup summary, then the supervisi `Kotiko.Plug.HostCheck` ([`plug/host_check.ex`](../server/lib/kotiko/plug/host_check.ex), refuses unknown host names against DNS rebinding), then the token check (deny by default; only `GET` and - `HEAD /health` and `POST /api/v1/proof` are open; an address that sent 10 wrong tokens - in a minute gets `429`, [`Kotiko.AuthThrottle`](../server/lib/kotiko/auth_throttle.ex)), - then body parsing (64 KB, 1 MB for the batch route, 1 KB for the proof). + `HEAD /health` and `POST /api/v1/proof` are open; `Bearer ` or a request signed + with it, [`Kotiko.RequestAuth`](../server/lib/kotiko/request_auth.ex), whose answers are + signed back; an address other than this computer's that sent 10 wrong tokens in a + minute gets `429`, [`Kotiko.AuthThrottle`](../server/lib/kotiko/auth_throttle.ex)), + then body parsing (64 KB, 1 MB for the batch route, 1 KB for the proof; a signed + request's body is checked against its signed hash first). `/api/v1` is forwarded to [`Kotiko.RouterV1`](../server/lib/kotiko/router_v1.ex). The routes are listed in [`reference/http-api.md`](reference/http-api.md). - **Settings**: [`Kotiko.Config`](../server/lib/kotiko/config.ex) parses the environment diff --git a/docs/best-practices.md b/docs/best-practices.md index 398c9ee..c81745b 100644 --- a/docs/best-practices.md +++ b/docs/best-practices.md @@ -134,14 +134,14 @@ What has to happen for the badge, and to keep it, and who does it: |---|---|---|---|---|---| | `know_secure_design` | MUST | Met | The lead maintainer applies the Saltzer and Schroeder principles plus limited attack surface and allowlist input validation; how each is applied is written down in docs/security/assurance-case.md section 4 (for example deny-by-default authentication, loopback bind, one auth plug before routing). | `docs/security/assurance-case.md:102-117`; `server/lib/kotiko/router.ex:206-214` (`authorize/2`) | – | | `know_common_errors` | MUST | Met | The common weaknesses for this kind of software and how each is countered (XSS, SQL injection, command injection, path traversal, missing authentication, CSRF, SSRF, unsafe deserialization, resource exhaustion, hard-coded credentials, secrets in logs, DNS rebinding, supply chain) are listed in docs/security/assurance-case.md section 5; the adversarial review in docs/research/06-adversarial-qa.md drove the fixes. | `docs/security/assurance-case.md:119-140`; `docs/research/06-adversarial-qa.md` | – | -| `crypto_published` | MUST | Met | Kotiko implements no cryptography. It relies on TLS (Erlang/OTP's ssl for the server's outbound requests, the browser for the extension), SHA-256, PKCE with S256 (RFC 7636) for connecting OpenRouter, and the operating system's and browser's secure random sources. | `server/lib/kotiko/token.ex:59`; `server/lib/kotiko/llm/cache.ex:26,47`; `extension/lib/pkce.js:33-37`; `extension/lib/llm/client.js:50` | – | +| `crypto_published` | MUST | Met | Kotiko implements no cryptography. It relies on TLS (Erlang/OTP's ssl for the server's outbound requests, the browser for the extension), SHA-256, HMAC-SHA256 (RFC 2104, through Erlang's :crypto and the browser's WebCrypto) to sign each request to the server and each answer instead of sending the token, PKCE with S256 (RFC 7636) for connecting OpenRouter, and the operating system's and browser's secure random sources. | `server/lib/kotiko/token.ex:59`; `server/lib/kotiko/llm/cache.ex:26,47`; `extension/lib/pkce.js:33-37`; `extension/lib/llm/client.js:50` | – | | `crypto_call` | SHOULD | Met | Only library calls: Erlang :crypto, Plug.Crypto.secure_compare, OTP ssl, and the browser's Web Crypto API. | Same; `server/lib/kotiko/router.ex:214` | – | | `crypto_floss` | MUST | Met | Erlang/OTP (crypto and ssl, built on OpenSSL) and the Web Crypto API (implemented by Firefox and Chromium) are FLOSS. | – | – | | `crypto_keylength` | MUST | Met | By default Kotiko's secrets are 256-bit: the server generates the API token from 32 random bytes. A token the person sets themselves must be at least 24 characters, and the server suggests openssl rand -hex 24 (192 bits). Hashes are SHA-256. TLS key exchange and lengths are left to Erlang/OTP's and the browser's defaults; Kotiko offers no setting that weakens them. | `server/lib/kotiko/token.ex:11,48,59`; `server/lib/kotiko/config.ex:297`; `docs/reference/configuration.md:91` | – | | `crypto_working` | MUST | Met | No MD4, MD5, DES, RC4, ECB or disabled certificate checks in Kotiko's code. | `grep -rniE "md5\|md4\|rc4\|:des\b\|verify_none\|\becb\b" server/lib server/config extension scripts --include=*.ex --include=*.exs --include=*.js --include=*.mjs --include=*.sh` finds nothing (2026-10-06) | – | | `crypto_weaknesses` | SHOULD | Met | No default security mechanism depends on a weak algorithm or mode: there is no SHA-1, MD4, MD5, RC4, DES, ECB or disabled certificate check in Kotiko's code. SHA-256 is used where a hash is needed (lookup cache keys, the PKCE challenge), and TLS is left to Erlang/OTP and the browser. | `grep -rniE "sha1\|sha-1"` over the same paths finds nothing (2026-10-06) | – | | `crypto_pfs` | SHOULD | Met | Kotiko negotiates no keys itself. Its TLS connections use Erlang/OTP (TLS 1.3 and 1.2 by default from OTP 26, the oldest supported) and the browser, which use forward-secret key exchange (TLS 1.3 always; ECDHE for TLS 1.2). | `.github/workflows/ci.yml:46-47` (OTP 26.2 and 28.1) | – | -| `crypto_password_storage` | MUST | N/A | Kotiko has no user passwords. The server is protected by one random API token (bearer token) and compares it in constant time; there are no user accounts. | `server/lib/kotiko/router.ex:206-214`; `server/lib/kotiko/token.ex` | – | +| `crypto_password_storage` | MUST | N/A | Kotiko has no user passwords. The server is protected by one random API token, sent by tools as a bearer token or used by the extension to sign each request (HMAC-SHA256), and compares either in constant time; there are no user accounts. | `server/lib/kotiko/router.ex:206-214`; `server/lib/kotiko/token.ex` | – | | `crypto_random` | MUST | Met | Tokens, ids, salts and nonces come from cryptographically secure generators: :crypto.strong_rand_bytes on the server, crypto.getRandomValues and crypto.randomUUID in the extension (including the PKCE verifier). Math.random is used only for non-secret values (instance labels, confetti). | `server/lib/kotiko/token.ex:59`; `server/lib/kotiko/uuid7.ex:19`; `extension/lib/pkce.js:34`; `extension/lib/store.js:85`; `extension/content.js:70`; non-secret: `extension/content.js:471`, `extension/lib/word-source.js:53`, `extension/ui/confetti.js:121` | – | | `delivery_mitm` | MUST | Met | The source is delivered from GitHub over HTTPS (git over HTTPS or SSH). The release pipeline publishes each release on GitHub with SHA256SUMS and Sigstore build provenance, and the stores (Chrome Web Store, addons.mozilla.org) sign and deliver their packages over HTTPS. | Repository URL; `.github/workflows/release.yml:128-165`; `docs/verify.md` | – | | `delivery_unsigned` | MUST | Met | No hashes are published or fetched over HTTP: SHA256SUMS is a GitHub release file served over HTTPS and covered by the release's attestation. | `.github/workflows/release.yml:128-160`; no `http://` fetch outside localhost in `scripts/` or `.github/workflows/` | – | diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 8eb22f3..b2f7495 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -97,8 +97,9 @@ behind a reverse proxy). Used only in the pairing string from ### `API_TOKEN` -The token the extension sends as `Authorization: Bearer ` (see the -[HTTP API](http-api.md#requests)). A secret. +The token that opens the server. A secret. The extension never sends it: it signs each +request with it ([signed requests](http-api.md#signed-requests)); `curl` and other tools +send it as `Authorization: Bearer ` (see the [HTTP API](http-api.md#requests)). - Default: none. Then the server uses the token saved in `/api-token`, and on the first start makes one there: 32 random bytes from the operating system's secure @@ -110,11 +111,16 @@ The token the extension sends as `Authorization: Bearer ` (see the (`passwordpasswordpassword`), or one with under 64 bits by its characters' frequencies (its length times the Shannon entropy of the character counts, low when most characters are the same). This is a simple check that catches typed tokens, not a guarantee. The - same check runs on a token you put in `api-token` yourself. Ten wrong tokens from one - address within a minute lock that address out for the rest of the minute - ([HTTP API](http-api.md#requests)), but [`POST /api/v1/proof`](http-api.md#post-apiv1proof) - lets anyone who can reach the server test guesses offline, so only a random token is - safe on a network. + same check runs on a token you put in `api-token` yourself. Ten wrong tokens or failed + signatures from one address within a minute lock that address out for the rest of the + minute ([HTTP API](http-api.md#requests)), except this computer's own addresses + (127.0.0.0/8 and `::1`), which every local program and web page shares, so none of them + can lock the extension out; [`POST /api/v1/proof`](http-api.md#post-apiv1proof) isn't + limited from them either. A token the server made (256 random bits) can't be guessed, + so this costs nothing; a weak token you chose can be guessed by any program on this + computer, without limit, and through a reverse proxy on this computer, which reaches + the server from `127.0.0.1`, by anyone the proxy lets in. The proof also lets anyone + who can reach the server test guesses offline. Keep the generated token. - To replace the saved token: `mix kotiko.token --rotate`, then restart. With `API_TOKEN` set, edit `.env` instead; with [`API_TOKEN_FILE`](#api_token_file), replace the file's contents. diff --git a/docs/security/assurance-case.md b/docs/security/assurance-case.md index d8be304..fe7db23 100644 --- a/docs/security/assurance-case.md +++ b/docs/security/assurance-case.md @@ -23,7 +23,7 @@ its argument and evidence. | Sub-claim | Argument | Evidence | |---|---|---| -| Only `/health` answers without the token | One plug runs before routing and allows exactly `GET` and `HEAD` `/health`; everything else needs `Bearer `, compared in constant time. It matches on the raw path, so encoded spellings can't slip past | [`router.ex`](../../server/lib/kotiko/router.ex) `authorize/2`; [`router_auth_test.exs`](../../server/test/kotiko/router_auth_test.exs) "every method and path spelling is 401", "only GET and HEAD /health are open"; [`router_socket_test.exs`](../../server/test/kotiko/router_socket_test.exs) "encoded and dotted paths need the token" (through a real socket) | +| Only `/health` answers without the token | One plug runs before routing and allows exactly `GET` and `HEAD` `/health` (and the proof, which reveals nothing); everything else needs `Bearer ` or a request signed with it (`Kotiko-HMAC`), each compared in constant time. It matches on the raw path, so encoded spellings can't slip past; every route, v1 and 0.2, goes through it | [`router.ex`](../../server/lib/kotiko/router.ex) `authorize/2`; [`router_auth_test.exs`](../../server/test/kotiko/router_auth_test.exs) "every method and path spelling is 401", "only GET and HEAD /health are open"; [`router_socket_test.exs`](../../server/test/kotiko/router_socket_test.exs) "encoded and dotted paths need the token" (through a real socket); [`router_signed_test.exs`](../../server/test/kotiko/router_signed_test.exs) "every route" | | `/health` says nothing else | It returns name, version, API versions and database state only | [`health.ex`](../../server/lib/kotiko/health.ex) `report/1` | | A strong token, kept private | 32 bytes from `:crypto.strong_rand_bytes`, written to a file made 0600 before the token goes in, then renamed into place; a chosen `API_TOKEN` under 24 characters stops the boot | [`token.ex`](../../server/lib/kotiko/token.ex) `generate/1`, `save/2`, `check/3`; [`token_test.exs`](../../server/test/kotiko/token_test.exs) "first boot makes a 43-character token in a 0600 file", "short or whitespace-only tokens are refused"; [`config.ex`](../../server/lib/kotiko/config.ex) `api_token/1` | | Other accounts on the computer can't read the words | The BEAM can't set a mode on create, so files are created empty, made 0600, then filled: the database before SQLite opens it (SQLite gives `-wal`, `-shm` and `-journal` the database's mode, checked by a test), each backup before `VACUUM INTO`, the token and model cache through a private temporary file. A data folder the server makes is 0700. At every boot, before the Repo opens, `make_private/1` tightens any of Kotiko's files others can open and the folder when it holds only Kotiko's files, and warns otherwise. Before that, `check_safe/2` refuses to start when someone else could have planted a file Kotiko would trust: the folder or a Kotiko file owned by another uid, a Kotiko file that is a symlink, or a folder others can write in that also holds other files (B-02); `api-token` is read with `lstat`, open, `fstat` and compare, never through a link. `run.sh` sets umask 077 and the unit `UMask=0077` | [`private.ex`](../../server/lib/kotiko/private.ex) `read_own/3`; [`data_dir.ex`](../../server/lib/kotiko/data_dir.ex) `check_safe/2`, `make_private/1`; [`private_test.exs`](../../server/test/kotiko/private_test.exs); [`data_dir_test.exs`](../../server/test/kotiko/data_dir_test.exs); [`boot_test.exs`](../../server/test/kotiko/boot_test.exs) (boots with umask 022 and checks the modes); [`scripts_test.exs`](../../server/test/kotiko/scripts_test.exs) | @@ -38,7 +38,7 @@ its argument and evidence. | 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), 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) | +| The token never leaves the browser, and a server that doesn't hold it gets nothing it can use | The extension never sends the token (slice 54, D-01). It signs each request with it: HMAC-SHA256 over the method, the path and query, a timestamp, a random nonce and the body's SHA-256 (`Authorization: Kotiko-HMAC v1 ...`). The server checks the MAC in constant time, refuses a timestamp more than 120 s off or from before it started and a nonce it has seen, and checks the body it reads against the signed hash; a failed signature counts like a wrong token. It signs every answer to an accepted request (`X-Kotiko-Server`, HMAC over the nonce and status), and the extension uses no answer without that signature: it forgets the server's proof and sends nothing more until a new one. Before the first request to an address, and before any request that carries the learner's words or settings (anything but `GET`) when the last proof is over 30 s old, the server proves it holds the token (`POST /api/v1/proof`, HMAC of a fresh nonce, checked with WebCrypto); a missing or wrong proof means nothing is sent. The proof is also forgotten on `server.connect`, an address or token change and any connection error. The extension uses `127.0.0.1` for a typed `localhost` (B-01) | [`lib/server-auth.js`](../../extension/lib/server-auth.js); [`background.js`](../../extension/background.js) `request`, `proveServer`, `askProof`; [`request_auth.ex`](../../server/lib/kotiko/request_auth.ex); [`router.ex`](../../server/lib/kotiko/router.ex) `check_signed/3`, `parse_body/2`; [`router_signed_test.exs`](../../server/test/kotiko/router_signed_test.exs) (the vectors, wrong MAC, stale time, replay, tampered body, every route); [`server-auth.test.mjs`](../../test/unit/server-auth.test.mjs) (the same vectors); [`background.test.mjs`](../../test/bg/background.test.mjs) "signed requests: the token never leaves the browser (D-01)", "the server proves it holds the token before it gets it"; [`loopback.spec.mjs`](../../test/e2e/loopback.spec.mjs) "after the server stops and a listener takes its port, the listener gets no token and no word" | | 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) | @@ -78,7 +78,8 @@ its argument and evidence. │ reads words from storage.local │ (popup, dashboard, │ │ welcome) │ ┌──────────────────────────┤ - │ │ HTTPS │ HTTP(S) + Bearer token + │ │ HTTPS │ HTTP(S), each request and + │ │ │ answer signed with the token │ ▼ ▼ │ model provider (answers your Kotiko server ── Host check, │ untrusted), Wiktionary token check, body cap @@ -97,7 +98,7 @@ its argument and evidence. | Content script to background | Runtime messages | Sender kind per handler | [`lib/messages.js`](../../extension/lib/messages.js) | | Content script to `storage.local` and `storage.sync` | Writes the browser allows (Firefox can't forbid them) | Not trusted: the background acts only on its IndexedDB copy and puts the mirror back; `storage.sync` closed to content scripts where the browser can (Chrome 140+) | [`lib/settings.js`](../../extension/lib/settings.js); [`background.js`](../../extension/background.js) `storage.onChanged` listener | | Extension pages to background | Runtime messages, including secrets | Allowed only from the extension's own URL; payload checks before handlers run | [`lib/messages.js`](../../extension/lib/messages.js) `checks`; [`background.js`](../../extension/background.js) | -| Extension to server | HTTP with `Authorization: Bearer` | Token check, Host check, body cap; the extension validates the address and every response | [`router.ex`](../../server/lib/kotiko/router.ex); [`lib/url.js`](../../extension/lib/url.js); [`lib/validate-words.js`](../../extension/lib/validate-words.js) | +| Extension to server | HTTP, each request signed with the token (never the token itself), each answer signed by the server | Signature check (MAC, time, nonce, body hash), Host check, body cap; the extension checks the server's proof and the signature on every answer, then the address and every response | [`router.ex`](../../server/lib/kotiko/router.ex); [`request_auth.ex`](../../server/lib/kotiko/request_auth.ex); [`lib/server-auth.js`](../../extension/lib/server-auth.js); [`lib/url.js`](../../extension/lib/url.js); [`lib/validate-words.js`](../../extension/lib/validate-words.js) | | Server or extension to model provider | The learner's typed text, the key | HTTPS to an address the owner set; answers parsed as JSON and checked by the word spec; deadline and attempt cap per lookup | [`llm.ex`](../../server/lib/kotiko/llm.ex) `interpret/3`; [`spec/models.json`](../../spec/models.json) budgets; [`lib/llm/client.js`](../../extension/lib/llm/client.js) | | Server to Telegram | Updates in, messages out | HTTPS; sender allowlist | [`bot.ex`](../../server/lib/kotiko/bot.ex); [`telegram.ex`](../../server/lib/kotiko/telegram.ex) | | Server to file system | The database, token, backups | `kotiko.db` and its `-wal`/`-shm`, `api-token`, `models-cache.json` 0600; `backups/` 0700 with 0600 files; a data folder the server makes 0700, one that holds only Kotiko's files made 0700 at start (otherwise a warning); umask 077 in `run.sh` and the unit (`UMask=0077`) | [`private.ex`](../../server/lib/kotiko/private.ex); [`data_dir.ex`](../../server/lib/kotiko/data_dir.ex) `make_private/1`; [`migrations.ex`](../../server/lib/kotiko/migrations.ex) `backup!/2`; [`data_dir_test.exs`](../../server/test/kotiko/data_dir_test.exs); [`boot_test.exs`](../../server/test/kotiko/boot_test.exs) | @@ -113,7 +114,7 @@ The eight principles of Saltzer and Schroeder, plus two the badge criteria add. | Fail-safe defaults | Deny by default (only `/health` is open); loopback bind; a token is generated if none is set; a setting the server can't parse stops it; unknown message senders are refused | `authorize/2`; `@default_bind`; [`token.ex`](../../server/lib/kotiko/token.ex) `resolve/2`; `Config.load!/0` | | Complete mediation | The token check runs on every request before routing, on the raw path, and is tested over encoded and dotted spellings and through a real socket | [`router_auth_test.exs`](../../server/test/kotiko/router_auth_test.exs); [`router_socket_test.exs`](../../server/test/kotiko/router_socket_test.exs) | | Open design | The code is public; the token and keys are the only secrets | [LICENSE](../../LICENSE) | -| Separation of privilege | Weakly applied. One bearer token grants everything on the server; there is no second factor. The bot needs both the bot token and an allowlisted sender ID. Per-user tokens are planned (slice 48) | [`bot.ex`](../../server/lib/kotiko/bot.ex) `handle/1` | +| Separation of privilege | Weakly applied. One token grants everything on the server; there is no second factor. The bot needs both the bot token and an allowlisted sender ID. Per-user tokens are planned (slice 48) | [`bot.ex`](../../server/lib/kotiko/bot.ex) `handle/1` | | Least privilege | Content scripts may only ask for a sync and the sensitive-site list (and mark one word never to swap), and change no setting; secrets sit where content scripts can't read them; CI jobs get a read-only token. The extension asks for `` because it works on any page; a permission review is planned (slice 28 section 6); a sandboxed service unit is planned (slice 40) | [`background.js`](../../extension/background.js); [`ci.yml`](../../.github/workflows/ci.yml) `permissions: contents: read`; [`manifest.json`](../../extension/manifest.json) | | Least common mechanism | Each person runs their own server with their own data folder and bot; there is no shared service run by the project | [DECISIONS.md](../../slices/DECISIONS.md) 2026-10-02 (own bot, no hosted service) | | Psychological acceptability | Plain-language startup warnings and error messages that name the fix; a pairing string so the token is pasted, not retyped; settings errors list every problem at once | [`exposure.ex`](../../server/lib/kotiko/exposure.ex); [`config.ex`](../../server/lib/kotiko/config.ex) `format_problems/3`; [`mix kotiko.token`](../../server/lib/mix/tasks/kotiko.token.ex) | @@ -132,8 +133,8 @@ a small JSON API. | CWE-78 OS command injection | The server runs no external commands (no `System.cmd`, `Port.open` or `:os.cmd` in `server/lib`). The shell scripts read only the owner's `.env` | In place | `server/lib/`; ShellCheck in [`ci.yml`](../../.github/workflows/ci.yml) | | CWE-22 Path traversal | No file path comes from a request. The data folder comes from the owner's settings; backup names are built from the version and the time. Sobelow fails CI on any new file call with a path it can't rule out; each accepted one says why | In place | [`config.ex`](../../server/lib/kotiko/config.ex) `data_dir/4`; [`migrations.ex`](../../server/lib/kotiko/migrations.ex); [`.sobelow-conf`](../../server/.sobelow-conf) | | CWE-306, CWE-862 Missing authentication or authorization | Deny by default; one token for everything (no roles yet, so no authorization levels to get wrong). The only open routes are `/health` and `POST /api/v1/proof`, which proves the server holds the token (HMAC of a client nonce) without revealing it | In place; per-user authorization planned (slice 48) | `authorize/2`; [`token.ex`](../../server/lib/kotiko/token.ex) `proof/2`; [`router_auth_test.exs`](../../server/test/kotiko/router_auth_test.exs); [`router_proof_test.exs`](../../server/test/kotiko/router_proof_test.exs) | -| CWE-307 Excessive authentication attempts | After 10 wrong tokens from one address (an IPv6 /64) within a minute, that address gets 429 on every authenticated route until the minute ends; other addresses are unaffected; the lockout is logged once an hour per address. Proofs are limited to 30 a minute per address. A proof lets someone test guesses offline, so a weak chosen token gets a warning at start | In place | [`auth_throttle.ex`](../../server/lib/kotiko/auth_throttle.ex); [`rate_limit.ex`](../../server/lib/kotiko/rate_limit.ex); [`router_throttle_test.exs`](../../server/test/kotiko/router_throttle_test.exs) | -| CWE-352 Cross-site request forgery | The token travels in a header, never a cookie; the server sends no CORS headers, so a page can't attach the header (that needs a preflight the server doesn't answer) | In place | [`router_auth_test.exs`](../../server/test/kotiko/router_auth_test.exs) "a CORS preflight gets no CORS headers" | +| CWE-307 Excessive authentication attempts | After 10 wrong tokens or failed signatures from one address (an IPv6 /64) within a minute, that address gets 429 on every authenticated route until the minute ends; other addresses are unaffected; the lockout is logged once an hour per address. Proofs with a well-formed nonce and `Content-Type: application/json` are limited to 30 a minute per address; malformed ones are refused before they are counted, so a web page (which can't send JSON cross-site without CORS) can't use them up. This computer's own addresses (127.0.0.0/8, `::1`, IPv4-mapped 127.x) are exempt from both (slice 54, D-02): every local program and web page shares them, so a limit there would let any of them lock the extension out. A generated token (256 random bits) can't be guessed; a proof lets someone test guesses offline, so a weak chosen token gets a warning at start | In place | [`auth_throttle.ex`](../../server/lib/kotiko/auth_throttle.ex); [`rate_limit.ex`](../../server/lib/kotiko/rate_limit.ex) `loopback?/1`; [`router_throttle_test.exs`](../../server/test/kotiko/router_throttle_test.exs) "this computer's addresses are never locked out"; [`router_proof_test.exs`](../../server/test/kotiko/router_proof_test.exs) "31 malformed proofs", "this computer's addresses aren't limited" | +| CWE-352 Cross-site request forgery | The credential (a `Bearer` token or a request signature) travels in a header, never a cookie; the server sends no CORS headers, so a page can't attach the header (that needs a preflight the server doesn't answer) | In place | [`router_auth_test.exs`](../../server/test/kotiko/router_auth_test.exs) "a CORS preflight gets no CORS headers" | | CWE-918 Server-side request forgery | The server fetches only URLs the owner set (`LLM_URL`, `TRANSCRIBE_URL`) and fixed hosts (Telegram, Wiktionary with the word percent-encoded in the path). No request can choose a URL | In place | [`config.ex`](../../server/lib/kotiko/config.ex); [`pronounce.ex`](../../server/lib/kotiko/pronounce.ex) | | CWE-502 Unsafe deserialization | Nothing from the network becomes an Erlang term: requests and API answers are parsed as JSON, Wiktionary pages are read as text. One call reads back the lookup cache the server itself wrote to its own database, with `Plug.Crypto.non_executable_binary_to_term/2` and `:safe`, so a changed row can neither create atoms nor carry a function; Sobelow checks for any other | In place | [`llm/cache.ex`](../../server/lib/kotiko/llm/cache.ex) `decode/1`; `llm_test.exs` ("a stored entry holding a function is a miss") | | CWE-400 Resource exhaustion | Body caps (64 KB, 1 MB batch); at most 500 words per batch and 20,000 per list request; each lookup has a deadline and an attempt cap; refused Host names are logged at most once an hour per name, for at most 1,000 names an hour, then one summary line (`log/limiter.ex`). Wrong tokens and proofs are limited per address (CWE-307). There is no request rate limit for token holders | In place, partly | [`router.ex`](../../server/lib/kotiko/router.ex); [`router_v1.ex`](../../server/lib/kotiko/router_v1.ex) `@max_batch`, `@max_limit`; [`spec/models.json`](../../spec/models.json); [`host_check.ex`](../../server/lib/kotiko/plug/host_check.ex) | @@ -147,8 +148,8 @@ a small JSON API. | Input | Where it enters | Rule | Location | |---|---|---|---| -| HTTP method, path, headers | Every request | Host allowlist; exactly one `Authorization: Bearer` header compared in constant time; unknown routes 404 | [`host_check.ex`](../../server/lib/kotiko/plug/host_check.ex); [`router.ex`](../../server/lib/kotiko/router.ex) | -| HTTP body | `POST` and `PATCH` routes | JSON only, size cap, parsed after auth; then per-field checks: text 1 to 200 characters after normalisation, base languages (at most 4, canonical tags), `client_request_id` a UUID, word fields against the word spec and its caps, `limit` 1 to 20,000, `status` from a fixed list, `if_updated_at` an ISO 8601 time | [`router_v1.ex`](../../server/lib/kotiko/router_v1.ex); [`word_spec.ex`](../../server/lib/kotiko/word_spec.ex) `prepare_input/1`, `validate_word/2`, `patch/1`; [`spec/rules.json`](../../spec/rules.json) | +| HTTP method, path, headers | Every request | Host allowlist; exactly one `Authorization` header: `Bearer` compared in constant time, or `Kotiko-HMAC v1` in exactly one form (time 1 to 12 digits, nonce 22 to 128 base64url, body hash 64 hex, MAC 43 base64url), its MAC compared in constant time, its time within 120 s and not before the server started, its nonce unseen; unknown routes 404 | [`host_check.ex`](../../server/lib/kotiko/plug/host_check.ex); [`router.ex`](../../server/lib/kotiko/router.ex) | +| HTTP body | `POST` and `PATCH` routes | JSON only, size cap, parsed after auth (a signed request's body read first, within the cap, and checked against its signed hash); then per-field checks: text 1 to 200 characters after normalisation, base languages (at most 4, canonical tags), `client_request_id` a UUID, word fields against the word spec and its caps, `limit` 1 to 20,000, `status` from a fixed list, `if_updated_at` an ISO 8601 time | [`router_v1.ex`](../../server/lib/kotiko/router_v1.ex); [`word_spec.ex`](../../server/lib/kotiko/word_spec.ex) `prepare_input/1`, `validate_word/2`, `patch/1`; [`spec/rules.json`](../../spec/rules.json) | | Legacy `POST /api/words` body | 0.2 extensions | Text must be a non-empty string; not run through the 200-character input check (the 64 KB body cap applies) | [`router.ex`](../../server/lib/kotiko/router.ex) | | Environment (`.env`) | Server start | One parser per variable; any problem stops the server | [`config.ex`](../../server/lib/kotiko/config.ex) `parse/2` | | Data files | Server start | The token file must hold at least 24 characters; migrations back up before changing the database | [`token.ex`](../../server/lib/kotiko/token.ex); [`migrations.ex`](../../server/lib/kotiko/migrations.ex) | @@ -158,7 +159,7 @@ a small JSON API. | Extension messages | The background | Sender kind, then the handler's payload `check` | [`lib/messages.js`](../../extension/lib/messages.js); [`background.js`](../../extension/background.js) | | Settings a page changes (`settings.set`, `settings.restore`) | The background | Allowlist of keys; per-key shape and size (booleans, host and language-tag lists with caps, objects up to a byte cap, the four `ui` fields); a restore takes only what `settingsPatch` allows, never addresses or the service | [`lib/settings.js`](../../extension/lib/settings.js) `edit`; [`lib/backup.js`](../../extension/lib/backup.js) `settingsPatch` | | `storage.local` and `storage.sync` as read back | The background | Never trusted after the one-time adoption: compared with the IndexedDB copy and put back; `storage.sync` changes taken only where content scripts can't write there | [`background.js`](../../extension/background.js) `adoptOnce`, `adoptSync` | -| Server responses in the extension | Sync | Status, content type, JSON shape, word fields and counts | [`lib/validate-words.js`](../../extension/lib/validate-words.js) | +| Server responses in the extension | Every request to the server | The server's signature on the status (`X-Kotiko-Server`) before anything else is read; then status, content type, JSON shape, word fields and counts | [`lib/validate-words.js`](../../extension/lib/validate-words.js) | | The server address the learner types | Extension settings | http(s) only, no user info in the URL, https assumed for non-local names | [`lib/url.js`](../../extension/lib/url.js) | | Pasted lists and dropped files | Bulk add | Parsed as text in the extension; each row then saved through the word checks | [`bulk/parse.js`](../../extension/bulk/parse.js); a backup file is read by [`lib/backup.js`](../../extension/lib/backup.js) `read`, which checks every word against the word spec before anything is restored | | The page DOM | Content script | Read as text; only text nodes in languages the learner reads are changed | [`content.js`](../../extension/content.js), [`lib/matcher.js`](../../extension/lib/matcher.js) | @@ -176,9 +177,34 @@ 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, 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. +- **Plain HTTP beyond loopback** exposes the words to the network, and a lookup service's + key and the words looked up. The server's token isn't sent (requests are signed), and a + request seen on the network can't be sent again. The server warns, and so do the + extension's address fields for the server and for a lookup service; neither refuses. +- **Someone at the server's address after it stops** (slice 54, B-01 and D-01). If the + server stops (a crash, a restart, an update) and another account starts listening at its + port, that program never gets the token and can't answer for the server: its first + answer has no server signature, so the extension uses nothing from it, forgets the + proof, and sends nothing more until a proof it can't give. What it can get: the signed + requests already sent before that first answer came back. A `GET` (a sync, a status + read) shows it only which route was asked. A request that carries the learner's words + or settings (an add, an edit, an import batch, a delete, the languages sent after a + sync) goes out only within 30 s of a successful proof, so it gets such a body only if + it took the port within 30 s of the last proof and before any other request found it + out; then it reads that request's body (and each other one already in flight at that + moment), never the token. It can't play any request to the server later: a nonce is + accepted once, a request's time must be within 120 s and not before the server started. +- **What a signature doesn't do.** It authenticates; it doesn't encrypt. The server's + signature covers the answer's status, not its body, so a program that sits between the + extension and a running server (on a network without HTTPS, or a local listener that + forwards to the server at an address the extension doesn't use) can read and change + bodies; HTTPS (or Tailscale) is the answer there. A clock more than two minutes off the + server's makes every request fail, with a message that says so. +- **Weak chosen tokens on this computer.** This computer's own addresses are exempt from + the wrong-token lockout and the proof limit (D-02), so a program on the computer, or a + reverse proxy on it (whose requests come from `127.0.0.1`), can try tokens without + limit. A generated token (256 random bits) can't be guessed; a chosen one gets a + warning at start ([configuration.md](../reference/configuration.md#api_token)). - **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 @@ -210,7 +236,8 @@ These match "What you can't expect" in the [requirements](requirements.md). 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. +- **No rate limit** for requests that carry the token or are signed with it (beyond the + 50,000 signed requests the server remembers at once). - **Response hardening headers** (`X-Content-Type-Options` and similar) are not sent yet; only `/health` sends `cache-control: no-store`. Planned (slice 01 section 7). - **No enforced second review.** The `main` ruleset requires the CI checks but no diff --git a/docs/security/requirements.md b/docs/security/requirements.md index 9095427..de0dc17 100644 --- a/docs/security/requirements.md +++ b/docs/security/requirements.md @@ -16,14 +16,16 @@ Kotiko has two parts: the browser extension, and an optional server you run your - **Nothing but `/health` answers without your token.** Every other request, whatever its method or however its path is spelled (`/%61pi`, `/API`, `//api`), gets `401` unless it - carries `Authorization: Bearer `. `/health` says only the server's name, + carries `Authorization: Bearer ` or is signed with it (the extension signs; + it never sends the token, and uses an answer only if the server signed it too). `/health` says only the server's name, version and whether its database works. - **A strong token, kept private.** On first start the server makes a random 256-bit token and saves it in `api-token` in the data folder, readable only by you (mode 0600). If you pick your own with `API_TOKEN`, it must be at least 24 characters, or the server won't start, and one that looks easy to guess gets a warning at every start. After 10 wrong tokens from one address within a minute, that address is refused until the minute is - over; other addresses keep working. + over; other addresses keep working, and this computer's own addresses are never + refused, so no local program or web page can lock the extension out. - **Other accounts on the computer can't read your words.** The database, its `-wal` and `-shm` files, the backups, the token and the model list cache are readable only by you (0600), and a data folder the server makes only by you (0700). At every start it makes From 155392767cf9afd5d818dcc42c789be381c32bdb Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Wed, 7 Oct 2026 12:33:25 -0400 Subject: [PATCH 5/7] test(extension): wait for the lookup status after an add, not a fixed 20 ms Slice 54, D-01 made each request a little slower (it is signed, and its answer's signature checked), and the llmStatus test's fixed 20 ms sleep failed once under coverage. It now waits, up to 5 s, for the status the add refreshes, as docs/CODE_REVIEW.md asks of tests. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Ayla Croft --- test/bg/background.test.mjs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/test/bg/background.test.mjs b/test/bg/background.test.mjs index de15420..4488f84 100644 --- a/test/bg/background.test.mjs +++ b/test/bg/background.test.mjs @@ -517,8 +517,11 @@ describe("add and remove relay", () => { assert.ok(signedWith(requests.at(-1), "good-token")); await send({ type: "add", text: "x" }, POPUP); - await sleep(20); - await fake.idle(); + // The add, then its status refresh, each signed and checked (slice 54, D-01): wait for it. + for (const end = Date.now() + 5000; store.lookupStatus?.quota?.remaining !== 37 && Date.now() < end;) { + await fake.idle(); + await sleep(5); + } assert.equal(store.lookupStatus.quota.remaining, 37); route = false; From 3300a87500c2b5bbdfcee016460f346c0769843f Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Wed, 7 Oct 2026 12:45:30 -0400 Subject: [PATCH 6/7] fix(server): limit what a reverse proxy on this computer forwards (D-02) The loopback exemption from the D-02 fix also exempted everyone behind a reverse proxy on the same machine (Caddy or nginx in front of the server for remote access): every remote client arrives from 127.0.0.1, so strangers on the internet got unthrottled guesses at a weak chosen token, re-opening B-09 there. A loopback request is now exempt only when it carries no forwarding header (Forwarded, X-Forwarded-For, X-Real-IP, X-Forwarded-Host, CF-Connecting-IP, True-Client-IP; any case, any value). One that carries one counts towards the proof limit and the wrong-token lockout under {:proxied, peer}, shared by all proxied requests: the header's claimed client address can be forged, so it isn't used. The extension sends no forwarding header, so a local program that adds one to its guesses locks out only the proxied requests, never the extension (RateLimit.peer/1). Regression tests that fail without this: 10 wrong tokens from 127.0.0.1 with x-forwarded-for, then the 11th proxied request (with any of the six headers, or the right token) gets 429 while the right token from 127.0.0.1 without one gets 200; and three proofs through the proxy, then the fourth gets 429 while the extension's proofs keep getting 200. The HTTP API reference, configuration, requirements and assurance case say exactly this, and the residual risk now names the shared proxied lockout instead of unlimited guesses through a proxy. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Ayla Croft --- CHANGELOG.md | 2 +- docs/reference/configuration.md | 19 +++++---- docs/reference/http-api.md | 29 ++++++++----- docs/security/assurance-case.md | 16 ++++--- docs/security/requirements.md | 5 ++- server/lib/kotiko/auth_throttle.ex | 46 ++++++++++++++------- server/lib/kotiko/rate_limit.ex | 31 ++++++++++++-- server/lib/kotiko/router.ex | 23 ++++++----- server/test/kotiko/router_proof_test.exs | 15 +++++++ server/test/kotiko/router_throttle_test.exs | 28 +++++++++++++ 10 files changed, 157 insertions(+), 57 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index affb50a..8165bfb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,7 @@ Conventional Commits by release-please. - Security: a web page, or any program on your computer, could lock Kotiko out of your server for a minute by sending it a burst of wrong keys or malformed checks. Requests from your own computer are no longer locked out; requests from other computers still - are. + are, including those a reverse proxy on your computer passes on. - 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 diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index b2f7495..1f83f17 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -113,14 +113,17 @@ send it as `Authorization: Bearer ` (see the [HTTP API](http-api.md#reque are the same). This is a simple check that catches typed tokens, not a guarantee. The same check runs on a token you put in `api-token` yourself. Ten wrong tokens or failed signatures from one address within a minute lock that address out for the rest of the - minute ([HTTP API](http-api.md#requests)), except this computer's own addresses - (127.0.0.0/8 and `::1`), which every local program and web page shares, so none of them - can lock the extension out; [`POST /api/v1/proof`](http-api.md#post-apiv1proof) isn't - limited from them either. A token the server made (256 random bits) can't be guessed, - so this costs nothing; a weak token you chose can be guessed by any program on this - computer, without limit, and through a reverse proxy on this computer, which reaches - the server from `127.0.0.1`, by anyone the proxy lets in. The proof also lets anyone - who can reach the server test guesses offline. Keep the generated token. + minute ([HTTP API](http-api.md#requests)), except requests from this computer + (127.0.0.0/8 and `::1`) without a forwarding header, which every local program and web + page shares, so none of them can lock the extension out; + [`POST /api/v1/proof`](http-api.md#post-apiv1proof) isn't limited for them either. + Requests a reverse proxy on this computer forwards (they carry a header such as + `X-Forwarded-For` or `Forwarded`) are limited, all together: a stranger's lockout + refuses everyone coming through the proxy for the rest of that minute, never the + extension. A token the server made (256 random bits) can't be guessed, so this costs + nothing; a weak token you chose can be guessed by any program on this computer without + limit, and the proof lets anyone who can reach the server test guesses offline. Keep + the generated token. - To replace the saved token: `mix kotiko.token --rotate`, then restart. With `API_TOKEN` set, edit `.env` instead; with [`API_TOKEN_FILE`](#api_token_file), replace the file's contents. diff --git a/docs/reference/http-api.md b/docs/reference/http-api.md index ace90fa..7da9e64 100644 --- a/docs/reference/http-api.md +++ b/docs/reference/http-api.md @@ -45,14 +45,20 @@ one address within a minute, every request from that address that needs the toke until the minute is over, even with the right token, so guessing learns nothing while it waits. Other addresses are not affected. A request with no token, another scheme or a malformed signature doesn't count. An IPv6 address counts by its /64 network. The server -logs the lockout once an hour per address. This computer's own addresses (127.0.0.0/8, -`::1` and IPv4-mapped 127.x) are never locked out: every program on the computer, and any -web page open in a browser there, reaches the server from them, so any of them could -otherwise lock the extension out. A token the server made (256 random bits) can't be -guessed in any case; a weak token you chose can, and the server warns about it at start -([`API_TOKEN`](configuration.md#api_token)). Behind a reverse proxy on the same machine, -every request comes from the proxy's loopback address, so the lockout doesn't apply there: -limit attempts at the proxy and keep the generated token. +logs the lockout once an hour per address. + +A request from this computer (127.0.0.0/8, `::1` or IPv4-mapped 127.x) with no forwarding +header is never locked out: every program on the computer, and any web page open in a +browser there, reaches the server from those addresses, so any of them could otherwise +lock the extension out. A request from this computer that carries a forwarding header +(`Forwarded`, `X-Forwarded-For`, `X-Real-IP`, `X-Forwarded-Host`, `CF-Connecting-IP` or `True-Client-IP`, any value) is what a reverse proxy on the same machine sends +for each remote client: those are counted, all together under one key per proxy address, +whatever client address the header claims (it can be forged). A stranger's lockout there +refuses everyone who comes through the proxy for the rest of that minute, but never the +extension, which sends no forwarding header; a local program that adds one locks out only +the proxied requests. A token the server made (256 random bits) can't be guessed in any +case; a weak token you chose can, and the server warns about it at start +([`API_TOKEN`](configuration.md#api_token)). **Host names.** Before anything else, the server checks the `Host` header, to stop web pages reaching it through DNS rebinding. It answers to `localhost`, IP addresses, this machine's @@ -310,9 +316,10 @@ requests only if they match. Use a new nonce each time. base64url characters. The body may be at most 1,000 bytes (`413`). - `429 rate_limited` (`details.reason`: `too_many_proofs`): more than 30 proofs with a well-formed nonce from one address within a minute; `Retry-After` says when to try - again. Refused requests (`415`, `400`) don't count, and neither do this computer's own - addresses (127.0.0.0/8, `::1`, IPv4-mapped 127.x), which every local program and web - page shares. + again. Refused requests (`415`, `400`) don't count, and neither do requests from this + computer (127.0.0.0/8, `::1`, IPv4-mapped 127.x) without a forwarding header, which every + local program and web page shares. Requests from this computer with a forwarding header + (a reverse proxy's) are limited all together, as for wrong tokens ([Requests](#requests)). - The answer never contains the token and is sent with `cache-control: no-store`. Like `/health`, it is checked against the `Host` names the server answers to. - Only this exact path and `POST` are open: any other method or spelling diff --git a/docs/security/assurance-case.md b/docs/security/assurance-case.md index fe7db23..be07564 100644 --- a/docs/security/assurance-case.md +++ b/docs/security/assurance-case.md @@ -133,7 +133,7 @@ a small JSON API. | CWE-78 OS command injection | The server runs no external commands (no `System.cmd`, `Port.open` or `:os.cmd` in `server/lib`). The shell scripts read only the owner's `.env` | In place | `server/lib/`; ShellCheck in [`ci.yml`](../../.github/workflows/ci.yml) | | CWE-22 Path traversal | No file path comes from a request. The data folder comes from the owner's settings; backup names are built from the version and the time. Sobelow fails CI on any new file call with a path it can't rule out; each accepted one says why | In place | [`config.ex`](../../server/lib/kotiko/config.ex) `data_dir/4`; [`migrations.ex`](../../server/lib/kotiko/migrations.ex); [`.sobelow-conf`](../../server/.sobelow-conf) | | CWE-306, CWE-862 Missing authentication or authorization | Deny by default; one token for everything (no roles yet, so no authorization levels to get wrong). The only open routes are `/health` and `POST /api/v1/proof`, which proves the server holds the token (HMAC of a client nonce) without revealing it | In place; per-user authorization planned (slice 48) | `authorize/2`; [`token.ex`](../../server/lib/kotiko/token.ex) `proof/2`; [`router_auth_test.exs`](../../server/test/kotiko/router_auth_test.exs); [`router_proof_test.exs`](../../server/test/kotiko/router_proof_test.exs) | -| CWE-307 Excessive authentication attempts | After 10 wrong tokens or failed signatures from one address (an IPv6 /64) within a minute, that address gets 429 on every authenticated route until the minute ends; other addresses are unaffected; the lockout is logged once an hour per address. Proofs with a well-formed nonce and `Content-Type: application/json` are limited to 30 a minute per address; malformed ones are refused before they are counted, so a web page (which can't send JSON cross-site without CORS) can't use them up. This computer's own addresses (127.0.0.0/8, `::1`, IPv4-mapped 127.x) are exempt from both (slice 54, D-02): every local program and web page shares them, so a limit there would let any of them lock the extension out. A generated token (256 random bits) can't be guessed; a proof lets someone test guesses offline, so a weak chosen token gets a warning at start | In place | [`auth_throttle.ex`](../../server/lib/kotiko/auth_throttle.ex); [`rate_limit.ex`](../../server/lib/kotiko/rate_limit.ex) `loopback?/1`; [`router_throttle_test.exs`](../../server/test/kotiko/router_throttle_test.exs) "this computer's addresses are never locked out"; [`router_proof_test.exs`](../../server/test/kotiko/router_proof_test.exs) "31 malformed proofs", "this computer's addresses aren't limited" | +| CWE-307 Excessive authentication attempts | After 10 wrong tokens or failed signatures from one address (an IPv6 /64) within a minute, that address gets 429 on every authenticated route until the minute ends; other addresses are unaffected; the lockout is logged once an hour per address. Proofs with a well-formed nonce and `Content-Type: application/json` are limited to 30 a minute per address; malformed ones are refused before they are counted, so a web page (which can't send JSON cross-site without CORS) can't use them up. Requests from this computer's own addresses (127.0.0.0/8, `::1`, IPv4-mapped 127.x) without a forwarding header are exempt from both (slice 54, D-02): every local program and web page shares them, so a limit there would let any of them lock the extension out. Loopback requests with a forwarding header (`Forwarded`, `X-Forwarded-For`, `X-Real-IP`, `X-Forwarded-Host`, `CF-Connecting-IP`, `True-Client-IP`), which is how a reverse proxy on the same machine forwards remote clients, keep both limits, counted together per proxy address whatever client the header claims (it can be forged); the extension sends no such header, so a local program that adds one locks out only the proxied requests. A generated token (256 random bits) can't be guessed; a proof lets someone test guesses offline, so a weak chosen token gets a warning at start | In place | [`auth_throttle.ex`](../../server/lib/kotiko/auth_throttle.ex); [`rate_limit.ex`](../../server/lib/kotiko/rate_limit.ex) `peer/1`; [`router_throttle_test.exs`](../../server/test/kotiko/router_throttle_test.exs) "this computer's addresses are never locked out", "loopback requests with a forwarding header are limited together"; [`router_proof_test.exs`](../../server/test/kotiko/router_proof_test.exs) "31 malformed proofs", "this computer's addresses aren't limited", "proofs through a reverse proxy on this computer are limited together" | | CWE-352 Cross-site request forgery | The credential (a `Bearer` token or a request signature) travels in a header, never a cookie; the server sends no CORS headers, so a page can't attach the header (that needs a preflight the server doesn't answer) | In place | [`router_auth_test.exs`](../../server/test/kotiko/router_auth_test.exs) "a CORS preflight gets no CORS headers" | | CWE-918 Server-side request forgery | The server fetches only URLs the owner set (`LLM_URL`, `TRANSCRIBE_URL`) and fixed hosts (Telegram, Wiktionary with the word percent-encoded in the path). No request can choose a URL | In place | [`config.ex`](../../server/lib/kotiko/config.ex); [`pronounce.ex`](../../server/lib/kotiko/pronounce.ex) | | CWE-502 Unsafe deserialization | Nothing from the network becomes an Erlang term: requests and API answers are parsed as JSON, Wiktionary pages are read as text. One call reads back the lookup cache the server itself wrote to its own database, with `Plug.Crypto.non_executable_binary_to_term/2` and `:safe`, so a changed row can neither create atoms nor carry a function; Sobelow checks for any other | In place | [`llm/cache.ex`](../../server/lib/kotiko/llm/cache.ex) `decode/1`; `llm_test.exs` ("a stored entry holding a function is a miss") | @@ -200,11 +200,15 @@ These match "What you can't expect" in the [requirements](requirements.md). forwards to the server at an address the extension doesn't use) can read and change bodies; HTTPS (or Tailscale) is the answer there. A clock more than two minutes off the server's makes every request fail, with a message that says so. -- **Weak chosen tokens on this computer.** This computer's own addresses are exempt from - the wrong-token lockout and the proof limit (D-02), so a program on the computer, or a - reverse proxy on it (whose requests come from `127.0.0.1`), can try tokens without - limit. A generated token (256 random bits) can't be guessed; a chosen one gets a - warning at start ([configuration.md](../reference/configuration.md#api_token)). +- **Weak chosen tokens on this computer.** Requests from this computer without a + forwarding header are exempt from the wrong-token lockout and the proof limit (D-02), so + a program on the computer can try tokens without limit. Remote clients behind a reverse + proxy on the computer are still limited (the proxy adds a forwarding header), but all + together: one stranger's lockout refuses every proxied client, the owner's other + devices included, for the rest of that minute. A proxy that forwards without any of + those headers makes its clients look local and unlimited. A generated token (256 + random bits) can't be guessed; a chosen one gets a warning at start + ([configuration.md](../reference/configuration.md#api_token)). - **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 diff --git a/docs/security/requirements.md b/docs/security/requirements.md index de0dc17..9822229 100644 --- a/docs/security/requirements.md +++ b/docs/security/requirements.md @@ -24,8 +24,9 @@ Kotiko has two parts: the browser extension, and an optional server you run your pick your own with `API_TOKEN`, it must be at least 24 characters, or the server won't start, and one that looks easy to guess gets a warning at every start. After 10 wrong tokens from one address within a minute, that address is refused until the minute is - over; other addresses keep working, and this computer's own addresses are never - refused, so no local program or web page can lock the extension out. + over; other addresses keep working. Requests from this computer itself are never + refused, so no local program or web page can lock the extension out; those a reverse + proxy on this computer forwards are limited together. - **Other accounts on the computer can't read your words.** The database, its `-wal` and `-shm` files, the backups, the token and the model list cache are readable only by you (0600), and a data folder the server makes only by you (0700). At every start it makes diff --git a/server/lib/kotiko/auth_throttle.ex b/server/lib/kotiko/auth_throttle.ex index 6c872f6..64db565 100644 --- a/server/lib/kotiko/auth_throttle.ex +++ b/server/lib/kotiko/auth_throttle.ex @@ -13,16 +13,18 @@ defmodule Kotiko.AuthThrottle do fails; a request with no token, another scheme or a malformed signature can't be a guess. - This computer's own addresses (`Kotiko.RateLimit.loopback?/1`) are never locked out - (slice 54, D-02): every local program shares them, so any of them, or a web page in a - browser here, could otherwise lock the extension out. A token the server made (256 - random bits) can't be guessed anyway, and a weak chosen one gets a warning at start. Addresses are counted as `Kotiko.RateLimit.client/1` says (an IPv6 /64 is one). + Requests count as `Kotiko.RateLimit.peer/1` says (slice 54, D-02). This computer's own + requests without a forwarding header (`:local`) are never locked out: every local + program shares that address, so any of them, or a web page in a browser here, could + otherwise lock the extension out. Loopback requests with a forwarding header, as a + reverse proxy on this computer sends every remote client, are counted together under + one key: a stranger's lockout there locks out everyone behind the proxy for the rest + of the minute, but never the extension. Addresses are counted as `Kotiko.RateLimit.client/1` says (an IPv6 /64 is one). Each lockout is logged once an hour per address, for at most 100 addresses an hour (`Kotiko.Log.Limiter`). `auth_failures_per_minute: nil` turns the throttle off (tests). - Behind a reverse proxy on the same machine every request comes from the proxy's - loopback address, so the lockout doesn't apply there: limit attempts at the proxy, and - keep the token the server made. + A token the server made (256 random bits) can't be guessed anyway, and a weak chosen + one gets a warning at start. """ require Logger alias Kotiko.Log.Limiter @@ -45,23 +47,24 @@ defmodule Kotiko.AuthThrottle do Limiter.reset(@log_table) end - @doc "`:ok`, or `{:locked, seconds_left}` when `remote_ip` sent too many wrong tokens." - def check(remote_ip) do - with false <- RateLimit.loopback?(remote_ip), + @doc """ + `:ok`, or `{:locked, seconds_left}` when `peer` sent too many wrong tokens. `peer` is + `Kotiko.RateLimit.peer/1` of the request, or an address. + """ + def check(peer) do + with key when key != :local <- key(peer), limit when is_integer(limit) <- limit(), - {count, seconds} when count >= limit <- - RateLimit.peek(@kind, RateLimit.client(remote_ip)) do + {count, seconds} when count >= limit <- RateLimit.peek(@kind, key) do {:locked, seconds} else _ -> :ok end end - @doc "Counts a wrong token from `remote_ip`; logs the lockout when it starts." - def failed(remote_ip) do - with false <- RateLimit.loopback?(remote_ip), + @doc "Counts a wrong token from `peer` (as for `check/1`); logs the lockout when it starts." + def failed(peer) do + with client when client != :local <- key(peer), limit when is_integer(limit) <- limit() do - client = RateLimit.client(remote_ip) {count, _seconds} = RateLimit.hit(@kind, client, @window_ms) if count == limit, do: log_lockout(client, limit) end @@ -71,6 +74,15 @@ defmodule Kotiko.AuthThrottle do defp limit, do: Application.get_env(:kotiko, :auth_failures_per_minute, 10) + # A key from RateLimit.peer/1 as it is; an address as a request without forwarding + # headers from it would count. + defp key(:local), do: :local + defp key({:proxied, _} = key), do: key + defp key({_, _, _, _, :"/64"} = key), do: key + + defp key(ip) when is_tuple(ip), + do: if(RateLimit.loopback?(ip), do: :local, else: RateLimit.client(ip)) + defp log_lockout(client, limit) do {decision, unlogged} = Limiter.check(@log_table, client, max_keys: @max_logged) @@ -100,6 +112,8 @@ defmodule Kotiko.AuthThrottle do end end + defp format({:proxied, peer}), do: "requests forwarded by " <> format(peer) + defp format({a, b, c, d, :"/64"}), do: Enum.map_join([a, b, c, d], ":", &String.downcase(Integer.to_string(&1, 16))) <> "::/64" diff --git a/server/lib/kotiko/rate_limit.ex b/server/lib/kotiko/rate_limit.ex index 3918002..a301519 100644 --- a/server/lib/kotiko/rate_limit.ex +++ b/server/lib/kotiko/rate_limit.ex @@ -39,11 +39,36 @@ defmodule Kotiko.RateLimit do :ok end + # Headers a reverse proxy adds for the client it forwards (any value, any case). + @forwarding ~w(forwarded x-forwarded-for x-real-ip x-forwarded-host cf-connecting-ip true-client-ip) + @doc """ - Whether `remote_ip` is this computer: 127.0.0.0/8, `::1`, or an IPv4-mapped 127.x. - Such peers are exempt from the proof limit and the wrong-token lockout (slice 54, - D-02): every local program shares them, so one could lock the extension out. + Who a request counts as for the proof limit and the wrong-token lockout (slice 54, D-02): + + * `:local`: a loopback peer with no forwarding header. Every program on this computer, + and any web page in a browser here, shares it, so a limit there would let any of + them lock the extension out: it is never limited. + * `{:proxied, peer}`: a loopback peer with a forwarding header, as a reverse proxy on + this computer (Caddy, nginx) sends every remote client. Limited, all of them + together: the header's claimed client address can be forged, so it isn't used. + A local program that adds such a header can only lock out this bucket, never the + extension, which sends none. + * otherwise the peer's address as `client/1` counts it. """ + def peer(%Plug.Conn{remote_ip: ip, req_headers: headers}) do + cond do + not loopback?(ip) -> + client(ip) + + Enum.any?(headers, fn {name, _} -> String.downcase(name) in @forwarding end) -> + {:proxied, client(ip)} + + true -> + :local + end + end + + @doc "Whether `remote_ip` is this computer: 127.0.0.0/8, `::1`, or an IPv4-mapped 127.x." def loopback?({127, _, _, _}), do: true def loopback?({0, 0, 0, 0, 0, 0, 0, 1}), do: true def loopback?({0, 0, 0, 0, 0, 0xFFFF, ab, _}), do: ab >>> 8 == 127 diff --git a/server/lib/kotiko/router.ex b/server/lib/kotiko/router.ex index 2a0dd4c..2de1095 100644 --- a/server/lib/kotiko/router.ex +++ b/server/lib/kotiko/router.ex @@ -86,7 +86,7 @@ defmodule Kotiko.Router do not Token.nonce?(nonce) -> error(conn, true, 400, "invalid_request", %{field: "nonce"}) - seconds = too_many_proofs(conn.remote_ip) -> + seconds = too_many_proofs(RateLimit.peer(conn)) -> rate_limited(conn, "too_many_proofs", seconds) true -> @@ -103,14 +103,17 @@ defmodule Kotiko.Router do end end - # Seconds to wait, or nil. This computer's own addresses aren't limited (slice 54, D-02): - # every local program shares 127.0.0.1, so any of them could use up the extension's - # proofs. Strangers on the network keep the limit. - defp too_many_proofs(ip) do + # Seconds to wait, or nil. This computer's own requests (`:local`, no forwarding header) + # aren't limited (slice 54, D-02): every local program shares 127.0.0.1, so any of them + # could use up the extension's proofs. Strangers on the network, and those a reverse + # proxy here forwards (all together), keep the limit (Kotiko.RateLimit.peer/1). + defp too_many_proofs(:local), do: nil + + defp too_many_proofs(peer) do limit = Application.get_env(:kotiko, :proof_requests_per_minute, 30) - if is_integer(limit) and not RateLimit.loopback?(ip) do - {count, seconds} = RateLimit.hit(:proof, RateLimit.client(ip), 60_000) + if is_integer(limit) do + {count, seconds} = RateLimit.hit(:proof, peer, 60_000) if count > limit, do: seconds end end @@ -288,7 +291,7 @@ defmodule Kotiko.Router do # An address that sent too many wrong tokens gets 429 whatever it sends now # (Kotiko.AuthThrottle). defp authorize(conn, _opts) do - case AuthThrottle.check(conn.remote_ip) do + case AuthThrottle.check(RateLimit.peer(conn)) do :ok -> check_token(conn) {:locked, seconds} -> rate_limited(conn, "auth_failures", seconds) end @@ -305,7 +308,7 @@ defmodule Kotiko.Router do if Plug.Crypto.secure_compare(token, expected) do conn else - AuthThrottle.failed(conn.remote_ip) + AuthThrottle.failed(RateLimit.peer(conn)) deny(conn) end @@ -340,7 +343,7 @@ defmodule Kotiko.Router do end defp signature_failed(conn, reason) do - AuthThrottle.failed(conn.remote_ip) + AuthThrottle.failed(RateLimit.peer(conn)) deny(conn, Atom.to_string(reason)) end diff --git a/server/test/kotiko/router_proof_test.exs b/server/test/kotiko/router_proof_test.exs index e38ce9a..2765bd3 100644 --- a/server/test/kotiko/router_proof_test.exs +++ b/server/test/kotiko/router_proof_test.exs @@ -146,6 +146,21 @@ defmodule Kotiko.RouterProofTest do assert prove(%{nonce: @nonce}, ip: ip).status == 429 end + test "proofs through a reverse proxy on this computer are limited together; the extension's aren't" do + put_app_env(:proof_requests_per_minute, 3) + + via_proxy = fn forwarded_for -> + build_conn(%{nonce: @nonce}, ip: {127, 0, 0, 1}) + |> Plug.Conn.put_req_header("x-forwarded-for", forwarded_for) + |> Kotiko.Router.call(Kotiko.Router.init([])) + end + + for i <- 1..3, do: assert(via_proxy.("203.0.113.#{i}").status == 200) + assert via_proxy.("203.0.113.99").status == 429 + # The extension, on the same address without a forwarding header, isn't limited. + for _ <- 1..10, do: assert(prove(%{nonce: @nonce}, ip: {127, 0, 0, 1}).status == 200) + end + test "an unknown Host is refused before the proof" do conn = request("POST", "/api/v1/proof", [], body: %{nonce: @nonce}, host: "evil.example") assert conn.status == 421 diff --git a/server/test/kotiko/router_throttle_test.exs b/server/test/kotiko/router_throttle_test.exs index 243968c..cc65086 100644 --- a/server/test/kotiko/router_throttle_test.exs +++ b/server/test/kotiko/router_throttle_test.exs @@ -72,6 +72,34 @@ defmodule Kotiko.RouterThrottleTest do assert from({198, 51, 100, 99}, auth()).status == 429 end + # A reverse proxy on this computer (Caddy, nginx) hands the server every remote client from + # 127.0.0.1, with a forwarding header: those are strangers and are limited, all together + # under one key (the header's claimed address can be forged). The extension sends no such + # header, so a local program that adds one can only lock the proxied requests out. + test "loopback requests with a forwarding header are limited together; the extension isn't" do + proxied = [{"x-forwarded-for", "203.0.113.9"}] + + log = + capture_log(fn -> + for _ <- 1..10, do: assert(from({127, 0, 0, 1}, @wrong ++ proxied).status == 401) + end) + + assert from({127, 0, 0, 1}, @wrong ++ proxied).status == 429 + # The header's address doesn't matter: every proxied request shares the lockout. + assert from({127, 0, 0, 1}, auth() ++ [{"x-forwarded-for", "198.51.100.1"}]).status == 429 + + for header <- ~w(forwarded x-real-ip x-forwarded-host cf-connecting-ip true-client-ip) do + assert from({127, 0, 0, 1}, auth() ++ [{header, "x"}]).status == 429, header + end + + assert from({0, 0, 0, 0, 0, 0, 0, 1}, auth() ++ [{"x-forwarded-for", "x"}]).status == 200, + "another loopback address is another proxy" + + # The extension (no forwarding header) still gets in from 127.0.0.1. + assert from({127, 0, 0, 1}, auth()).status == 200 + assert log =~ "wrong API tokens from requests forwarded by 127.0.0.1" + end + test "requests without a token don't count; the lockout line is logged once" do ip = {203, 0, 113, 10} for _ <- 1..20, do: assert(from(ip, []).status == 401) From f888a22f669e9a3ceade771696ff84298ff4bfad Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Wed, 7 Oct 2026 13:09:28 -0400 Subject: [PATCH 7/7] test(extension): content tests wait for the page to go quiet, not one task MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The engine swaps in 8 ms slices, so on a busy machine a test's last block could swap a slice after load() returned, and the assertion saw the old text: "a tabindex=0 chip…" failed CI this way on #36, a PR that only changes .gitleaks.toml. load() now waits until the page has had no change for 50 ms (at most 3 s). Under heavy load (128 parallel runs of the file), failing runs went from 30 to 8; the 8 left are one older test whose proper-noun check has a time limit of its own (recorded on SCR-764). Co-Authored-By: Claude Opus 5.5 Signed-off-by: Ayla Croft --- test/dom/content.test.mjs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/test/dom/content.test.mjs b/test/dom/content.test.mjs index 0a1ff43..de1662f 100644 --- a/test/dom/content.test.mjs +++ b/test/dom/content.test.mjs @@ -52,6 +52,18 @@ function pinDay(win) { win.Date = TestDate; } +// The engine swaps in 8 ms slices, so on a busy machine the last block can swap a few +// slices after the first sync request. Waits until the page has had no change for 50 ms +// (at most 3 s), instead of assuming one task is enough. +async function quiet(win, { ms = 50, max = 3000 } = {}) { + let last = Date.now(); + const mo = new win.MutationObserver(() => (last = Date.now())); + mo.observe(win.document, { subtree: true, childList: true, characterData: true, attributes: true }); + const end = Date.now() + max; + while (Date.now() - last < ms && Date.now() < end) await sleep(10); + mo.disconnect(); +} + async function load(html, { words = WORDS, url = "https://example.com/", beforeInject, lang = "en", detect = null, ...local } = {}) { // As the background writes it: rules for the learner's bases (theirs, else their words'). if (!("baseRules" in local)) local.baseRules = baseRulesFor(local.baseLangs ?? basesOf(words)); @@ -73,7 +85,7 @@ async function load(html, { words = WORDS, url = "https://example.com/", beforeI // content.js starts swapping a task after it loads (the segmenter's warm-up, slice 15); // asking the background for a sync is the last thing it does. for (let i = 0; i < 400 && !fake.calls.sendMessage.some((m) => m.type === "sync"); i++) await sleep(5); - await sleep(0); + await quiet(dom.window); const doc = dom.window.document; return { dom,