Протокол зашифрованной синхронизации SafePass, версия 1
Документ описывает обмен ревизиями между ASP.NET API и клиентами. HTTP-контракт и хранилище проверяются интеграционными тестами с PostgreSQL. Flutter и расширение используют этот контракт для отправки, упорядоченного получения и обработки конфликтов. Реализации находятся в apps/flutter_client/lib/src/sync и apps/browser_ext/src/remote_api.ts. Расширение также проверяют apps/browser_ext/test/remote_api.test.ts и сквозной тест Chromium.
Идентификаторы и ограничения
accountId,recordId,revisionId, идентификаторы родителей и ключи идемпотентности — непрозрачные регистрозависимые строки ASCII от 1 до 128 байт. Допустимы буквы, цифры,-,_,.и@.- Конверт ревизии — полный двоичный
SPCRv1 в Base64URL без заполнения внутри JSON. Ограничения описаны в формате шифрования. - У новой записи нет родителей, у обычной правки один родитель. При разрешении конфликта клиент указывает все показанные вершины. Поддерживается до восьми родителей. Сервер ограничивает число активных вершин записи восемью, чтобы конфликт всегда можно было закрыть.
cursor— назначаемое сервисом неотрицательное десятичное число до9223372036854775807, представленное строкой в JSON. Клиент не назначает и не вычисляет курсор новой ревизии.
Отправка ревизии
POST /v1/records/{recordId}/revisions
{
"revisionId": "rev_01J...",
"parentRevisionIds": ["rev_previous"],
"idempotencyKey": "device_01J_operation_01J",
"envelope": "U1BDUgE...",
"tombstone": false,
"expectedHeadIds": null
}Для новой записи и обычной правки expectedHeadIds отсутствует или равен null. Для разрешения конфликта он содержит полный набор прочитанных клиентом вершин. Сервер сравнивает его с актуальным набором и добавляет ревизию слияния в той же транзакции. При расхождении возвращается 409 conflict_heads_changed с актуальными непрозрачными вершинами. Родителями слияния становятся проверенные вершины.
Идентификатор ревизии ограничен аккаунтом и записью, ключ идемпотентности — аккаунтом. Повтор одинакового запроса с тем же ключом возвращает первоначальный курсор и текущие вершины. Использование ключа для другой операции даёт 409 idempotency_key_reused. Повтор ID ревизии с другим ключом даёт 409 revision_id_collision. Уже записанная ревизия сохраняется.
Успешный ответ:
{
"inserted": true,
"cursor": "42",
"heads": [
{"revisionId": "rev_01J...", "cursor": "42", "tombstone": false}
]
}Метаданные вершины не содержат открытых полей записи. Сервер сохраняет все принятые ревизии, в том числе заменённые ветки и маркеры удаления. Маркер показывает, что операция была удалением, и относится к видимым серверу метаданным.
Получение изменений
GET /v1/sync?afterCursor=41&limit=200
{
"changes": [
{
"recordId": "rec_01J...",
"revisionId": "rev_01J...",
"parentRevisionIds": ["rev_previous"],
"idempotencyKey": "device_01J_operation_01J",
"envelope": "U1BDUgE...",
"tombstone": false,
"cursor": "42"
}
],
"nextCursor": "42",
"hasMore": false
}Курсоры монотонно растут внутри аккаунта. limit принимает значения от 1 до 1000. Сервер сортирует изменения по курсору. Flutter применяет ревизию и её курсор в одной транзакции SQLCipher. Ответ отправки не продвигает курсор загрузки. Отправленная операция считается подтверждённой, когда её ключ идемпотентности возвращается в упорядоченном потоке изменений. При повторе клиент начинает с последнего сохранённого курсора и не пропускает изменения при прерывании страницы.
Конфликты и удаление
Обычные отправки могут создать параллельные вершины. Ответ возвращает все текущие вершины. Клиент сохраняет обе зашифрованные версии и показывает их пользователю. Подтверждённое разрешение — новая неизменяемая зашифрованная ревизия. В её родителях и expectedHeadIds перечислены все показанные вершины. Конкурирующая правка делает запрос устаревшим, а ветка остаётся сохранённой.
Удаление — новая зашифрованная ревизия с tombstone: true. Предыдущие ревизии остаются в истории. Клиент может скрыть запись после фиксации удаления. Политики хранения и восстановления не должны неявно отбрасывать серверную историю.
Аутентификация и транспорт
Каждый маршрут синхронизации требует TLS и короткоживущей аутентифицированной сессии. Сервер проверяет подпись, издателя, аудиторию, срок, назначение, время выпуска, идентификатор токена и аккаунт. Маршруты OPAQUE находятся под /v1/auth. Успешное доказательство выдаёт JWT на 15 минут и одноразовый токен обновления. Flutter хранит токен обновления в SQLCipher и обновляет его после разблокировки и перед истечением JWT. Расширение хранит его в зашифрованном сейфе IndexedDB и использует тот же /v1/auth/refresh.
Сервер хранит только SHA-256 токена обновления. Повтор отзывает семейство устройства. Токен истекает через 30 дней, семейство — через 90 дней. JWT с sid прекращают действовать при отзыве семейства. Старые токены без sid работают до истечения срока. TLS завершается на доверенной точке. Тело JSON ограничено 2 МиБ, частота запросов ограничена. Журналы исключают тела запросов, конверты, токены, пароли и данные восстановления.
Конверт восстановления аккаунта
Первый привязанный клиент получает короткоживущий аутентифицированный вызов и отправляет в PUT /v1/account/recovery-envelope конверт SPRE v1, привязанный к аккаунту, и выведенный из фразы открытый ключ Ed25519. При обновлении прежний ключ подписывает новый ключ и хеш конверта. Аутентифицированное устройство получает шифротекст и открытый ключ через GET /v1/account/recovery-envelope и локально раскрывает ключ сейфа фразой из 24 слов. Новый локальный сейф должен быть пустым. Клиент шифрует общий ключ мастер-паролем устройства и загружает ревизии по обычному курсору. API хранит конверт в зашифрованном виде.
Подписанное восстановление также может сбросить пароль OPAQUE. Успешный сброс заменяет верификатор, погашает незавершённые вызовы и отзывает все сессии обновления в одной транзакции. При восстановлении привязанного локального сейфа меняется локальная фраза. После подтверждения новой фразы клиент запрашивает прежнюю для подписи перехода ключа. Форматы сообщений приведены в протоколе восстановления v1.