Перейти к содержимому
SafePass

Протокол зашифрованной синхронизации 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 байт. Допустимы буквы, цифры, -, _, . и @.
  • Конверт ревизии — полный двоичный SPCR v1 в 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.

Документация