Skip to content

Sync Protocol ru

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

Протокол синхронизации

English · Русский

Синхронизация необязательна, размещается самостоятельно и работает по принципу zero-knowledge: сервер держит шифротексты, SRP-6a-верификатор и метаданные. Пользовательская сторона — Синхронизация и устройства, эксплуатация — server/README.md.

Составные части

Часть Где
SyncEngine shared/sync — что пушить, что тянуть, как сливать
SyncClient shared/sync — контракт транспорта (регистрация, вход, push, pull, устройства, привязка)
KtorSyncClient shared/jvmSharedMain/sync — реализация на HTTP/WebSocket
SyncCoordinator composeApp/ui/sync — жизненный цикл, состояние, отчёт об ошибках
:sync-wire DTO, которые компилируют обе стороны — руками не дублируются
server/ Ktor + Exposed + HikariCP; по умолчанию SQLite, PostgreSQL — сменой URL

Регистрация и вход

sequenceDiagram
  participant C as Клиент
  participant S as Сервер
  C->>C: Argon2id(мастер-пароль) → masterKey<br/>masterKey → authKey, обёрнутый dataKey
  C->>S: POST /auth/register {соль и верификатор SRP, wrappedDataKey, устройство}
  S-->>C: access + refresh JWT
  Note over C,S: позже, на другом устройстве
  C->>S: POST /auth/srp/challenge {accountId}
  S-->>C: {challengeId, salt, B}
  C->>S: POST /auth/srp/verify {A, M1}
  S-->>C: {M2, токены}   %% M2 аутентифицирует сервер клиенту
  C->>S: GET /vault/keys → wrappedDataKey
  C->>C: развернуть masterKey'ом → dataKey
Loading

Сам пароль не передаётся никогда; сервер хранит верификатор и не может его восстановить. changePassword доказывает текущий пароль полным SRP-рукопожатием, затем атомарно меняет верификатор и обёрнутый dataKey и отзывает все прочие устройства. Сам dataKey не меняется — меняется только его обёртка.

Push и pull

sequenceDiagram
  participant A as Устройство A
  participant S as Сервер
  participant B as Устройство B
  A->>S: PUT /vault/records [зашифрованные записи]
  S-->>A: победители LWW + новый курсор
  S-->>B: WS /sync — «есть изменения», только курсор
  B->>S: GET /vault/records?since=курсор
  S-->>B: дельта
  B->>B: mergeRemote → applied / rejected
Loading
  • Конфликты: last-writer-wins по version (счётчик Лампорта), при равенстве — по deviceId.
  • Удаления: надгробия, физически чистятся через SKERRY_TOMBSTONE_DAYS.
  • Живой push несёт новый курсор и никогда — содержимое.
  • Отклонённые слияния: запись, чей блоб не прошёл аутентификацию против заявленных метаданных, не сохраняется и показывается — это и есть защита от подделки на сервере.

Курсоры

Курсор — позиция в истории одного сервера, поэтому важно, под каким ключом он хранится: SyncStateStore ключуется целым ServerLink (один id аккаунта называет разные аккаунты на домашнем и рабочем инстансах), а область команды — связкой ссылки и области через shared/sync/KeyedStateStore. Общий курсор на два сервера означает, что все записи ниже вершины другого не будут вытянуты никогда — реальный баг (#242), ради которого это ключевание и сделано.

Совместимость вперёд

Типы записей, появившиеся после того, как серверы уже развернули, пушатся отдельной best-effort пачкой, поэтому старый сервер, отвергнув один тип, не роняет весь push. За «сервер просто старый» считаются только PROTOCOL (400) и NOT_FOUND (404); любой другой сбой принадлежит вызывающему. Тип покидает это множество, когда его принимает каждый поддерживаемый релиз сервера.

Команды

Записи команд для сервера — шифротекст; членство выдаётся запечатанными конвертами под опубликованные публичные ключи участников, а у области доступа свой ключ. Сервер обеспечивает роли и ACL областей, но расшифровать не может ничего. Маршруты — /teams/*, плюс /account/key и /account/keys/{accountId} для публикации и поиска ключей.

Поскольку конверты запечатаны под ключ команды, приглашения и ротация ключей — только клиентские операции: браузерная сессия их выполнить не может.

Трансляция сессии

Общий терминал кадрируется share/SessionShareFrame / SessionShareCodec и передаётся через ShareChannel; SessionShareHost ведёт сторону хозяина, SharedSessionViewer — зрителя. Релей двигает зашифрованные кадры и не узнаёт о сессии ничего.

Поверхность сервера

Область Маршруты
Здоровье GET /healthz, GET /readyz, GET /metrics (по умолчанию выключен)
Аутентификация /auth/register, /auth/srp/challenge, /auth/srp/verify, /auth/refresh, /auth/change-password, /auth/web-password, /auth/web-login
Хранилище GET /vault/keys, GET/PUT /vault/records, GET /vault/envelopes, WS /sync
Аккаунт GET /account/summary, GET /account/activity
Устройства GET/DELETE /devices, /pairing/start, /pairing/claim
Команды /teams, /teams/{id}/members, /teams/{id}/records, /teams/{id}/activity, роли и области
Админ /admin/* за X-Admin-Token, то же самое умеет CLI skerry-admin

Правила серверной стороны, которые стоит помнить: страж админ-токена — один плагин на область маршрутов, а не проверка в каждом; идентификаторы и лимиты идут через requiredPathId / limitParam; ввод валидируется до побочных эффектов (400, а не 500 плюс сожжённый одноразовый код); и ни одна метка метрики не несёт accountId, deviceId, recordId, teamId, scopeId или IP.

Удаление аккаунта

DELETE /admin/accounts/{id} в одной транзакции убирает записи, устройства, сессии привязки, опубликованные ключи Teams, членства и выдачи областей. Команда, которой аккаунт владел, переходит к самому старшему активному участнику; если активных не осталось, команда удаляется вместе с записями и выдачами. Оставшимся стоит ротировать ключ команды — удалённый аккаунт его знал, а ротировать может только клиент.

Clone this wiki locally