Kokkoja: first public assembly
Assembled from the development repository by tools/make_release.sh. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
commit
95c80d04c9
39 files changed
+9905
No files matched your search
@@ -0,0 +1,234 @@
|
||||
# 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`.
|
||||
Reference in new issue
Block a user