halkyon/docs/adr/0002-no-end-to-end-encryption.md
Tizian 「ティツィアーン」 549406b154
Some checks failed
Analyze / analyze (push) Has been cancelled
Format / format (push) Has been cancelled
Tests / test (push) Has been cancelled
docs(adr): 0002 no end-to-end encryption
Private and shared content is stored in plaintext on the instance and is readable by its operator, as on Mastodon. Private sharing happens only between persons of the same instance; only public content travels between instances. The key management that encryption needed (master key, recovery phrase, per-space keys, re-wrapping, compaction on devices only, signed operations) was the largest subsystem on the plan, and it shut the server out of grammar checking, search and publishing. Whoever trusts no operator can run an instance.

Rules: the privacy page and a notice when sharing say so; deleting content first blanks the payloads of its revisions, because the sync engine keeps rows until their space is deleted; content stays in the versioned payload, because every migration of a synced table locks older app versions out of syncing; server features decode payloads into server-only tables; reports and suggestions go through endpoints, so the author comes from the session. keyId and nonce stay nullable as the one way back.

ADR 0001 gets an "Amended by" note; its decision and rules stand.

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

6.8 KiB

ADR 0002: No end-to-end encryption; private data is entrusted to the instance

  • Status: accepted
  • Date: 2026-09-29
  • Amends: ADR 0001, 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.