MOffice

Шифрование пакетов MOP, версия 1 (нормативная спецификация)

Реализация: kernel/crates/meridian-crypto (сессия B). Векторы: spec/formats/vectors/mop-encryption-v1.json; независимая проверка на Python: kernel/crates/meridian-crypto/tests/reference/mop_reference.py (совпадает байт в байт). Уточняет docs/04-file-formats.md §2.16 (владелец — сессия A; запрос на синхронизацию — во «Входящих» A). Основа — собственный код владельца PasswordSafe (crypto.rs, vault/envelope.rs, ветка wip/snapshot-2026-10-04), с исправлениями из брифа B2: ключи через HKDF, MAC на весь файл, потоковое AES-256-GCM по чанкам, Argon2id по RFC 9106.

Ключевые слова «должен», «не должен», «может» — в смысле RFC 2119. Все целые — big-endian. ‖ — конкатенация.

1. Контейнер

Зашифрованный документ — ZIP с тремя записями:

Запись Сжатие Содержимое
mimetype stored, первая application/vnd.moffice.encrypted (без перевода строки)
package.bin stored шифротекст исходного пакета MOP (§4)
encryption.xml deflate или stored заголовок (§3)

Порядок package.bin и encryption.xml свободный: заголовок готов только после записи всего шифротекста (в нём размер и хэш), поэтому пишущий может положить его после package.bin. Читатель находит записи по центральному каталогу ZIP. thumbnail.png, meta.xml и прочие части исходного пакета в открытом виде не хранятся.

2. Ключи

Обозначение Как получается Назначение
DEK 32 байта CSPRNG ОС ключ файла; никогда не используется напрямую
file_id 16 байт CSPRNG соль HKDF, входит в AAD
nonce_prefix 7 байт CSPRNG префикс nonce чанков
K_data HKDF-SHA256(salt = file_id, IKM = DEK, info = "meridian-mop-v1/data-key", L = 32) AES-256-GCM чанков
K_mac HKDF-SHA256(salt = file_id, IKM = DEK, info = "meridian-mop-v1/header-mac-key", L = 32) HMAC заголовка

HKDF — RFC 5869 (Extract, затем Expand). Строки info — ASCII без завершающего нуля.

2.1 Ключ-шифровальщик «пароль» (m:password)

pw   = UTF-8(NFC(пароль))                        — пустой пароль запрещён
A    = Argon2id(pw, salt (16 байт), m = memory КиБ, t = time, p = parallelism, T = 32, версия 0x13, без secret и AD)
KEK  = HKDF-SHA256(salt = file_id, IKM = A, info = "meridian-mop-v1/kek/password")
wrapped-key = AES-256-GCM(KEK, nonce = wrap-nonce (12 байт), AAD = "meridian-mop-v1/wrap/password" ‖ file_id, DEK)
            = 32 байта шифротекста ‖ 16 байт тега

Параметры по умолчанию — вторая рекомендация RFC 9106 §4: memory = 65536 (64 МиБ), time = 3, parallelism = 4. Профиль организации может задать первую: memory = 2097152 (2 ГиБ), time = 1, parallelism = 4.

2.2 Ключ-шифровальщик «код восстановления» (m:recovery-code)

Код — 20 байт CSPRNG (160 бит), показывается пользователю один раз в виде 32 символов Crockford Base32 (0123456789ABCDEFGHJKMNPQRSTVWXYZ) группами по 4 через дефис: XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX. При вводе регистр, пробелы и дефисы не важны; I, L → 1, O → 0.

KEK = HKDF-SHA256(salt = file_id, IKM = код (20 байт), info = "meridian-mop-v1/kek/recovery-code")
wrapped-key = AES-256-GCM(KEK, wrap-nonce, AAD = "meridian-mop-v1/wrap/recovery-code" ‖ file_id, DEK)

Argon2id не нужен: код имеет полную энтропию. Перенос идеи из PasswordSafe (ключ восстановления хранилища).

2.3 Будущие виды

m:certificate (RSA-OAEP-SHA256) и HPKE X25519 для корпоративного восстановления — в следующих версиях. Читатель должен сохранять неизвестные элементы внутри m:key-encryptors как есть (имя и атрибуты): они входят в MAC.

3. Заголовок encryption.xml

<?xml version="1.0" encoding="UTF-8"?>
<m:encryption xmlns:m="urn:moffice:core:1" version="1" inner-mime="application/vnd.moffice.write">
  <m:data cipher="AES-256-GCM" stream="stream-v1" chunk-size="4096" chunks="2" plain-size="5000"
          nonce-prefix="b64:MDEyMzQ1Ng==" file-id="b64:ICEiIyQlJicoKSorLC0uLw==" package-sha256="b64:…"/>
  <m:key-encryptors>
    <m:password id="pw1" kdf="argon2id" kdf-version="19" memory="65536" time="3" parallelism="4"
                salt="b64:…" wrap-nonce="b64:…" wrapped-key="b64:…"/>
    <m:recovery-code id="rc1" wrap-nonce="b64:…" wrapped-key="b64:…"/>
  </m:key-encryptors>
  <m:mac alg="HMAC-SHA256" value="b64:…"/>
</m:encryption>

