Вызов Dela: прочитай мою задачу

Демо-аккаунт Dela с одной секретной задачей и всё, что о нём хранит сервер. Прочитайте текст задачи.

Проверьте файл

SHA-256 выгрузки dela-challenge.json. Если хеш скачанного файла другой, файл подменили или он скачался не целиком.

9e1cdfa989b84344d3bf0de8ba3276e79741484f630337132c08dd7323555a15

Чего в выгрузке не будет

Хеша пароля (auth_hash), мастер-ключа, зашифрованного паролем (bundle.masterByPassword), и их солей. С любой из этих вещей угаданный пароль проверяется на своём компьютере без сервера, и вызов свёлся бы к перебору моего пароля: короткий пароль ломается при любом шифровании.

Остаётся всё, что зашифровано ключом пространства и мастер-ключом. Это случайные ключи по 32 байта, из пароля они не выводятся. Почта аккаунта заменена на demo.

В Dela данные шифруются на устройстве, сервер хранит только шифротекст. Чтобы это можно было проверить, а не принять на веру, я публикую всё, что сервер хранит о демо-аккаунте. В нём обычные задачи обычного человека и одна секретная задача. Задача вызова: прочитать её текст.

Скрипты и команды: scripts/demo/README.md. Устройство шифрования: architecture.md, раздел «Шифрование».

Что публикуется

Выгрузка dela-challenge.json (формат dela-challenge/1) собирается скриптом scripts/demo/export.ts прямо из базы сервера. Правило для каждой таблицы записано в TABLE_POLICY в самом скрипте; таблица без правила останавливает выгрузку, а тест сверяет список со схемой сервера.

Таблица В выгрузке Почему
users id, created_at, is_admin, открытые ключи (bundle.publicKey X25519, sign_key Ed25519), bundle.secretKeyByMaster, параметры Argon2id (bundle.kdf) открытые ключи и так раздаются другим пользователям; секретный ключ X25519 зашифрован мастер-ключом
users.email заменена на demo личные данные автора
users.auth_salt, auth_hash, recovery_salt, recovery_hash, bundle.salt, bundle.masterByPassword, bundle.masterByRecovery нет см. «Почему нет хеша пароля»
users.totp_* нет секрет TOTP хранится открыто
spaces id, kind, created_at, last_seq, key_version; created_by только как «свой/чужой» пространства, где демо-аккаунт единственный участник
members role, key_kind, key для тех же пространств key это ключ пространства, обёрнутый мастер-ключом
envelopes seq, slot, hlc, payload для тех же пространств сами зашифрованные данные
files, file_parts id, size, parts, complete, created_at, шифротекст; created_by как «свой/чужой» вложения комментариев (в демо их нет)
devices число устройств и времена created_at, last_seen_at, revoked_at id и имена устройств заменены на device-N: имя ставит клиент, оно может описывать реальный телефон автора
sessions нет хеши токенов входа
invites нет приглашения и их авторы
push_subscriptions нет адреса push-сервисов устройств
push_schedule нет расписание напоминаний устройств; у секретной задачи нет срока, её там нет
passkeys нет ключи WebAuthn устройств автора
rotations, rotation_envelopes нет незаконченная смена ключа общего пространства, живёт до 30 минут
meta нет секрет сервера для поддельных солей и ключи VAPID

Общие пространства, где кроме демо-аккаунта есть другие участники, не выгружаются целиком: там чужие данные. Их число есть в сводке.

Почему нет хеша пароля

Сервер хранит три вещи, из которых пароль можно перебирать офлайн: auth_hash (scrypt от секрета входа, а секрет входа выводится из пароля через Argon2id), bundle.masterByPassword (мастер-ключ, зашифрованный ключом из пароля) и соль. С любой из них угаданный пароль проверяется локально, без сервера. Тогда вызов проверял бы стойкость моего пароля, а не шифрование: короткий пароль сломался бы при любом шифровании. Поэтому они не публикуются.

Честное следствие: тот, у кого есть база сервера (администратор или тот, кто её украл), может перебирать пароли пользователей. Argon2id с 64 МиБ памяти и 3 проходами делает каждую попытку дорогой, но от слабого пароля это не спасает. Длинный пароль или фраза из нескольких слов закрывает этот путь.

bundle.masterByRecovery зашифрован случайным 256-битным ключом восстановления и перебору не поддаётся. Он тоже не публикуется: вызову он ничего не добавляет, а ключ восстановления мог где-то засветиться. Соль Argon2id сервер отдаёт любому, кто знает почту (до входа нужен расчёт ключа), но без masterByPassword и хеша она бесполезна, а почта скрыта.

Остаётся честная задача: есть всё, что зашифровано ключом пространства и мастер-ключом, нет ничего, что выводится из пароля. Мастер-ключ и ключи пространств случайные, по 32 байта.

