Вызов 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: серверу он не виден.
Как воспроизвести
- Поднять свой сервер Dela, создать приглашение и наполнить демо-аккаунт:
node scripts/demo/seed.ts --server ... --email ... --invite ... --secret-prompt(подробно в README). - На сервере:
node scripts/demo/export.ts --db /data/dela.sqlite --email ... --out dela-challenge.json. - Проверить, что выгрузка полная: войти в тот же аккаунт ядром (
packages/core:signIn,ensurePersonalSpace,spaceKeys) и расшифровать конверты из выгрузки функциейopenEnvelope. Так делает тестscripts/demo/test/challenge.test.ts: из выгрузки ключом аккаунта читается ровно одна секретная задача, а без ключа в выгрузке нет ни почты, ни хешей, ни секрета.
Скрипт выгрузки перед записью файла проверяет его сам: в нём нет почты ни одного пользователя, id других пользователей, пространств и устройств, солей, хешей и обёрток мастер-ключа, секретов сервера; каждое строковое поле известного вида (id, слот, метка, base64 нужной длины); каждый шифротекст записи имеет длину 40 + 64·k и не читается как текст UTF-8. Сервер сам не проверяет, что клиент прислал шифротекст, поэтому эта проверка ловит и клиента, который отправил бы открытый текст.