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>
113 lines
6.9 KiB
Markdown
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.
|