Wire protocol between a client (note-core) and the relay. Companion to
design/overview.md. This is a living spec for roadmap stage 1; version it
as it stabilizes.
Protocol version: 1 (sent in every session handshake).
- The relay is zero-knowledge: it only ever sees ciphertext and routing metadata (group id, device id, sequence numbers, sizes, timestamps). It never holds an E2E key and cannot read note content.
- Authentication (may this device talk to the relay?) is fully separate from encryption (may this device read the content?). The relay enforces the former; only sync-group membership grants the latter.
- Multi-tenant: many sync groups share one relay, mutually isolated.
- CRDT (Automerge) makes merges automatic; the relay is a durable, ordered broadcast log of encrypted changes — it never merges anything.
| Id | Shape | Meaning |
|---|---|---|
group_id |
16-byte random (UUIDv4) | A sync group = the set of devices sharing one E2E key. Tenant boundary. |
device_id |
16-byte random (UUIDv4) | One physical device within a group. |
note_id |
16-byte random | A note inside the CRDT document. Assigned client-side. |
attachment_id |
32-byte = SHA-256 of ciphertext | Content-addressed encrypted blob. Deduplicates automatically. |
seq |
u64, per group | Monotonic sequence number the relay assigns to each accepted change. |
group_id is not secret (the relay knows it) but is unguessable. It is
never derived from the E2E key.
- AEAD: XChaCha20-Poly1305 (24-byte random nonce → safe without a nonce counter, which matters across independent devices).
- E2E key
K: 32 random bytes, generated once on the first device of a group. Shared to new devices only via QR pairing (§4). Never sent to the relay. - Device identity: each device holds an Ed25519 keypair. The public key
is registered with the relay at enrollment; the private key signs auth
challenges. This is auth-only and unrelated to
K.
Every content payload (a CRDT change, an attachment) is wrapped:
Envelope = version(1B) || nonce(24B) || ciphertext
ciphertext = XChaCha20Poly1305_Encrypt(key=K, nonce, plaintext, aad)
aad (authenticated, not encrypted) binds the ciphertext to its group, origin
device, and kind, so the relay cannot replay a blob into another group or
reinterpret it as a different kind:
aad = group_id(16B) || kind(1B) || device_id(16B)
kind: 0x01 = CRDT change, 0x02 = attachment
Values that exist only after sealing are deliberately not in the AAD: the
relay-assigned seq (§6) is untrusted ordering metadata that CRDT application
tolerates, and attachment_id = SHA-256(ciphertext) (§7) is verified
independently by both relay and client. Binding either would be circular or
meaningless.
The relay stores the whole Envelope opaquely; it can read none of it.
Auth is a credential the relay issues; it gates connection, not decryption.
- Admin issues an invitation. The relay admin creates a single-use,
revocable invitation code bound to a
group_id(existing or new). Codes expire. - Device enrolls. The device generates its Ed25519 keypair and calls
POST /v1/enroll { invite_code, device_pubkey }. The relay verifies the code, records(group_id, device_id, device_pubkey), marks the code used, and returns{ device_id, group_id, device_token }. Thedevice_tokenis a bearer secret for authenticated HTTP calls (attachment upload/download); the relay stores only its hash. WebSocket sync uses the Ed25519 key, not the token. - Session auth (challenge-response) on every WebSocket connect:
- Client opens the socket and sends
Hello. - Relay replies with a random
challenge(32 bytes). - Client sends
Auth { device_id, signature = Ed25519_Sign(sk, challenge) }. - Relay verifies against the stored pubkey. On success the session is bound to
(group_id, device_id).
- Client opens the socket and sends
- Revocation. The admin removes a device's pubkey; its next auth fails and
any live session is dropped. Revocation is independent of
K— a revoked device keeps whatever plaintext it already synced (unavoidable), but can no longer push/pull.
The relay never learns
Kat any step. Enrollment authorizes transport, QR pairing (next) authorizes decryption.
Adding a device to an existing group's encryption is separate from enrolling it on the relay.
-
An existing device shows a QR code containing everything the new device needs to join, transferred directly (screen → camera), never via the relay:
QR payload (CBOR/JSON), one-time: { "v": 1, "relay_url": "wss://relay.example/v1/sync", "group_id": "<uuid>", "invite_code": "<single-use enroll code>", "e2e_key": "<32 bytes, base64>" // the secret; leaves only via this QR } -
The new device: stores
K, generates its Ed25519 keypair, enrolls withinvite_code(§3), then connects and does an initial pull (§6). -
The QR is single-use and short-lived;
invite_codeis consumed on enrollment.
- Primary channel: WebSocket at
/v1/syncfor real-time change flow. - Bulk blobs (attachments) go over HTTP (
/v1/attachments) to keep the socket responsive; both require the same session/credential. - Messages are JSON for stage 1 (readable, easy to debug); a binary framing may replace it later. Binary fields (nonces, ciphertext) are base64 in JSON.
| Type | Fields | Purpose |
|---|---|---|
Hello |
protocol_version |
Begin session. |
Auth |
device_id, signature |
Answer the auth challenge. |
Push |
envelope (base64), client_change_id |
Submit one encrypted CRDT change. client_change_id is the Automerge change hash (hex) and doubles as the relay dedup key. |
Pull |
since_seq |
Request all changes with seq > since_seq. |
Ping |
— | Keepalive. |
| Type | Fields | Purpose |
|---|---|---|
Challenge |
challenge |
Random bytes to sign. |
AuthOk |
group_id, current_seq |
Session established; latest known seq. |
Ack |
client_change_id, seq |
A Push was durably stored and got seq. |
Change |
seq, device_id, envelope |
A change (from any group member) to apply. |
PullDone |
seq |
End of a Pull response: the client is caught up to seq. |
Pong |
— | Keepalive reply. |
Error |
code, message |
Auth failure, unknown group, rate limit, etc. |
The relay keeps, per group, an append-only log of Change records:
(seq, device_id, envelope, received_at). It assigns seq and never inspects
envelope.
Push (local edit):
- Client makes an Automerge change → gets the raw change bytes.
- Encrypt into an
Envelope(kind0x01,aadbound togroup_id,device_id;seqis0until assigned). - Send
Push. Relay appends, assignsseq, repliesAck, and fans out aChangeto every other connected device in the group. - Offline? Queue locally; replay
Pushes on reconnect.
Pull (catch-up / new device):
- Client tracks the highest
seqit has applied (last_seq, persisted). - It sends
Pull { since_seq: last_seq }. - Relay streams every
Changewithseq > since_seqin order, then aPullDone { seq }marking the client caught up toseq. - Client decrypts each envelope and applies the Automerge change. Application is
idempotent and order-tolerant (CRDT), so duplicates and races are harmless;
last_seqonly advances.PullDoneterminates a one-shot sync round.
Dedup and idempotency: client_change_id is the Automerge change hash, which
is globally unique per change (it binds the actor and causal deps). The relay
dedups per group by this id: a re-pushed change returns its original seq and is
not re-appended or re-broadcast. This makes a client safe to re-push its whole
change set (e.g. after a restart) without bloating the log. The hash reveals no
content — it is an opaque identifier over ciphertext-adjacent metadata.
Why this is conflict-free: Automerge changes commute and merge deterministically. Two devices editing the same note offline both push; each applies the other's change on reconnect and converges to the same document — no server-side merge, no user-visible conflict.
The log grows unbounded. A future optimization: a device periodically uploads an
encrypted snapshot (a compacted Automerge save) with the seq it covers;
the relay may then drop changes at or below that seq. New devices pull the
latest snapshot plus subsequent changes. Deferred past stage 1.
Images/files are separate encrypted blobs, referenced from Markdown by
attachment_id.
- Upload:
PUT /v1/attachments/{attachment_id}with theEnvelope(kind0x02) as body.attachment_id = SHA-256(ciphertext), so the relay can verify the id matches the body without reading it, and identical blobs deduplicate. Idempotent. - Download:
GET /v1/attachments/{attachment_id}returns theEnvelope; the client decrypts withK. - The Markdown note (inside the CRDT) stores the
attachment_id; the blob syncs lazily/independently of the change log. - Garbage collection of unreferenced blobs is a later concern (the relay can't see references, so GC is client-driven via an encrypted manifest — deferred).
| Sees (metadata) | Cannot see |
|---|---|
group_id, device_id, seq, timestamps |
Note text, titles, folders, tags |
| Change/attachment sizes and counts | Any plaintext, ever |
| Which devices are online, connection times | The E2E key K |
attachment_id (hash of ciphertext) |
Attachment contents or filenames |
Traffic-analysis metadata (sizes, timing) is inherent to any relay and out of scope for stage 1; padding/batching can be considered later.
- Auth failure / revoked device:
Error { code: "unauthorized" }, socket closed. - Unknown/expired invite:
enrollreturns HTTP 403. - Replayed
Push: deduplicated byclient_change_idper group; relay returns the originalAckand does not re-broadcast. attachment_idmismatch: upload rejected (HTTP 422) — body hash ≠ id.- Gap in
seqon the client: issue aPull { since_seq: last_seq }to refill; never apply out of order beyond what CRDT tolerates.