234 lines
12 KiB
Markdown
234 lines
12 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 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 `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 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`.
|