halkyon/docs/adr/0002-no-end-to-end-encryption.md
Tizian 「ティツィアーン」 074d05ab4e
docs(adr): private studysets (ADR 0019)
The developer's decisions of 21 Oct: private studysets ship with build 22;
a data key per private studyset and a personal key for tags and settings,
sealed to the account and kept in a synced table; files encrypted too; a
release's tag names and the copies counted come from the device; the shape,
size and rhythm of studying stay readable and are listed; existing
studysets get one offer; accounts without keys at the register wait for a
password. ADR 0002 and ADR 0017 point to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:13:08 +02:00

113 lines
6.9 KiB
Markdown

# ADR 0002: No end-to-end encryption; private data is entrusted to the instance
- **Status:** accepted
- **Date:** 2026-09-29
- **Amended by:** [ADR 0019](0019-private-studysets.md), which takes the way back below:
private studysets are end-to-end encrypted.
- **Amends:** [ADR 0001](0001-offline-sync.md), whose context assumed that private and
shared data would be end-to-end encrypted later.
## Context
Until now, private and shared studysets were to be end-to-end encrypted after the
hackathon, and the content envelope (`audience`, `keyId`, `payload`, `nonce`) was designed
for it. Counted up, that encryption is the largest subsystem on the plan:
- a master key per person, kept in each device's keystore (with a fallback for Linux
desktops without a keyring), and a recovery phrase;
- per-studyset content keys, re-wrapped when two persons are merged;
- a key per shared space, wrapped for each member, plus a teacher's wrapping so that a
pupil's key loss doesn't lose class material;
- compaction of revision history only on a member's device, with the other members
accepting its snapshot, and signed operations, so that authorship can be trusted
without the server;
- a review of every plain column for what it leaks (a public card reference in the review
log tells the server what someone studies and when).
It also shuts the server out of every feature that reads content: grammar checking needs
opt-in rules and alternative backends, the server can't search or sort, and publishing
needs a device to decrypt a snapshot and upload it.
What it would buy: content protected from the instance's operator, its hosting provider, a
breach of the server, and legal orders. For flashcards and lesson material that is rarely
what people need. Whoever trusts no operator can run their own instance, which is a goal of
the project anyway. Mastodon works the same way: people trust their instance, and Mastodon
tells them that private posts aren't end-to-end encrypted.
## Decision
- **No end-to-end encryption.** Private and shared content is stored in plaintext on the
instance, readable by its operator. It is protected in transit (TLS) and in off-site
backups (restic encrypts before uploading), which guards against stolen storage, not
against the operator.
- **Private sharing stays within one instance.** A shared space only ever holds persons of
the same instance; that is also all the sync engine's spaces can express, since they live
in one server's database. Between instances, only public content travels (the public
tier).
- **Treated as permanent.** Once server features read plaintext, encryption can't return
wholesale (see *Way back*).
## Rules that follow
1. **Say it where it matters.** The privacy page states that the instance's operator can
read your content and that you can run your own instance. Sharing shows a short notice
that shared content isn't end-to-end encrypted. Nothing in the app or its store listings
implies encryption.
2. **Redact before delete.** The sync engine never removes a row physically before its
whole space is deleted (ADR 0001). Deleting content therefore first blanks the `payload`
of each of its revisions (a column-scoped update, which syncs like any other edit), then
deletes. Compacting old revisions uses the same mechanism. The privacy page states the
backup retention, because backups keep blanked content until they expire.
3. **Content stays in the versioned payload.** No longer to hide it from the server, but
because every migration of a synced table locks older app versions out of syncing until
they update: the engine compares a hash of the synced tables' schemas when a device
connects. A new content field changes the payload's schema version, not a synced table.
4. **Server features read payloads through one shared codec** (a pure Dart package used by
app and server) and keep what they derive (a search index, statistics) in
**server-only tables**. Those aren't synced, so they migrate freely and can be rebuilt
from the payloads at any time.
5. **Authorship that must be trustworthy goes through endpoints.** Reports, suggestions and
moderation actions are endpoint calls into server-only tables, and the server takes the
author from the session, so they can't be forged.
6. **`keyId` and `nonce` stay** in the envelope, nullable, `null` meaning plaintext. They
cost nothing and keep the one way back open.
## Consequences
- **Removed from the plan:** master key and device keystores, recovery phrase, key
re-wrapping on account merge, per-space keys and their wrapping, compaction on devices
only, the leak review of plain columns. A person who loses every device signs in again
and has everything. (Anonymous accounts still can't sign back in; that is unchanged.)
- **Simpler:** grammar checking runs on the instance without opt-in rules (the gateway
stays, for other backends); publishing can build its snapshot on the server; the server
can notify people (e.g. about a changed card in a shared studyset) and act on reports.
- **Lost:** protection against the operator, the hosting provider, a server breach and
legal orders, and erasure by deleting keys. Every operator, including that of
`learn.halkyon.eu`, holds everyone's content in plaintext and carries the duties that
come with it: data protection law (GDPR) and acting on reports.
- **Sync is unchanged.** Offline devices still write concurrently, and the engine still
keeps only the latest value of a field, so ADR 0001's rule 2 (content as append-only
revisions, conflicts shown) stands. One objection against the engine disappears: with
encryption, the server could only have merged whole blobs.
- **Authorship of revisions** in shared spaces no longer needs signatures for encryption's
sake. A revision written through sync carries an `authorId` that other members of the
same space could forge: the engine only checks the space role and has no hook to inspect
incoming rows. Whether members are trusted or devices sign revisions with keys the server
vouches for (which needs no key recovery) is decided with shared spaces.
## Alternatives considered
- **Encrypt as planned.** Rejected for the cost above, against a protection the content
rarely needs and that self-hosting offers to those who do.
- **Encrypt private data only, keep shared data plain.** Most of the cost remains (master
key, recovery phrase, keystores), and every feature would have to follow two sets of
rules.
- **Private sharing across instances,** the way Mastodon delivers private posts to other
instances. It would put private data into federation and let two operators read it; not
needed for learning together, where a class or group can choose one instance.
## Way back
Opt-in encryption for individual private studysets remains possible: their revisions would
carry a `keyId`, and server features would skip them, as the grammar gateway would. Anything
wider means migrating existing data and redesigning every feature that reads plaintext.