Sync system design: enrollment, auth, encryption at rest and syncing
Status: design proposal, not a decision. It builds on the decisions D-27 (E2EE for anything that leaves the device), D-45 (account-free sync groups on the backend infrastructure), D-46 (the server’s own Postgres), D-47 (libsodium, XChaCha20-Poly1305, per-note data keys wrapped under the root key), D-48 (QR enrollment plus a 24-word recovery key), D-49 (ask the user about delete-versus-edit conflicts), D-50 (no size padding) D-51 (desktop key storage), D-52 to D-58 (entitlement per sync group, store subscriptions, recovery key derivation, one-year retention of lapsed groups, optional keychain backup, a pluggable store verifier), D-64 (a new note key for each rotation, formats version 2), D-74 (a long-term X25519 encryption key per device) and D-75 (how the root key is rotated and where its recovery copy is sealed). Everything else here is a proposal until a
D-xxrecords it. Background and options are in cloud-sync-and-e2ee.md.Built so far: the
:cryptomodule (crypto/), with the root key, note key wrapping, record envelopes and the QR enrollment wrap; the server’s auth, enrollment, recovery and record routes; the client sync engine (app/sharedLogic/.../sync/SyncEngine.kt: the outbox, push, pull and merging of section 7) withKtorDestinationandNotesCore.sync, tested against a fake server and the real one; and root key rotation (D-75: the server’s rotation routes, the engine’s key rewrite andCloudSync’s flow). In the apps, Settings can turn sync on (a promo code, the recovery key shown once and three of its words typed back;CloudSync.turnOn), join a group by QR code (CloudSync.startJoining,finishJoiningandapprove, D-77), and shows its status with Sync now, Add a device and turning it off; removing devices, the devices list, recovery and the D-57 keychain backup are not built.
1. The short version
- The server is a dumb store of ciphertext. It knows which devices belong to which group, and it orders changes, but it can’t read a note, a title or a notebook name.
- Two kinds of secrets, kept apart. Encryption keys (root key, note keys) decide who can read. Device signing keys decide who can talk to the server. Holding one never gives the other.
- Devices join by QR code. A device already in the group scans the new device’s QR code and hands it the root key, encrypted to the new device. The server only relays it.
- Sync is push and pull of sealed records. Each device keeps an outbox of local changes, pushes them with a conditional put, and pulls everyone else’s changes from a per-group change feed.
2. Components
flowchart LR
subgraph Devices
A["Android app<br/>NotesCore + SQLite"]
I["iOS app<br/>NotesCore + SQLite"]
D["Desktop app<br/>NotesCore + SQLite"]
end
subgraph Shared["Shared Kotlin (all clients)"]
C[":crypto<br/>libsodium"]
S["Sync engine<br/>outbox, cursor, merge"]
end
subgraph Box["Backend infrastructure"]
CA["HTTPS proxy"]
K["Ktor sync server<br/>auth, records, change feed"]
P[("Postgres<br/>own instance, D-46")]
end
A & I & D --- S
S --- C
S -- "HTTPS" --> CA
CA --> K
K --> P
The proxy terminates HTTPS; the Ktor server checks device signatures and the group’s entitlement, and stores records. The app never relies on the transport for confidentiality, because everything it sends is already encrypted.
3. Keys
flowchart TD
RC["Recovery key, 24 words<br/>(shown once, kept by the user)"]
RK["Root key RK<br/>32 bytes, one per group, has an id"]
NK["Note keys NK<br/>32 bytes, random, numbered 1, 2, ...<br/>a new one at each rotation"]
REC["Records<br/>note metadata, each revision"]
DX["Device enrollment key<br/>X25519, temporary"]
DS["Device signing key<br/>Ed25519, one per device"]
SRV["Sync server"]
RC -- "wraps (backup copy on server)" --> RK
DX -- "receives RK once, at enrollment" --> RK
RK -- "wraps (82 bytes per key)" --> NK
NK -- "seals (XChaCha20-Poly1305)" --> REC
DS -- "signs requests, proves membership" --> SRV
| Key | Where it lives | What it protects | Lifetime |
|---|---|---|---|
| Root key (RK) | Each device, wrapped by Android Keystore / iOS Keychain (ThisDeviceOnly); on desktop by macOS Keychain, Windows DPAPI or Linux Secret Service, or a passphrase where none is available (D-51). A copy sealed to the recovery key’s X25519 key on the server (D-75). A device keeps the earlier ones too (D-75). | Wraps note keys | Until rotated (device removed) |
| Note keys (NK) | A list per note, each wrapped by RK, stored on the server with the note and in the local database. Unwrapped only in memory. | NK n seals the records written between rotation n and the next | The note’s life; a new one at every rotation (D-64) |
| Device signing key | Each device, in the same store as the root key. Public half on the server. | Proves to the server that a request comes from a member device | Until the device is removed |
| Device encryption key (X25519, D-74) | Each device, in the same store. Public half on the server, covered by the statements members sign. | Receives RK at enrollment and after each rotation | Until the device is removed |
| Recovery key | 24 words with the user (printed, password manager), shown once when sync is turned on (D-48) | Derives the lookup id, a signing key and an X25519 key; the last opens the server’s copy of RK (D-75) | Until regenerated |
Why per-note keys under a root key (D-47). Rotating the root key, for example after removing a lost phone, rewraps the note keys (82 bytes each) and the records themselves never need re-encrypting. Per-note keys also leave room for sharing a single note later without exposing the rest of the group.
Formats (version 2, all built)
| Item | Layout | Associated data |
|---|---|---|
| Wrapped note key (82 B) | version, alg, rootKeyId(4), noteKeyId(4), nonce(24), sealed NK(32), tag(16) | label, header, note id |
| Record envelope | version, alg, noteKeyId(4), nonce(24), ciphertext, tag(16) | label, header, record id, caller context (schema version, content hash) |
| Wrapped root key for enrollment (110 B) | version, alg, rootKeyId(4), ephemeral X25519 key(32), nonce(24), sealed RK(32), tag(16) | label, header, recipient’s public key, group id |
| Root key sealed for recovery (110 B, D-75) | the enrollment wrap, to the recovery key’s X25519 public key | label, header, recipient’s public key, group id |
The enrollment wrap is still version 1, and is also what a rotation sends to each device and what the server keeps for recovery (D-74, D-75; the recovery copy was a symmetric 78-byte wrap before D-75). The wrapped note key and the record envelope moved to version 2 to carry the note key id (D-64), which the associated data covers so the server cannot relabel a key or a record. Version 1 of those two never shipped. All use XChaCha20-Poly1305 from libsodium with random 24-byte nonces. The enrollment wrapping key is BLAKE2b-256 over the X25519 shared secret and both public keys, as libsodium recommends. The associated data stops the server from moving a ciphertext to another note, record or group, or replaying it with other metadata.
4. Auth: who may talk to the server
There are no accounts, emails or passwords. Membership is a list of device public keys per group, and the server trusts a new key only when an existing member vouches for it.
Group creation. The first device makes a random 128-bit group id, a root key and its own Ed25519 signing key, and registers the group with its public key. From then on it is the group’s first member.
Getting a session. A device asks for a challenge, signs it, and gets an opaque bearer token valid for 15 minutes (D-67). The server stores only its SHA-256, so any server container can check any token against the shared Postgres, and removing a device deletes its tokens at once.
sequenceDiagram
participant Dev as Device
participant Srv as Sync server
participant DB as Postgres
Dev->>Srv: POST /v1/auth/challenge {groupId, deviceId}
Srv->>DB: look up device public key
Srv-->>Dev: nonce (single use, 60 s)
Dev->>Dev: sign("jotry/v1/auth" ‖ groupId ‖ deviceId ‖ nonce) with Ed25519
Dev->>Srv: POST /v1/auth/token {deviceId, nonce, signature}
Srv->>Srv: verify signature, nonce unused, device not revoked
Srv-->>Dev: token (15 min), stored hashed
Dev->>Srv: later requests with Authorization: Bearer token
Adding a device is part of enrollment (section 5): the existing device signs a statement “add device X with signing key Y to group G” and the server records it. The server refuses member changes not signed by a current member.
Removing a device. Any member can remove another. The server drops its key and tokens at once, so it can’t fetch anything new. The removed device may still hold RK and note keys it already unwrapped, so a remaining device rotates both (section 7): a new RK, and a new note key for every note (D-64). Records written afterwards are sealed with the new note keys, which the removed device never gets, so it cannot read them even if it obtains the ciphertext. Old records stay readable to someone who copied them before, because they keep their old keys; that is true of any E2EE system.
Entitlement. Hosted sync is paid (D-53), so the server also asks whether a group is entitled, separately from who may talk for it (D-52).
- Creating a group needs a verified store transaction whose account token names the new group id (D-54). The server checks it through a pluggable verifier (development, Apple, Google, Microsoft; D-58), stores it as an entitlement row, and creates the group.
- Several entitlements can sit on one group, from different stores. The group is entitled while any of them is active; the latest expiry wins. Buying again on another store just adds a row.
- Store notifications (renewal, expiry, refund, revoke) update the rows. A transaction maps to one group; moving it to another group is allowed with a cooldown.
- Lapsed groups are read-only: pull and recovery still work, push is refused (D-56). Renewing or restoring the purchase reattaches it. The server deletes the group one year after its last entitlement ended.
- Testing and promo entitlements are rows with their own source. How they are issued is a later decision (D-58).
- Devices in a group are not capped for now (D-52), and no quotas are set yet.
What auth does not do. It doesn’t decrypt anything and isn’t needed to read local notes. Losing every device’s signing key costs server access, not data: the recovery key restores RK, and a new device can re-join through a recovery flow (open question).
HTTPS is not the auth. The server must be safe on the open internet, which is where it sits once sync is offered to other users (D-52).
5. QR enrollment
The new device shows a QR code; an existing device scans it. Scanning a screen in the same room is what proves the new device’s keys belong to it, so the server can’t slip in its own keys. Desktop has no camera, which is why the new device shows the code.
QR payload (D-77): JOTRY1: and base64url of a CBOR map of the server URL, enrollment id, device id, the new device’s X25519 public key and its Ed25519 public key, about 190 characters. The scanning member checks the device id and both keys against the server’s copy.
sequenceDiagram
participant New as New device
participant Srv as Sync server
participant Old as Existing device
New->>New: make X25519 encryption key and Ed25519 signing key (D-74)
New->>Srv: POST /v1/enrollments {enrollmentId, x25519 pub, ed25519 pub}<br/>(no auth, rate limited, expires in 10 min)
New->>New: show QR {server, enrollmentId, x25519 pub, ed25519 pub}
Old->>New: scans QR on screen
Old->>Srv: GET /v1/enrollments/{id} (authenticated)
Old->>Old: check that the keys from the server match the QR
Old->>Old: wrap RK to the X25519 key (110 bytes)<br/>sign "add device" with own Ed25519 key
Old->>Srv: POST /v1/enrollments/{id}/approve {wrappedRootKey, groupId, signed statement}
Srv->>Srv: verify the signature is from a current member,<br/>add the device to the group
New->>Srv: poll or listen for approval
Srv-->>New: {groupId, wrappedRootKey}
New->>New: unwrap RK, store it in Keystore / Keychain,<br/>keep the X25519 key (it receives later rotated keys)
New->>Srv: auth challenge with the new signing key (section 4)
New->>Srv: pull the change feed from the start
If the existing device has no camera (a desktop adding a phone), the roles flip: the existing device shows a QR code with a one-time secret, the new device scans it and sends its public keys with a MAC under that secret, and the existing device checks the MAC before approving. Not designed in detail yet.
Recovery with only the recovery key (D-55)
The 24-word recovery key has 256 bits, so no password stretching is needed. Three values are derived from it with HKDF, each under its own label: a lookup id, an encryption key (X25519) and a recovery signing key (Ed25519). When the group is created, the server stores the lookup id, the two public keys and the root key sealed to the encryption key’s public key (D-75), which any member can replace on a rotation. It never sees the recovery key.
sequenceDiagram
participant New as New device
participant Srv as Sync server
New->>New: user types the 24 words,<br/>derive lookup id, encryption key, recovery signing key
New->>Srv: POST /v1/recovery/challenge {lookupId}
Srv-->>New: nonce (single use, 60 s)
New->>New: sign nonce with the recovery signing key,<br/>make a new Ed25519 device key
New->>Srv: POST /v1/recovery/enroll {lookupId, nonce, signature, new device public key}
Srv->>Srv: verify against the stored recovery public key,<br/>add the device to the group
Srv-->>New: {groupId, recovery_wrapped_root_key}
New->>New: open the root key with the encryption key,<br/>store it in Keystore / Keychain
New->>Srv: normal auth (section 4), then pull the change feed from the start
- It works while the group is lapsed, because it only needs reading (D-56).
- After recovering, the app lists the group’s devices and offers to remove the lost one, which rotates the root key (section 7).
- At setup the user re-enters part of the key before sync turns on, so they have actually saved it.
- Optional keychain backup (D-57). When sync is turned on, the user may also let the OS back up the device key (iCloud Keychain, Android Backup). A restored phone then rejoins without the words. The recovery key is created either way.
6. Encryption at rest
flowchart LR
subgraph Device
UI["App UI"] --> Core["NotesCore"]
Core --> DB[("SQLite<br/>plaintext notes,<br/>OS file protection")]
Core --> Out["Outbox"]
KS["Keystore / Keychain<br/>RK, signing key"]
end
Out -- "seal with NK" --> Net["HTTPS"]
KS -. "unwrap NK in memory" .-> Out
Net --> Pg[("Postgres<br/>ciphertext, wrapped keys,<br/>device public keys")]
On the device. The local database stays plaintext so search, history and export keep working, protected by the OS (D-10, D-11: Android file-based encryption and app sandbox, iOS Data Protection, excluded from cloud backups). RK and the signing key are stored wrapped by a hardware-backed key in Keystore / Keychain, ThisDeviceOnly, never in the database. Wrapped note keys are kept in the database (sync_note_key), and are unwrapped in memory when sealing or opening.
On the server. Postgres holds only what a server without keys may see:
| Stored | Readable by the server? |
|---|---|
| Record bodies (note metadata, revisions) | No, sealed with NK |
| Wrapped note keys | No, sealed with RK |
| Recovery copy of RK | No, sealed with the recovery key |
| Pending enrollment’s wrapped RK | No, sealed to the new device |
| Group id, device ids and public keys | Yes |
| Record ids, note ids, sizes, timestamps, which device wrote what | Yes |
What leaks is the shape of activity: how many notes, how big, when edited, from which device. Ids are random UUIDs, so they carry no titles. Record sizes are not padded (D-50): encrypting the contents is enough for a personal server. Disk encryption on the box and Postgres backups are an extra layer for the case of a stolen disk, not part of the E2EE guarantee.
7. Syncing with the server
Server data model (sketch)
Accepted with changes as D-65: one
recordtable with anotebookkind added, noversioncolumn (the conditional put comparesseq), and tombstones as records with no envelope. The sketch below is the original proposal.
erDiagram
sync_group ||--o{ device : has
sync_group ||--o{ entitlement : has
sync_group ||--o{ record : holds
sync_group ||--|| group_counter : orders
device ||--o{ auth_token : gets
sync_group {
uuid id
bytea recovery_lookup_id
bytea recovery_public_key
bytea recovery_wrapped_root_key
int current_root_key_id
timestamptz lapsed_since
}
entitlement {
uuid id
uuid group_id
text source
text store_transaction_id
text status
timestamptz expires_at
timestamptz bound_at
}
device {
uuid id
uuid group_id
bytea ed25519_public_key
timestamptz added_at
uuid added_by
timestamptz revoked_at
}
record {
uuid group_id
text record_id
text kind
uuid note_id
bigint version
bigint seq
bytea envelope
bool tombstone
uuid author_device_id
}
group_counter {
uuid group_id
bigint last_seq
}
entitlement.source is apple, google, microsoft, promo or dev; lapsed_since is set when the last active entitlement ends and drives the one-year deletion. record.kind is note-key, note or revision; a note-key record holds the whole list of that note’s wrapped keys. version is per record, for conditional puts; seq is per group, for the change feed, and is taken from group_counter under a row lock in the same transaction, so a pulling device never skips a change committed out of order.
Push and pull
sequenceDiagram
participant Core as NotesCore
participant Eng as Sync engine
participant Srv as Sync server
Core->>Eng: local commit (revision, move, delete)
Eng->>Eng: add to outbox
Eng->>Eng: seal records with the note's NK<br/>(new note: make NK, wrap under RK)
Eng->>Srv: PUT /v1/records/{id} {envelope, expectedVersion}
alt version matches
Srv-->>Eng: 200 {version, seq}
Eng->>Eng: drop from outbox
else someone else wrote first
Srv-->>Eng: 409 {current version}
Eng->>Srv: pull, merge, push again
end
Note over Eng,Srv: pull
Eng->>Srv: GET /v1/changes?cursor=seq
Srv-->>Eng: records with seq > cursor, next cursor
Eng->>Eng: unwrap NK, open envelopes,<br/>apply to NotesCore, save cursor
Srv--)Eng: SSE "changed" nudge (optional), triggers a pull
Conflicts. Revisions are immutable and already form a graph (parent_revision_id), so two devices editing the same note offline both upload their revisions without conflict. The note’s head record is what can conflict: on a 409 the engine pulls, sees two heads, and either merges them (text that doesn’t overlap) or keeps both and shows the note as conflicted, with both versions in history. Deletes are tombstones. When a delete on one device meets an edit on another, sync asks the user which to keep (D-49): keep the edited note, or delete it. Until they answer, the note stays, marked as needing a decision, and nothing is lost.
Ordering on the device. The client never trusts server timestamps for order; it uses the revision graph and a hybrid logical clock written into the sealed record.
Root key rotation
flowchart LR
R["Remove device"] --> G["Make RK2 (id + 1)"]
G --> N["Make a new NK for every note<br/>(id + 1), used for later records"]
N --> W["Rewrap every NK, old and new, under RK2<br/>(PUT note-key records)"]
W --> E["Send RK2 to the other devices<br/>(wrapped to each device)"]
E --> D["Keep RK1 too, for key lists<br/>not rewrapped yet (D-75)"]
The steps as built (D-75): the first remaining device to sync after a removal makes RK2, keeps it in its vault and notes the rotation in its database, then asks the server to move the current id from 1 to 2, which also replaces the recovery copy (only one device can win; the server answers 409 root-key-conflict to the other). It rewrites the note-key records and seals RK2 to every other device’s X25519 key, which each collects when it syncs. A device that was offline, or enrolled in the middle of a rotation, shows up in the server’s list of devices that were not given the current key, and any member that holds it sends it. A rotation cut short at any point is settled by the next sync.
Existing records keep the note key that sealed them (the envelope names it), so they are never re-encrypted and only the note-key records are uploaded. From then on each new revision and the note’s head record are sealed with the newest note key. A note’s note-key record is a list: the old keys stay in it so old revisions open, each wrapped under RK2. Handing RK2 to the remaining devices needs a per-device encryption key (the signing key can be converted, or each device keeps a long-term X25519 key); open question below.
8. Threats
| Threat | What stops it |
|---|---|
| Someone reads the server’s disk or backups | Everything there is sealed; keys never reach the server |
| The server swaps or replays ciphertexts | Associated data binds every envelope to its record, note and group |
| The server adds its own device to a group | New members need a statement signed by a current member (covering the signing and encryption keys, D-74), and the key comes from a QR code scanned in person. Not yet checked by clients: a malicious server could show a rotating or enrolling device a member that does not exist (section 9) |
| A stolen phone | OS lock and Keystore / Keychain protect local data; another device removes it and rotates RK and every note key (D-64) |
| Someone on the network | HTTPS; and the content is sealed anyway |
| Another group claims a leaked store receipt | The store’s signed transaction carries the group it was bought for (D-54); the server refuses a mismatch and allows rebinding only with a cooldown |
| One paid group shared among many people | Not prevented: there is no device cap for now (D-52). Per-group quotas are a later decision |
| A test shortcut left on in production | The development verifier cannot be enabled in a production build (D-58); promo entitlements are explicit rows with their own source |
| Losing every device | The 24-word recovery key restores RK from the server copy and lets a new device re-join (section 5, D-55); without it the synced notes are gone, and the app says so (accepted in D-48) |
| A lapsed subscription | The group turns read-only, not locked; it is deleted a year after the last entitlement ended (D-56) |
9. Open questions
Recovery flow: how a device with only the recovery key re-joins.Designed in section 5 (D-55).- Store verification details: the Google Play equivalent of Apple’s account token, how the Microsoft Store verifies purchases server-side (a short spike before committing to Windows), and handling Apple’s Sandbox and Production environments.
- Promo and test entitlements: how they are issued and managed in production (D-58).
- Quotas and rate limits per group, and whether a device cap is ever needed (none for now, D-52).
Distributing a rotated RK to the remaining devices.A long-term X25519 key per device (D-74), and a sealed copy per device on the server (D-75).- Trusting the server’s list of devices. A rotating device seals the new root key to the keys the server lists, and an enrolling device takes the approver’s wrap from the server, which is sealed with an ephemeral key and not signed. A malicious server could add a device of its own to what a rotating device sees and be sent the new root key. Closing this needs clients to check the chain of approvals (each stored with its signature, verified from a device they trust) and the approver to sign the wrap it sends (D-74).
- Camera-less existing device adding a phone: the reverse QR flow in section 5.
Conflict rule for a delete on one device and an edit on another.Ask the user during sync (D-49): on screen while the app is open, through a notification that opens the prompt when sync ran in the background (D-73).Size padding of records.Not done (D-50).Desktop key storage.OS key store, passphrase as fallback (D-51).Recovery key encoding.BIP-39 English, 256 bits plus checksum, HKDF directly from the bits (D-66).Token format.Opaque random tokens, stored hashed in Postgres (D-67): they work across any number of server containers and revoke at once.