From the development repository at 62e0967. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
309 lines
16 KiB
Markdown
309 lines
16 KiB
Markdown
# 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
|
|
`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.
|
|
|
|
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 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.
|
|
|
|
## 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 session 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. 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.
|
|
|
|
## 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 connects to a session 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 co-hosts 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
|
|
in landscape, 2970 by 2100 units. "Widen" makes the sheet wider, and
|
|
"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
|
|
the resolution recorded in the file, or 0.1 mm per pixel when there is
|
|
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.
|
|
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 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.
|
|
|
|
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.
|
|
|
|
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.
|
|
|
|
## 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**), 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 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
|
|
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
|
|
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.
|
|
|
|
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
|
|
on Linux and macOS. The browser's console 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.
|
|
|
|
## 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
|
|
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
|
|
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
|
|
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,
|
|
`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
|
|
(`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).
|
|
|
|
## 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`.
|