2026-10-06 15:58:02 +02:00
# 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 at `http://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 a
2026-10-11 16:59:03 +02:00
`config.json` beside it, so that a guest can join, and a host can
start a session, without the local program. `deploy/` has the server
setup.
2026-10-06 15:58:02 +02:00
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
2026-10-10 19:01:20 +02:00
the smallest such thing: a WebSocket server that passes messages among
the members of a session. What a user calls a session, the relay calls a
*room* : the word appears in the relay's code and log and in the address a
client connects to, `wss://<server>/relay/room/<room id>` , and nowhere
else. The relay gives each connection a number, tells the others 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, nothing of the session remains at the relay. It
holds at most 32 members per session and 500 sessions, and refuses
messages over 1 MB; clients split anything larger.
2026-10-06 15:58:02 +02:00
## 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
2026-10-10 19:01:20 +02:00
names the session at the relay, and the *message key* , a 256-bit AES-GCM
2026-10-06 15:58:02 +02:00
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
2026-10-10 19:01:20 +02:00
secret stays between the host and the guests. The Session window also
shows the link as a QR code, so that a phone or a tablet in the room can
join by camera; the encoder is Kokkoja's own (`qr_code.js` : byte mode,
error correction level M, any version), checked by the tests against
Nayuki's reference encoder.
The client writes its singular events (pages, images, the connection,
the co-host right, the first input report) to the browser's console
and keeps them, with a trace of every press, release and cancellation
of the pointer (an eraser's release says how many strokes and text
blocks it removed), every change of tool and the touch events, in
memory (`log.js` , the last 400 entries). The Session window shows the record with a Copy button,
so that a guest on a tablet, where there is no console, can send it
when the pen misbehaves. The record stays in the browser tab; nothing
of it is sent anywhere by Kokkoja.
2026-10-06 15:58:02 +02:00
## 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
2026-10-10 19:01:20 +02:00
key. When a client connects to a session it announces itself with a signed
2026-10-06 15:58:02 +02:00
`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,
2026-10-10 19:01:20 +02:00
images and the list of co-hosts are accepted only
2026-10-06 15:58:02 +02:00
from the host (or from an allowed guest, for pages and images).
## Pages
2026-10-10 19:01:20 +02:00
Page coordinates are in units of 0.1 mm. A blank page is an A4 sheet
2026-10-11 19:43:17 +02:00
in landscape, 2970 by 2100 units. "Expand" makes the sheet fill the
window in both directions, and
2026-10-10 19:01:20 +02:00
"Shrink" makes it the bounding box of everything on the page plus the
greater of the two margins, exactly, on every side; both are changes to the page like adding
an image, carried in the page descriptor (its `left` and `top` edges and
its size) with the images' revision raised. Writing within 5 mm of an edge grows the page by
5 mm on that side, counted from the sheet's edge. An image is placed at its natural size, taken from
2026-10-06 15:58:02 +02:00
the resolution recorded in the file, or 0.1 mm per pixel when there is
2026-10-10 19:01:20 +02:00
none (the nominal 72 and 96 dpi that cameras and screenshots write count
as none), centred across the page, 5 mm from the top or from the image
above, the sheet widened equally on both sides if it is wider than the
page; only one wider than a page can be, 2 m, is scaled down. A page holds at most 12 images and 24 MB of image data.
2026-10-06 15:58:02 +02:00
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.
2026-10-10 19:01:20 +02:00
A text block is free text, or a *text rectangle* with a width and a
height, drawn with a red frame on a white ground; the ground is drawn
beneath every layer, so that it hides an image and never anyone's
writing, and the frame within the owner's layer. A *Hide* rectangle is
a page object like an image, in the page descriptor and the page file,
drawn white above every layer with blue corners on screen; made, moved
and removed by the host and co-hosts. Both are moved by a hold of
the pen and resized from their corners, like images but with their
shape free; an image keeps its shape.
2026-10-06 15:58:02 +02:00
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.
2026-10-11 13:17:22 +02:00
Undo is personal, since nobody can change another's layer, and is a
stack of one's own gestures kept in the browser tab for the page being
written: a stroke; the strokes and text blocks one movement of the
eraser removed, kept whole; a text block as it was before an editing or
a move; a Hide rectangle as it was before it was made, moved or removed.
Undoing sends the ordinary messages, except that erased strokes come
back by a `stroke_restore` message carrying them whole. Images are not
in the stack, because their data is not kept. The stack is cleared when
the page changes and is not in the page file, so a reloaded tab starts
with nothing to undo.
2026-10-06 15:58:02 +02:00
## 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
2026-10-10 19:01:20 +02:00
the user asks (**Keep**), and by the program whenever `Current.svg`
2026-10-06 15:58:02 +02:00
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
2026-10-11 16:59:03 +02:00
page is blank or the same page is already kept in that notebook; a
page loaded from a notebook and left unchanged is therefore not kept
again, although the file names it as a new page. A kept page or a
2026-10-11 18:05:49 +02:00
whole notebook can be deleted from the Notebooks chooser, whose
tooltips describe each page (from the page file itself, which the
chooser fetches for the picture) and each notebook (from the listing,
which names the pages and says when each was last written); the local
2026-10-11 16:59:03 +02:00
program moves it to the system's Trash (the freedesktop Trash on
Linux, with the info file the file manager wants for putting it back;
`~/.Trash` on macOS) unless `--trash` says otherwise, and the
browser's notebook, having none, deletes for good.
2026-10-06 15:58:02 +02:00
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 `hello` is 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
2026-10-10 19:01:20 +02:00
on Linux and macOS. The browser's console shows what arrives. When a
2026-10-06 15:58:02 +02:00
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.
2026-10-10 19:01:20 +02:00
## Adjustable choices
Some choices of the interface are named constants at the top of the
file that uses them, so that they can be changed in one place, or offered
as preferences later: the line widths and the five colours (`main.js` ),
the zoom step of the wheel and the zoom per 100 pixels of a pen drag
(`renderer.js` ), the dead zone of that drag (`pen_input.js` ), the margin
2026-10-11 17:47:04 +02:00
by which the page grows (`page_model.js` ), the tooltip delay (1 s,
`DELAY_MS` in `tooltip.js` ) and how long after one tooltip the next
2026-10-11 19:32:13 +02:00
button's comes at once (1 s, `WARM_MS` there), the time within which a press after the window comes to the front is taken for the click that raised it and draws nothing (250 ms, `FOCUS_SETTLE_MS` in `pen_input.js` ), the hold that moves an image (600 ms, with 6 pixels of
2026-10-10 19:01:20 +02:00
slack, `pen_input.js` ), the reach of an image's corner (24 pixels,
`renderer.js` ), the smallest an image can be scaled to (1 cm,
`page_model.js` ), how often a dragged image's place is sent (40 ms,
2026-10-11 13:17:22 +02:00
`session.js` ) and the points of a stroke (25 ms, `session.js` ), the
interval of the eraser's double tap (400 ms, `DOUBLE_TAP_MS` in
`main.js` ) and how many gestures can be undone (100, `UNDO_LIMIT` in
`session.js` ), the colour and width of a text rectangle's frame
2026-10-10 19:01:20 +02:00
(`page_file.js` ) and whether the frame is shown whole or as corners on
screen (`TEXT_FRAME_STYLE` in `renderer.js` ; the file and the export
always hold the whole frame).
2026-10-06 15:58:02 +02:00
## 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` .