Экспорт: ключ архива из секрета аккаунта через HKDF, заголовок ровно 65 байт (magic, версия, соль, 32 сырых байта отпечатка владельца, iv) и он же целиком AAD шифротекста. Ника владельца в файле нет. Импорт сверяет отпечаток до расшифровки, сливает идемпотентно по id, а записи peers берёт только для ников, которых в локальном TOFU ещё нет: архивом доверие к ключу не перебить. Настройки: устройства с датой и пометкой «это устройство», «занято N МБ», кнопка «экспортировать» в подтверждении выхода и в подтверждении входа под другим ником — долг этапа 1 и обещание ADR-029 закрыты. Лента: страницы по 50 с подгрузкой вверх без прыжка прокрутки; новая страница вставляется, а не пересобирает ленту. ADR-050: импорт не перезаписывает лежащую запись — у своей есть состояние отправки, которого в архиве нет. ADR-054: архив — недоверенный ввод. Ревью собрало архивы с ts вне диапазона Date, мусорным lastId, ником с bidi-переопределением и roomId с обходом пути: каждый из них навсегда ломал ленту или счётчик. Теперь форма ника, roomId, id, ts и автора проверяется, а lastId из файла не читается вовсе. ADR-051, 052, 053: тексты и кнопки, устройства и место, страницы ленты без виртуализации. Приёмка: формат сверен побайтно на модулях, скачанных с боевого сервера, — смещения заголовка, отпечаток сырыми байтами, новые соль и iv на каждый экспорт, подмена любого байта заголовка ломает расшифровку, чужой секрет не открывает, семь видов битых файлов отвергнуты. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015DbCjVfTFq4ZFG8juD45YJ
120 lines
9.6 KiB
Markdown
120 lines
9.6 KiB
Markdown
# Криптография
|
|
|
|
Все операции — WebCrypto (`crypto.subtle`), все случайные байты — `crypto.getRandomValues`. Кодировка бинарных полей в JSON — base64url без паддинга. Строки в UTF-8, пароль нормализуется в NFC. Названия констант — буквальные строки, они входят в вывод ключей и менять их нельзя.
|
|
|
|
## Аккаунт
|
|
|
|
### Мастер-ключ и два ключа из него
|
|
|
|
```
|
|
salt = SHA-256(utf8("bare-v1:" + nick)) // 32 байта
|
|
master = PBKDF2-HMAC-SHA256(utf8(NFC(password)), salt, iter, 256 бит)
|
|
authKey = HKDF-SHA256(master, salt = пусто, info = "bare-auth-v1", 32 байта) → base64url
|
|
kek = HKDF-SHA256(master, salt = пусто, info = "bare-kek-v1") → AES-GCM-256
|
|
```
|
|
|
|
`iter` — из `GET /api/kdf?nick=` перед входом, из `GET /api/config` при регистрации. Целевое значение сервера — 1 000 000, границы — от 600 000 до 10 000 000 (ADR-013, ADR-030). Границы держат обе стороны: сервер не принимает блоб с `iter` вне них, клиент проверяет пришедшее число до `deriveBits` и не считает по нему ничего. WebCrypto: `deriveBits` из PBKDF2, результат импортируется `importKey("raw", …, "HKDF")`, дальше `deriveBits`/`deriveKey`.
|
|
|
|
`authKey` — единственное, что уходит на сервер. Пароль и `master` не покидают память клиента и не пишутся в IndexedDB.
|
|
|
|
### Ключевая пара и секрет аккаунта
|
|
|
|
- Пара — `generateKey({name: "ECDH", namedCurve: "P-256"}, extractable: true, ["deriveBits"])`. Экспорт: публичный — JWK, приватный — JWK (только для упаковки в блоб).
|
|
- Секрет аккаунта — 32 случайных байта.
|
|
- Отпечаток — `SHA-256(exportKey("raw", publicKey))`, 65-байтовая несжатая точка. Показывается как 64 hex-символа группами по 4, нижний регистр.
|
|
|
|
### Ключевой блоб
|
|
|
|
```
|
|
plain = JSON {"priv": <JWK приватного ключа>, "secret": <base64url 32 байта>}
|
|
iv = 12 случайных байт
|
|
ct = AES-GCM(kek, iv, utf8(plain), AAD = utf8("bare-blob-v1|" + nick))
|
|
blob = JSON {"v": 1, "iter": iter, "iv": iv, "ct": ct}
|
|
```
|
|
|
|
Сервер хранит `blob` как непрозрачную строку. При входе клиент читает `iter` из блоба, а не из ответа `/api/kdf`: расхождение означает несогласованность данных и показывается как ошибка.
|
|
|
|
### Хранение на устройстве
|
|
|
|
После расшифровки блоба:
|
|
|
|
- приватный ключ — `importKey("jwk", priv, ECDH P-256, extractable: false, ["deriveBits"])`, объект `CryptoKey` кладётся в IndexedDB `meta.privateKey`;
|
|
- секрет — `importKey("raw", secret, "HKDF", extractable: false, ["deriveKey", "deriveBits"])` → `meta.accountSecret`;
|
|
- публичный ключ — JWK → `meta.publicKey`; отпечаток → `meta.fingerprint`.
|
|
|
|
Сырые байты приватного ключа и секрета живут в памяти только во время входа, регистрации и смены пароля.
|
|
|
|
### Повышение итераций и смена пароля
|
|
|
|
Одна процедура. Вход: старый `authKey` уже вычислен. Клиент выводит `master'` с новым паролем или новым `iter`, получает `authKey'` и `kek'`, собирает новый `blob` из сырых байт (они есть: при входе — только что расшифрованы; при смене пароля — блоб скачивается и расшифровывается старым `kek` заново). Отправляет `POST /api/password {authKey, newAuthKey, blob, logoutOthers}`.
|
|
|
|
Автоматическое повышение происходит, когда `blob.iter < config.kdfIterations`, сразу после входа, с `logoutOthers: false`.
|
|
|
|
## Чат 1:1
|
|
|
|
```
|
|
shared = ECDH.deriveBits(myPrivate, peerPublic, 256)
|
|
dmKey = HKDF-SHA256(shared, salt = utf8("bare-dm-v1"), info = utf8(a + "\0" + b)) → AES-GCM-256
|
|
```
|
|
|
|
`a`, `b` — ники пары по возрастанию. Ключ симметричен для обеих сторон и всех их устройств. Кэшируется в памяти, в IndexedDB не пишется — выводится заново из `peers`.
|
|
|
|
## Комната
|
|
|
|
### Ключ
|
|
|
|
`roomKey` — 32 случайных байта, `keyId` — 16 случайных байт base64url. Распространитель держит сырые байты только до конца заворачивания, потом импортирует себе non-extractable AES-GCM-256.
|
|
|
|
### Заворачивание участнику
|
|
|
|
```
|
|
shared = ECDH.deriveBits(distributorPrivate, memberPublic, 256)
|
|
wrapK = HKDF-SHA256(shared, salt = utf8("bare-wrap-v1"), info = utf8("bare-roomkey-v1|" + roomId + "|" + keyId)) → AES-GCM-256
|
|
iv = 12 случайных байт
|
|
ct = AES-GCM(wrapK, iv, roomKey, AAD = utf8("bare-roomkey-v1|" + roomId + "|" + keyId + "|" + from + "|" + to))
|
|
```
|
|
|
|
Запись `{to, iv, ct}` уходит на сервер в `keys[]`. Участник разворачивает той же схемой со своим приватным и публичным ключом `from`, импортирует `roomKey` как non-extractable AES-GCM-256 и хранит в IndexedDB `roomKeys[roomId, keyId]`.
|
|
|
|
Публичный ключ `from` проходит через TOFU как любой другой. Заворачивание самому себе — `ECDH(myPrivate, myPublic)`, без исключений в коде.
|
|
|
|
Ключ, чей `from` не входит в состав комнаты, пришедший тем же `Room`, отвергается до запроса публичного ключа: TOFU запоминает первый ключ ника молча, поэтому незнакомый распространитель — это подмена, а не первый контакт (ADR-039).
|
|
|
|
## Сообщение
|
|
|
|
```
|
|
chat = "dm:" + a + ":" + b | "room:" + roomId
|
|
aad = utf8("bare-msg-v1|" + id + "|" + chat + "|" + from + "|" + keyId)
|
|
plain = JSON {"t": text}
|
|
iv = 12 случайных байт
|
|
ct = AES-GCM(key, iv, utf8(plain), aad)
|
|
```
|
|
|
|
`key` — `dmKey` при `keyId = "dm"`, иначе `roomKeys[roomId, keyId]`. `from` — собственный ник отправителя; сервер проставляет то же значение из сессии, поэтому AAD сходится у получателя. Расшифровка с неизвестным `keyId` или ошибкой AEAD не является фатальной: сообщение сохраняется как нерасшифрованное с кодом причины.
|
|
|
|
## Экспорт `.bare`
|
|
|
|
```
|
|
salt = 16 случайных байт
|
|
exportKey = HKDF-SHA256(accountSecret, salt, info = utf8("bare-export-v1")) → AES-GCM-256
|
|
iv = 12 случайных байт
|
|
payload = JSON {"v": 1, "exportedAt": ms, "chats": [...], "messages": [...], "peers": [...]}
|
|
ct = AES-GCM(exportKey, iv, utf8(payload), AAD = header)
|
|
file = header || ct
|
|
header = "BARE" (4) || version u8 = 1 || salt (16) || fingerprint (32) || iv (12) // 65 байт
|
|
```
|
|
|
|
Импорт: проверить magic и версию, сравнить `fingerprint` со своим — при несовпадении показать «архив создан другим аккаунтом» и остановиться, иначе вывести ключ и расшифровать. Слияние — идемпотентное по `id` сообщений и `id` чатов; записи `peers` из архива добавляются только для ников, которых в локальном TOFU ещё нет.
|
|
|
|
Чужая магия и незнакомая версия — файла в заголовке или нагрузки в поле `v` — показываются тем же текстом, что и порча: «файл повреждён». Третьего текста нет (ADR-054). Форму записей внутри нагрузки клиент проверяет сам — `docs/storage.md`, «Экспорт `.bare`».
|
|
|
|
## Идентификаторы
|
|
|
|
- ULID: 48 бит миллисекунд + 80 бит случайности, Crockford base32, 26 символов. Внутри одной миллисекунды на одном клиенте случайная часть инкрементируется.
|
|
- `deviceId`, `keyId`, `roomId` — 16 случайных байт base64url (22 символа).
|
|
- Сессионный токен — 32 случайных байта, на сервере хранится `SHA-256`.
|
|
|
|
## Что сервер проверяет, а что нет
|
|
|
|
Сервер не умеет и не пытается проверять шифротексты. Он проверяет форму: base64url, длины (`iv` = 12 байт, `ct` не короче 16), существование `keyId` для комнаты, формат ULID и его время.
|