This RFC proposes how freeq DMs become private without becoming losable. Each conversation encrypts under a stable key; the server stores only ciphertext; all conversation keys live in an encrypted keyring, unlocked by a secret that only the user's own devices ever hold — carried to a new device by an add-device handoff, or restored after total loss from a user-held recovery secret. The per-message ratchet retires as the DM transport. The PDS holds nothing — neither secret nor backup: preferences are readable and writable by every app the user has ever authorized (§5). The keyring synchronization model — device-local working copies, one home-server serializer, restore-only backups — and its open questions are §7.
Draft v0.1, 2026-08-07 — for contributor discussion.
- DMs between users must not be server-visible.
- A user must never lose their own conversation history: switching clients keeps every message readable.
Standing principle behind requirement 2: a platform where users cannot read their own conversation history is useless; no crypto work may trade history away silently. Guests have no DID and no encryption; their DMs stay signed plaintext, out of scope throughout.
E2EE Phase 1 (February, 0fed8406) shipped two things: channel encryption (+E, one passphrase-derived static key — has worked ever since, still works, untouched by this RFC) and a DM scheme (X3DH + Double Ratchet, per-message keys) wired to engage automatically whenever possible. In practice the DM scheme never engaged anywhere a user could see: browser initialization always failed (a private-key export call browsers refuse), and until 2026-08-06 the JS and Rust implementations used incompatible constructions, so a web↔native encrypted DM could never have decrypted regardless. The de facto product, February to now: every DM from every deployed client is signed plaintext — server-visible, durably stored, identical on every device. Requirement 2 met trivially (nothing was ever encrypted), requirement 1 not met at all.
The ratchet DM path has never been enabled in any release. It ran briefly on one test deployment (irc.zerosum.org, 2026-08-06/07) after its initialization bug was fixed; first human testing found what §3 documents, and 2bb1cf34 reverted DMs to signed plaintext, where they remain pending this RFC.
The first human browser↔browser pass (2026-08-07, on the test deployment) measured the ratchet DM path directly: the crypto layer is correct — cold X3DH establishment via an ENC4 opener, both directions decrypting live, server storing only ciphertext, signatures over ciphertext verifying valid/client-session-key (msgids 01KZEBY7PZ…, 01KZECE21W…, 01KZECEAA7… on irc.zerosum.org) — and the product around it does not exist: the only readable copy of a sent message was a 60-second in-memory cache, reloading a tab permanently blanked conversation history to [encrypted message], key publication raced (silent plaintext fallback until the recipient's browser happened to publish), and every conversation was readable on exactly one device per side. Per-message forward secrecy guarantees these outcomes unless surrounded by per-device history stores plus linked-device machinery (Signal's model) that nobody has planned here.
The pattern every system satisfying both requirements uses (WhatsApp encrypted backups, Apple Advanced Data Protection, Matrix key backup): the server stores ciphertext history durably; the keys travel in a container only the user can open.
- One stable key per DM conversation, produced by the existing X3DH handshake at first contact. The handshake, the ENC4 intro carriage, and signing-over-ciphertext all survive unchanged from current code; the per-message ratchet retires as the DM transport (code remains for future ephemeral modes).
- Messages encrypt under the conversation key; the server stores ciphertext (as today). Any device holding the key decrypts the full server-side history on demand — the mechanism that already works for
+Echannels, which is the half of Phase 1 that has always functioned. - All conversation keys live in one keyring: a single blob, encrypted client-side, stored server-side — a server holding what it cannot open, the same posture freeq servers already have toward message ciphertext. Which server(s) store it is the open federation question (§7); whatever answers it must provide the keyring's hot-path properties: fetchable at device onboarding from wherever the user connects, and conditional writes so concurrent devices can't clobber each other's updates.
- The unlock secret exists only on the user's devices. It is generated at E2EE setup and moves exactly two ways: add-device handoff — an existing device hands it to a new one (QR / copy), which is the everyday path — and a user-held recovery secret saved at setup (file, password manager, printed code; the client makes completing this step part of enabling E2EE, the standard recovery-code ceremony). After losing every device, the recovery secret restores everything; without it, encrypted history is gone — stated to the user at setup, not discovered later.
- No key material in the PDS. Investigated and rejected — §5.
The attractive early version of this design stored the keyring unlock secret in the user's PDS ("login is recovery"), with the sealed blob backed up there too. Both fell to the same underlying fact about what PDS private storage is today: PDS preferences — the only private PDS state that exists today — are readable AND writable by every app the user has ever authorized.
- Secret storage: rejected on read access. A decryption secret in preferences is exposed not to one accountable operator but to every service the user ever OAuth'd, bounded by the least trustworthy one. Key material under those terms is disqualified.
- Sealed-blob backup: rejected on write access.
putPreferencesis whole-blob; any authorized app can silently clobber or delete the backup — and a restore artifact is only ever consulted during a disaster, so its destruction is discovered exactly when it is unrecoverable. An emergency backup whose existence depends on the write hygiene of every authorized app fails its single job. Secondary reasons: the sealed blob's size and update timing leak encrypted-DM metadata (conversation count, activity) to every authorized app, and preferences carry no published durability guarantee and survive PDS migration only via conforming tools. - No derive-instead-of-store alternative exists: apps cannot ask a PDS to sign arbitrary data, so nothing identity-bound can deterministically regenerate a secret.
- This is outside atproto's current scope, by their own deliberate design: the in-progress auth-scopes work adds per-endpoint granularity rather than per-app data isolation, and the Private Data Working Group — which is actively building toward richer private records — estimates them "at least a year off," with PDS-side encryption at rest an explicit non-goal ("PDS has full visibility into the data and ACLs") and E2EE named "an entirely separate mechanism." Reasonable scoping decisions for their layer; they just mean the keyring's privacy properties have to come from ours.
When atproto lands private storage with per-app read and write isolation — a direction the working group is actively exploring — PDS-held convenience copies are worth revisiting (§9). Until then: no key material, no backups, no role.
- Key material exists only on the user's devices and wherever the user keeps the recovery secret. No server — freeq or PDS — can decrypt DM history, alone or in collusion, without compromising a user device or the recovery secret itself.
- A leaked conversation key exposes that conversation's stored history — no per-message forward secrecy. This is the explicit trade requirement 2 makes;
+Echannels already live with it. The RFC's claim is not "Signal-grade crypto"; it is "honest crypto with history that works." - The total-loss floor: losing every device AND every server-held sealed copy AND the user-held export loses encrypted history — mathematically forced by requirement 1: a server that cannot read your messages cannot restore them unaided.
- DM pre-key bundles remain server-served (the existing, RFC-acknowledged root-of-trust gap): a malicious freeq server could substitute keys at first contact. Unchanged by this design; the eventual fix is DID-anchored keys (the existing roadmap direction).
The shape this RFC proposes, produced by working the federation and multi-device cases: device-local working copies, one home-server serializer, restore-only backups. Every device that has been through handoff holds the keyring and secret locally and operates without any server dependency; all keyring writes go to a single user-designated home freeq server (compare-and-swap, version history); a new conversation started on one device reaches the others through the home copy (write-behind up, fetch down — triggered by an unknown key-id on arriving ciphertext, or at connect); backups exist strictly for restoration, never as live read/write paths. Federation-level replication of keyrings is rejected outright (§8): servers cannot decrypt, therefore cannot merge, so replicating an opaque blob across servers is multi-master on an unmergeable object; devices CAN decrypt and merge, and one serializer removes the multi-master problem entirely.
Each component carries an open question for contributor discussion — none are settled:
- Home designation and relocation. How a home server is chosen (default: where E2EE was first enabled?), how it is recorded/discovered by a fresh client, and the deliberate move procedure (home shutdown, user choice). Never automatic.
- New-device onboarding away from home. Handoff from an existing device carries the sealed blob AND the secret (both sit on the old device), making the common case home-independent. The residual case — recovery code only, connecting through a non-home server — needs the fetch mechanics: DID-proved direct fetch to home, or relay over the connected server's S2S link.
- Write authorization. Session-auth alone lets a hijacked login destroy the keyring (destruction, not disclosure — the blob is sealed). Proposed: an update keypair derived from the unlock secret, public half registered at keyring creation; the server verifies every write's signature, so only secret-holders can write, on home and backup servers alike. A monotonic version counter inside the ciphertext lets devices detect rollback; server-side version history makes even a compromised-device write recoverable. The residual writer is a compromised device holding the secret — at which point read access is already lost and write access is no longer the interesting problem.
- Keyring content format. Grow-only set of immutable entries — (conversation, key-id, key) — so concurrent device writes union cleanly and CAS losers merge-and-retry losslessly. Ciphertext on the wire carries the key-id it was encrypted under, so multiple keys per conversation coexist (which the concurrent same-peer-first-contact race produces, and which future key rotation needs anyway). The deterministic winner rule for new sends needs specifying.
- Backup topology. With the PDS excluded (§5): a user-designated secondary freeq server holding the sealed blob under the same signed-write protections, and/or the user-held export (recovery code, optionally plus a blob snapshot). Write-behind cadence for the secondary (every keyring change, not periodic — a stale emergency backup is a silent history loss). Whether a secondary server is required or optional for v1.
- Availability behaviors, enumerated. Home down: existing devices unaffected (local copies), new conversations work (queued write-behind; sibling devices lag on the new key until home returns), handoff onboarding works (blob travels device-to-device), recovery-code-only restore degrades to the secondary/export. These degradations should be acceptance-tested, not assumed.
- The per-server history scope fact. Message ciphertext is per-server today (plaintext included): unlocking the keyring on server Y decrypts what Y holds. v1's requirement 2 means "any client, same server"; the keyring makes keys portable, and cross-server history portability is a pre-existing, separate problem (§9) this design must not worsen.
- Signal's model (per-device fan-out, linked devices, per-device history): satisfies requirement 1 maximally, fails requirement 2 without device-transfer machinery several times this RFC's size; Signal itself gives new linked devices no old history.
- Matrix's full model (rotating room keys + key sharing + backup): the closest working system, and its fragility is proverbial ("unable to decrypt this message"). This RFC is deliberately Matrix-minus-the-fragile-parts: static per-conversation keys, no per-sender rotation, one backup blob.
- PDS-held unlock secret ("login is recovery"): rejected — §5.
- PDS-held sealed-blob backup: rejected — §5.
- Federation-level keyring replication: rejected — servers cannot merge what they cannot read; multi-master replication of an opaque blob loses conversation keys under ordinary concurrent use (§7).
- Plaintext DMs forever: rejected by requirement 1. It is the current state — the long-standing behavior deliberately restored by
2bb1cf34— and this RFC exists to move past it properly. - Status quo (ratchet, auto-on): rejected by requirement 2, measured in §3.
- PDS-held convenience copies (secret or backup), if and only if atproto ships private storage with per-app read AND write isolation (§5).
- Local at-rest wrapping of device-side state (WebAuthn-PRF direction already analyzed for channel keys).
- Cross-server history portability (the scope fact in §7.7), which would extend requirement 2 from "any client" to "any server."
What "correct" means for any implementation of this design — stated as a human walkthrough, because §3's failure shipped against automated criteria too weak to catch it: enable E2EE (including the recovery-secret ceremony); send encrypted from web; add a second device via handoff and read the full history; restore a third client from the recovery code plus a backup copy with the home server stopped; repeat on TUI; refresh and re-read everything at every step. Implementation sequencing (wire format, SDK work, migration) is deliberately not in this document — it gets planned after the design settles.
- Private Data WG notes: https://notes.commonscomputer.com/s/atproto-private-data-wg (status, "at least a year off", no encryption at rest, PDS full visibility)
- Account migration guide: https://atproto.com/guides/account-migration (preferences get→put migration step)
- Bluesky migration doc: https://github.com/bluesky-social/pds/blob/main/ACCOUNT_MIGRATION.md ("currently the only private state held on your PDS is your preferences")
- Preferences API: https://docs.bsky.app/docs/api/app-bsky-actor-get-preferences (documented multi-device sync + migration purpose)
- Auth scopes proposal: bluesky-social/atproto#3655 and https://docs.bsky.app/blog/oauth-improvements (endpoint-level scopes, status)
- Private data discussion: bluesky-social/atproto#3363