Skip to content

Sync Protocol

Se.Cherkasov edited this page Aug 23, 2026 · 1 revision

Sync protocol

English · Русский

Sync is optional, self-hosted and zero-knowledge: the server holds ciphertext, an SRP-6a verifier and metadata. The user-facing side is Sync and devices; operations are in server/README.md.

Pieces

Piece Where
SyncEngine shared/sync — what to push, what to pull, how to merge
SyncClient shared/sync — the transport contract (register, login, push, pull, devices, pairing)
KtorSyncClient shared/jvmSharedMain/sync — the HTTP/WebSocket implementation
SyncCoordinator composeApp/ui/sync — lifecycle, state, failure reporting
:sync-wire the DTOs both the client and the server compile against — never mirrored by hand
server/ Ktor + Exposed + HikariCP; SQLite by default, PostgreSQL by URL

Enrolment and login

sequenceDiagram
  participant C as Client
  participant S as Server
  C->>C: Argon2id(master password) → masterKey<br/>masterKey → authKey, wrapped dataKey
  C->>S: POST /auth/register {srp salt, verifier, wrappedDataKey, device}
  S-->>C: access + refresh JWT
  Note over C,S: later, on another device
  C->>S: POST /auth/srp/challenge {accountId}
  S-->>C: {challengeId, salt, B}
  C->>S: POST /auth/srp/verify {A, M1}
  S-->>C: {M2, tokens}   %% M2 authenticates the server to the client
  C->>S: GET /vault/keys → wrappedDataKey
  C->>C: unwrap with masterKey → dataKey
Loading

The password itself is never transmitted; the server stores a verifier and cannot recover it. changePassword proves the current password with a full SRP handshake, then atomically swaps the verifier and the wrapped dataKey and revokes every other device. The dataKey is unchanged — only its wrap.

Push and pull

sequenceDiagram
  participant A as Device A
  participant S as Server
  participant B as Device B
  A->>S: PUT /vault/records [encrypted records]
  S-->>A: LWW-winning state + new cursor
  S-->>B: WS /sync — "changes available", cursor only
  B->>S: GET /vault/records?since=cursor
  S-->>B: delta
  B->>B: mergeRemote → applied / rejected
Loading
  • Conflicts: last-writer-wins on version (a Lamport counter), deviceId as tiebreaker.
  • Deletions: tombstones, physically aged out after SKERRY_TOMBSTONE_DAYS.
  • Live push carries the new cursor and never content.
  • Rejected merges: a record whose blob fails AEAD authentication against its claimed metadata is not stored and is reported — that is the defence against a tampering server.

Cursors

A cursor is a position in one server's history, so the key it is filed under matters: SyncStateStore is keyed by the whole ServerLink (one account id names different accounts on a home and a work instance), and a team space is keyed by link and space through shared/sync/KeyedStateStore. Sharing a cursor between two servers means every record at or below the other's tip is never pulled — a real bug (#242) the keying exists to prevent.

Forward compatibility

Record types added after servers were deployed are pushed in a separate best-effort batch, so an older server rejecting one type does not fail the whole push. Only PROTOCOL (400) and NOT_FOUND (404) count as "the server is just old"; every other failure belongs to the caller. A type leaves that set once every supported server release accepts it.

Teams

Team records are ciphertext to the server; membership is granted with sealed-envelope invitations against members' published public keys, and an access scope has its own key. The server enforces roles and scope ACLs but can decrypt nothing. Endpoints live under /teams/*, plus /account/key and /account/keys/{accountId} for key publication and lookup.

Because envelopes are sealed under a team key, invitations and key rotation are client-only operations — no browser session can perform them.

Live session sharing

A shared terminal is framed by share/SessionShareFrame / SessionShareCodec and carried over ShareChannel; SessionShareHost drives the host side and SharedSessionViewer the viewer. The relay moves encrypted frames and learns nothing about the session.

Server surface

Area Routes
Health GET /healthz, GET /readyz, GET /metrics (off by default)
Auth /auth/register, /auth/srp/challenge, /auth/srp/verify, /auth/refresh, /auth/change-password, /auth/web-password, /auth/web-login
Vault GET /vault/keys, GET/PUT /vault/records, GET /vault/envelopes, WS /sync
Account GET /account/summary, GET /account/activity
Devices GET/DELETE /devices, /pairing/start, /pairing/claim
Teams /teams, /teams/{id}/members, /teams/{id}/records, /teams/{id}/activity, roles and scopes
Admin /admin/* behind X-Admin-Token, mirrored by the skerry-admin CLI

Server-side rules worth knowing when touching it: the admin-token guard is one route-scoped plugin, not a per-route check; ids and limits go through requiredPathId / limitParam; input is validated before side effects (400 rather than 500 plus a burned one-time code); and no metric label ever carries an accountId, deviceId, recordId, teamId, scopeId or IP.

Deleting an account

DELETE /admin/accounts/{id} removes records, devices, pairing sessions, published Teams keys, memberships and scope grants in one transaction. A team the account owned passes to its most senior active member; with no active member left the team is deleted with its records and grants. The remaining members should rotate the team key afterwards — the deleted account knew it, and only a client can rotate it.

Clone this wiki locally