Правила разбора (читающий должен их соблюдать):

  1. Размер ≤ 64 КиБ, UTF-8, без DTD, сущностей, инструкций обработки, текста и CDATA; комментарии допускаются.
  2. Все элементы — в пространстве имён urn:moffice:core:1 (префикс любой); атрибуты без префикса.
  3. Порядок: m:encryption → m:data → m:key-encryptors (1…32 пустых элемента) → m:mac. Лишние или повторные элементы и атрибуты — ошибка. Значение атрибута ≤ 4096 байт.
  4. Числа — десятичные без знака и ведущих нулей. Двоичные значения — b64: + Base64 (RFC 4648, с дополнением) точной длины: nonce-prefix 7, file-id 16, package-sha256 32, salt 16, wrap-nonce 12, wrapped-key 48, mac 32 байта.
  5. version = 1; cipher = AES-256-GCM; stream = stream-v1; kdf = argon2id; kdf-version = 19; иначе — «не поддерживается».
  6. chunk-size ∈ 4096..=67108864; chunks = max(1, ⌈plain-size / chunk-size⌉).
  7. inner-mime — тип исходного пакета ([a-z0-9.+/-], ≤ 255 символов); вложенное шифрование запрещено.
  8. id ключей-шифровальщиков уникальны, [A-Za-z0-9_-]{1,64}.

3.1 MAC заголовка («MAC на весь файл»)

MAC считается по канонической последовательности атрибутов, а не по тексту XML (переформатирование и смена префикса пространства имён MAC не ломают):

str(x)  = u32(длина UTF-8 x) ‖ UTF-8 x
elem(e) = str(локальное имя e) ‖ u32(число атрибутов) ‖ для атрибутов в порядке возрастания имени (по байтам): str(имя) ‖ str(значение)
canon   = "meridian-mop-v1/header" ‖ elem(m:encryption) ‖ elem(m:data) ‖ elem(каждый ключ-шифровальщик по порядку документа)
mac     = HMAC-SHA256(K_mac, canon)

Значения атрибутов — после раскрытия ссылок XML и нормализации атрибутов XML 1.0. Атрибуты xmlns/xmlns:* в каноническую форму не входят. Поскольку m:data содержит plain-size, chunks и package-sha256 (SHA-256 всего package.bin), MAC заголовка удостоверяет весь файл: параметры, ключи-шифровальщики, тип пакета и шифротекст.

4. Данные package.bin (конструкция STREAM)

Исходный пакет P делится на чанки по chunk-size байт (по умолчанию 4 МиБ); последний чанк — от 0 до chunk-size байт; пустой пакет — один пустой чанк. Для чанка i (с нуля) из n:

nonce_i = nonce_prefix (7) ‖ u32(i) ‖ u8(i = n − 1 ? 1 : 0)
AAD     = "meridian-mop-v1/chunk" ‖ file_id
C_i     = AES-256-GCM(K_data, nonce_i, AAD, P_i)       — шифротекст ‖ тег (16 байт)
package.bin = C_0 ‖ C_1 ‖ … ‖ C_{n−1}

Флаг последнего чанка в nonce защищает от усечения и дописывания, номер — от перестановки (Hoang, Reyhanitabar, Rogaway, Vizár, 2015). Пишущему не нужно знать размер заранее: последний чанк определяется заглядыванием вперёд. Число чанков ограничено 2³².

5. Порядок открытия

  1. Разобрать encryption.xml по §3 (пределы — до любых вычислений).
  2. Для пароля — перебрать m:password по порядку: вычислить KEK, развернуть wrapped-key; первый успешный даёт DEK. Ни один не подошёл — «неверный пароль»; ни одного элемента нужного вида — «нет ключа-шифровальщика».
  3. Вывести K_data, K_mac; сверить mac (сравнение за постоянное время). Не совпал — «заголовок изменён».
  4. Расшифровать чанки потоком, выдавая открытый текст чанка только после проверки его тега; параллельно считать SHA-256 всего package.bin.
  5. Сверить число чанков, plain-size и package-sha256. Пока проверка не завершена, результат не должен использоваться (пишется во временный файл, переименование — после успеха).

6. Операции без перешифровки данных

7. Тестовые векторы

spec/formats/vectors/mop-encryption-v1.json — три случая (два чанка с кодом восстановления и паролем в разложенной форме Unicode; пустой документ; размер ровно на границе чанка). Открытый текст: P[i] = i mod 251. «Случайные» байты — последовательность 00 01 02 …, расходуемая в порядке: DEK (32), file_id (16), nonce_prefix (7), соль (16), wrap-nonce (12), [код восстановления (20), его wrap-nonce (12)]. Векторы проверяются тестом крейта (cargo test -p meridian-crypto --test vectors) и независимой реализацией на Python (tests/reference/mop_reference.py, библиотеки cryptography и argon2-cffi).

8. Отличия от docs/04-file-formats.md §2.16 (черновика)

Было Стало Почему
Ключ данных = DEK напрямую K_data, K_mac из DEK через HKDF разделение ключей по назначению
KEK = выход Argon2id напрямую KEK = HKDF(выход Argon2id) то же; привязка к file_id
Заголовок без MAC HMAC-SHA256 по канонической форме, включая хэш всего package.bin целостность параметров, ключей-шифровальщиков и файла
nonce-prefix 8 байт ‖ u32(i); AAD с chunks и plain-size префикс 7 байт ‖ u32(i) ‖ флаг последнего; AAD с file_id STREAM: пишущему не нужен размер заранее, усечение ловится флагом
Атрибут aad stream="stream-v1" алгоритм задаётся версией, а не строкой AAD
Только пароль и сертификат + код восстановления (из PasswordSafe) офлайн-восстановление без организации

9. Безопасность и ограничения