Что сервер видит открыто

  • Пространства: id (UUIDv7, в нём время создания), вид (личное или общее), время создания, состав участников и их роли, версию ключа.
  • Слот каждой ячейки: BLAKE2b-128(ключ слотов, JSON [таблица, id записи, поле]). Одна ячейка всегда лежит в одном слоте, поэтому видно, что её перезаписали. По слоту нельзя узнать таблицу, запись или поле и нельзя понять, какие слоты принадлежат одной записи.
  • Метка HLC каждой операции: время в мс-счётчик-id узла. Время ставят часы устройства, сервер принимает метку, только если она опережает его часы не больше чем на 60 секунд. Видно, когда писали и сколько. Id узла случайный для каждого экземпляра клиента: видно, сколько разных клиентов писали, но не какое это устройство.
  • Порядок прихода (seq) и last_seq: сколько операций сервер принял за всё время. Разница с числом записей в журнале показывает, сколько раз ячейки перезаписывались (сервер хранит только последнюю версию ячейки).
  • Длина шифротекста: 24 байта nonce, 16 байт тега и открытый текст, дополненный до кратного 64 байтам. Длина всегда 40 + 64·k.
  • Устройства: сколько их, когда созданы, когда последний раз обращались, когда отозваны, имена (в выгрузке заменены).
  • Аккаунт: почта, время регистрации, открытые ключи, включён ли TOTP.
  • Файлы: размер шифротекста, число частей, время загрузки.
  • Напоминания: время срабатывания (текст зашифрован отдельно).
  • IP-адрес сервер видит во время запроса и держит в памяти для ограничения попыток входа; в базу он не пишется (обратный прокси может писать его в свой журнал).

Чего сервер не видит

Вид записи (задача, проект, метка, привычка, комментарий), id записей, имена полей и все значения: заголовки, описания, сроки, приоритеты, метки, проекты и их названия, время выполнения, повторы, отрезки помодоро, текст комментариев, имена и типы файлов. Связи тоже скрыты: какая задача в каком проекте, какая подзадача чья, к какой задаче комментарий.

Конверт операции:

slot    = BLAKE2b-128(ключ слотов, JSON [table, record, field]), hex
payload = nonce(24) ‖ XChaCha20-Poly1305(ключ шифрования, pad64(JSON {t, r, f, v}), AAD = space ‖ slot ‖ hlc)

AAD привязывает шифротекст к пространству, слоту и метке: переставить значение в другую ячейку или подменить метку нельзя, проверка тега не пройдёт.

Что можно вывести косвенно

Это метаданные, не содержимое, но они настоящие, и про них честно сказать:

  • Длина текста с точностью до 64 байт. Ячейка заголовка задачи это JSON {"t":"task","r":"<36 символов id>","f":"title","v":"<заголовок>"}, 74 байта без заголовка, плюс минимум байт дополнения. Заголовок до 53 байт UTF-8 (около 26 русских букв) даёт шифротекст 168 байт, как у большинства записей; 54..117 байт дают 232 байта.
  • Группы ячеек одного действия. Создание задачи пишет 6..10 ячеек подряд (заголовок, проект, раздел, родитель, порядок, время создания, срок, метки), они идут соседними seq с соседними метками HLC. По размеру групп можно гадать, где создавали задачу, а где отмечали выполнение.
  • Часто меняемые ячейки: у повторяющейся задачи после каждого выполнения перезаписывается срок, это видно по last_seq.
  • Ритм работы: метки HLC по дням показывают, когда человек пользуется приложением.
  • В этом демо все метки HLC стоят в одном коротком интервале: данные залиты скриптом за секунду. Даты создания и выполнения за прошлые 8 недель лежат только внутри шифротекста; сервер видит одну дату, день наполнения.

Сводка в выгрузке (summary) и в выводе скрипта считает ровно эти вещи: число пространств, записей и принятых операций, распределение длин шифротекста, первую и последнюю метку, операции по дням (UTC), число узлов HLC, устройства и файлы. Вид записей в сводке null: серверу он не виден.

Как воспроизвести

  1. Поднять свой сервер Dela, создать приглашение и наполнить демо-аккаунт: node scripts/demo/seed.ts --server ... --email ... --invite ... --secret-prompt (подробно в README).
  2. На сервере: node scripts/demo/export.ts --db /data/dela.sqlite --email ... --out dela-challenge.json.
  3. Проверить, что выгрузка полная: войти в тот же аккаунт ядром (packages/core: signIn, ensurePersonalSpace, spaceKeys) и расшифровать конверты из выгрузки функцией openEnvelope. Так делает тест scripts/demo/test/challenge.test.ts: из выгрузки ключом аккаунта читается ровно одна секретная задача, а без ключа в выгрузке нет ни почты, ни хешей, ни секрета.

Скрипт выгрузки перед записью файла проверяет его сам: в нём нет почты ни одного пользователя, id других пользователей, пространств и устройств, солей, хешей и обёрток мастер-ключа, секретов сервера; каждое строковое поле известного вида (id, слот, метка, base64 нужной длины); каждый шифротекст записи имеет длину 40 + 64·k и не читается как текст UTF-8. Сервер сам не проверяет, что клиент прислал шифротекст, поэтому эта проверка ловит и клиента, который отправил бы открытый текст.

Текст вызова в репозитории Dela

Сообщить о баге или уязвимости в Dela