Repository navigation
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.
| 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 |
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
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.
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
-
Conflicts: last-writer-wins on
version(a Lamport counter),deviceIdas 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.
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.
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.
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.
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.
| 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.
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.
User guide
- Installation
- Getting started
- Hosts and connections
- Vault and credentials
- Terminal
- Files (SFTP)
- Port forwarding
- Remote desktops
- Snippets and runbooks
- Monitoring and guard
- AI assistant
- Sync and devices
- Teams and sharing
- Settings and shortcuts
- Android
- Troubleshooting
Development
- Architecture
- Building and running
- Connections and transports
- Terminal engine
- Vault and crypto
- Sync protocol
- Remote desktop stacks
- UI layer
- Testing and the gate
- Contributing and releases
Русский — оглавление