Assembled from the development repository by tools/make_release.sh. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
12 KiB
How Kokkoja works
This part is for the curious, for anyone who wants to run a Kokkoja server, and for anyone who wants to read or change the code. It describes the parts, what travels between them, what is protected and what is not, and how pages are stored. The code is the final word; it is short and commented.
The parts
Kokkoja is three programs and a few static files.
- The client (
client/) runs in the web browser and does everything the user sees: the pen, the page, the text, the encryption, the saving. It is plain JavaScript modules, run by the browser as written, with no build step and no third-party packages. Its types are written in JSDoc comments and checked by the TypeScript compiler during development. - The local program (
kokkoja.py) runs on the user's computer. It serves the client to the browser athttp://localhost:8760/, keeps the user's identity, and reads and writes the notebook directories, which a web page cannot do on its own. It uses nothing but Python's standard library. - The relay (
relay/kokkoja_relay.py) runs on a server that every participant can reach. It passes messages between the members of a session and nothing more: it cannot read them and it stores nothing. It is Python with one library,websockets. - The web site is the
client/directory served over HTTPS, with aconfig.jsonbeside it, so that a guest can join without the local program.deploy/has the server setup.
Every participant's computer holds the whole page. Only changes travel: a stroke as it is drawn, a text block as it is typed, an erasure, a new page, an image once. There is no central copy.
Why the relay exists
Two computers behind home routers generally cannot connect to each other
directly, so something in between has to pass the messages. The relay is
the smallest such thing: a WebSocket server with rooms. A client
connects to wss://<server>/relay/room/<room id>; the relay gives the
connection a number, tells the room that a peer has arrived, and from
then on sends every message it receives from one member to all the
others, stamped with the sender's number. When the last member leaves,
the room is gone. It holds at most 32 members per room and 500 rooms,
and refuses messages over 1 MB; clients split anything larger.
Sessions and encryption
The invitation is
<relay address>#<secret>.<host id>
The secret is 16 random bytes, made by the host's browser. From it every client derives two values with HKDF-SHA-256: the room id, which names the room at the relay, and the message key, a 256-bit AES-GCM key with which every message is encrypted before it is sent and decrypted on arrival. The relay sees the room id, the members' network addresses, and the size and timing of encrypted messages. It cannot compute the key from the room id, so it cannot read anything. Neither can anyone watching the network between the participants and the relay, which is TLS-protected in any case.
A link invitation wraps the same text:
https://<site>/#join=<invitation>
Browsers never send the part of an address after # to the site, so the
secret stays between the host and the guests.
Identity and layers
Each participant has a signing key pair (ECDSA on P-256), made in the
browser the first time and then kept, by the local program in its
configuration directory or by the browser in its own storage. The
participant id is the first 16 bytes of the SHA-256 hash of the public
key. When a client joins a room it announces itself with a signed
hello carrying its name and public key; the signature covers the room
id and the peer number the relay stamped on the message, so nobody can
replay it elsewhere or claim another peer's connection. Until a peer has
sent a valid hello, everything else from it is ignored.
A page is a stack of layers, one per participant, keyed by participant id. A client accepts a change to a layer only from the participant whose id it is, so the rule "nobody can change anyone else's writing" is not a permission that could be misconfigured but a consequence of how the data is organised. Because the id is derived from the key, and the key is kept, a person is the same participant in every session from the same computer, and their writing on a page loaded weeks later is still theirs.
The host is whoever holds the key whose id is in the invitation. Pages, images and the list of guests allowed to start pages are accepted only from the host (or from an allowed guest, for pages and images).
Pages
Page coordinates are in units of 0.1 mm. A blank page is an A4 sheet, 2100 by 2970 units. Writing within 8 mm of an edge grows the sheet by 8 mm on that side. An image is placed at its natural size, taken from the resolution recorded in the file, or 0.1 mm per pixel when there is none, centred across the sheet, 8 mm from the top or from the image above, scaled down if wider than the sheet inside its margins (or, if the person adding it chooses, placed at natural size with the sheet widened to hold it). A page holds at most 12 images and 24 MB of image data. An image file over 2400 pixels on a side or 1.5 MB, or not PNG or JPEG, is redrawn at most 2400 pixels and stored as JPEG before it is shared; smaller PNG and JPEG files are shared byte for byte.
A stroke is a list of points with pressure, and the line width is a function of pressure: nominal at a pressure of 0.4, about 2.6 times that at 0.75. Points are sent while the stroke is drawn, so the others see it grow. A text block is sent whole at every keystroke, with its line breaks already laid out by the writer's browser, so that everyone shows the same lines.
Each page has a number, counted from 1 in the session, and a random id. A client that joins late asks the longest-connected member for the layers of absent participants, and receives each present participant's layer from that participant. A client that reconnects after a dropped connection sends its whole layer again, so that nothing drawn meanwhile is lost.
The page file
A notebook is a directory. The page being written is Current.svg in
it, rewritten a moment after every change (at most every 400 ms while
writing continues, at least every 2 s). A kept page is a copy of
Current.svg named YYYY_MM_DD_NN.svg, the day being when the page was
last changed and NN a serial number within that day. A copy is made when
the user asks (Keep page), and by the program whenever Current.svg
is about to be given to another page: when the session moves to a new
page, when the notebook is changed, and when a session begins and finds
a page of another session in the notebook. Nothing is copied if the
page is blank or an identical copy already exists.
The file is an SVG document that any viewer displays: the images as
embedded <image> elements, each layer as a group with the participant's
name and id, each stroke as a path and each text block as text.
Beside the shapes, the same elements carry Kokkoja's own data in
attributes, the exact points and pressures, the text and its breaks, so
that Kokkoja reads the page back without loss, with each layer still
belonging to its participant. The session id and host id are recorded
too, which is how a reloaded tab knows that Current.svg is its own page
to continue from.
With the local program, the notebooks are in Kokkoja in the Documents
directory (--notebooks chooses another place), the identity and the
browser's token in the configuration directory (~/.config/kokkoja on
Linux, ~/Library/Application Support/kokkoja on macOS). In the
browser, the notebooks are in the site's IndexedDB under the same names
and rules, and the identity in its localStorage; a kept page and an
exported picture are offered as downloads, since that is the only way a
web page can hand a file to its user.
The local program and its token
The local program listens on 127.0.0.1 only. Any web page open in the
browser could nevertheless send requests to localhost:8760, so every
request that touches files must carry a secret token, which the program
makes once, keeps in its configuration directory, and passes to the
browser in the address it opens (#token=..., which the browser keeps
in its local storage and never sends to any server). Requests without
the token are refused. The program also checks that requests come from
its own origin. Outside the notebooks it reads only image files and
writes only PNG files, each where the file chooser was pointed.
The web site, and whom you trust
A client served by a web site is a web application like any other: the site operator's code runs in your browser. The end-to-end encryption protects the session from the relay and the network, but a dishonest site could serve a client that gives the key away. Participants who run the local program load the client from their own disk and are not exposed to that; they trust the code they downloaded once. The site sends a Content Security Policy that lets the client load nothing from anywhere else, which protects against the ordinary web attacks but not against the operator. For a lesson this is the same trust one places in any video call service, and considerably less than in one that sees the content; it is stated here so that nobody is surprised.
Running a server
A server is a machine with a public name, TLS and nginx (or any reverse
proxy that passes WebSockets). deploy/README.md describes the one
Kokkoja comes with: the relay as a systemd service bound to localhost,
nginx terminating TLS, serving the client's files at https://<name>/
and forwarding wss://<name>/relay/ to the relay, and a config.json
telling the client which relay to use and that notebooks are kept in the
browser. A host uses the server with
python3 kokkoja.py --relay wss://<name>/relay
and invitations become links to https://<name>/. One server serves
any number of hosts and sessions at once; the relay's work per session
is passing a few kilobytes per second. Since the relay cannot read what
it passes, a server run by someone else is a matter of availability, not
of privacy; the web site is the part that requires trust, as described
above. A teacher who wants no dependence on anyone's server needs a
machine of their own that is reachable from the Internet, which a
computer behind a home router is not without arrangement.
What is not protected yet
- Only
hellois signed. A participant working together with a dishonest relay could forge another participant's strokes. Signing every message, or page descriptors with the host's key, would close this. - A participant can announce a page with a higher number than the host's, and the others move to it.
- The relay sees who talks to whom, when, and how much.
Pen input
The client uses the browser's pointer events, which deliver every sample a pen produces, with pressure, in Firefox and in Chromium-based browsers on Linux and macOS. The panel's last line shows what arrives. When a pen has been used, touches are ignored, so a hand resting on a touch screen does not draw; this rule has not yet been tested with a device that has both a pen and a touch sensor. Kokkoja opens none of the browser's system dialogs (the colour palette and, with the local program, the file chooser are its own), because one such dialog crashed Firefox on Linux.
Development
The code is Python and JavaScript only. The tests run with
.venv/bin/python -m unittest discover tests -v
where tests/test_end_to_end.py drives a headless Firefox through
geckodriver with several participants, with and without the local
program, and npm run check type-checks the client (the TypeScript
compiler is the only development dependency). The relay's --host and
--port and the local program's options are listed by --help.