# Triad Storage: ограниченная независимая записка по проектированию

Дата: 25 сентября 2026. Это предложение формата и испытаний, не утверждение о готовой реализации или сертификации. Site checkout не изменялся. Посмотрены `public/continuity.mjs`, `continuity-store.mjs`, `continuity-service.mjs`: сейчас это локальное IndexedDB-хранилище идентичности, контактов и зашифрованных курьерских сообщений. Там нет распределённого хранения файлов, Reed–Solomon, восстановимого резервирования ключей или доказательства независимости устройств.

## Рекомендуемая гарантия

Владелец шифрует файл **до** отправки. Зашифрованный контейнер кодируется в девять фрагментов RS(9,3). Любые три подлинных фрагмента с различными индексами восстанавливают контейнер. Шесть отсутствующих или обнаруженно испорченных фрагментов допустимы, если оставшиеся три доступны и сохранились ключ расшифрования и доверенный манифест. Свойство «любые k из n» относится к MDS-кодам для стираний. [RFC 5510](https://www.rfc-editor.org/rfc/rfc5510.html).

Это не обещание доступности при любых действиях 2/3 участников. Шесть атакующих могут молчать: восстановление возможно только через остальные три доступных фрагмента. Ещё один отказ означает недостаток данных. Девять аккаунтов одного оператора не образуют девять независимых мест хранения. Даже все девять фрагментов вместе не должны раскрывать содержимое без ключа AES; эту конфиденциальность обеспечивает шифрование, а не разделение файла.

Обычная коррекция неизвестных ошибок RS не равна устойчивости к шести произвольным изменениям. **До декодирования** проверяем каждый фрагмент по хешу из аутентифицированного манифеста; неподлинный фрагмент становится известным стиранием. Не выбираем ответы большинством и не принимаем хеш, присланный самим хранилищем вместо доверенного хеша.

## Угрозы и доверие

Недоверенный телефон может читать сохранённые байты, возвращать старые/чужие/изменённые фрагменты, дублировать индексы, удалять данные, лгать о сохранении, бесконечно задерживать ответ или переполнять запросами. Координатор может подменять маршруты и состав участников. Все они могут обмениваться своей информацией.

Требуется доверять устройству владельца, его криптографическому генератору, исполняемому приложению, закреплённому ключу подписанта манифеста и сохранённому последнему состоянию каталога. Взломанный сайт может выдать JavaScript, читающий выбранный файл **до** шифрования или вызывающий расшифрование неэкспортируемым CryptoKey. Поэтому веб-пилот не является подходящей доказанной защитой государственных секретов или других особо чувствительных материалов. Для такого применения нужны отдельная модель угроз, проверяемая цепочка выпуска клиента, внешняя оценка и применимые организационные требования; выбор AES сам по себе этого не обеспечивает.

Для пилота — только тестовые/несекретные данные. Архитектура ниже снижает конкретные риски, но не делает экспериментальное приложение сертифицированным хранилищем.

## Точный предлагаемый формат v1

Это новый профиль `triad-storage-v1`, **не** существующий чатовый `seal/open`: у чата другие ограничения размера, срока жизни и ключей. Чатовые AES-ключи и nonce для файлов не использовать. Отдельный постоянный ключ подписи хранилища предпочтительнее краткоживущей идентичности комнаты.

### 1. Ключ и закрытый контейнер

- На каждую новую неизменяемую версию файла генерировать независимый 32-байтовый DEK через CSPRNG; AES-256-GCM, nonce 12 байт, authentication tag 16 байт. Никогда не шифровать разные данные повторно с той же парой DEK/nonce. Повтор отправки использует прежние **неизменные** байты ciphertext; повтор шифрования после изменения файла создаёт новый DEK и nonce. 96-битный IV и требование уникальности соответствуют рекомендациям GCM. [NIST SP 800-38D](https://csrc.nist.gov/pubs/sp/800/38/d/final).
- Plaintext container: ASCII `TRIADST1` (8 байт), `metadataLength` uint32 big-endian, затем ровно столько UTF-8 JSON-байтов метаданных и ровно `fileLength` байтов файла. Метаданные: `{name,mime,fileLength}`. Имя и MIME не публиковать в открытом манифесте. Пустой файл допустим. JSON без повторных ключей; `fileLength` — неотрицательное безопасное целое. Не распаковывать архивы и не исполнять содержимое.
- `name` — максимум 255 UTF-8 байтов; `mime` — максимум 127 ASCII байтов и только как подсказка интерфейсу; весь metadata JSON — максимум 4096 байтов. Восстановленный файл выдавать как download; не открывать HTML/SVG/скрипты в origin приложения. Имя очищать от путей, разделителей и управляющих символов при сохранении.
- В v1 файл не более 4 MiB. Полный plaintext не более `4 MiB + 12 + 4096`. Ciphertext — результат WebCrypto AES-GCM, включая последние 16 байт tag, без JSON/base64-перекодирования большого бинарного файла.
- Дополнительные аутентифицируемые данные AES — UTF-8 JCS от `header`, описанного далее. Закрытые метаданные находятся внутри шифрования. Профиль v1 не скрывает длину: по длине ciphertext можно оценить размер файла. Padding отдельными фиксированными корзинами следует добавлять новым явно описанным профилем, а не заявлять уже реализованную защиту размера.

### 2. Открытый header и подписанный манифест

`header` содержит только фиксированные поля:

```json
{
  "format":"triad-storage-v1",
  "objectId":"<32 random bytes, base64url>",
  "revision":1,
  "previousManifestHash":null,
  "ownerId":"<SHA-256 normalized signing JWK, lowercase hex>",
  "encryption":"AES-256-GCM",
  "nonce":"<12 bytes, base64url>",
  "ciphertextLength":12345,
  "coding":"rs-gf256-vandermonde-9-3-v1",
  "k":3,
  "n":9,
  "shardLength":4115
}
```

`objectId` сохраняется при обновлении логического файла; каждая revision получает свежий DEK/nonce. `revision` — целое 1..2^31−1. У первой версии `previousManifestHash=null`, у остальных — SHA-256 ранее принятого тела манифеста. Восстановление неизменяемого объекта может обходиться без обновлений; это предпочтительный первый пилот.

Тело манифеста: `{header, ownerSigningKey, ciphertextHash, shards}`. `ownerSigningKey` — публичный P-256 JWK ровно `{kty:"EC",crv:"P-256",x,y}` без `d`; `ownerId=SHA256(JCS(ownerSigningKey))`. `shards` — массив ровно из девяти записей `{index,length,sha256}`, строго в порядке 0..8, без повторов. `ciphertextHash` и `sha256` — 32 байта SHA-256 в lowercase hex.

Подпись ECDSA P-256/SHA-256 над `UTF8("TRIAD-STORAGE-MANIFEST-V1\0") || JCS(body)`. Сериализация подписи — WebCrypto raw `r || s`, 64 байта, base64url, не DER. Transport wrapper: `{body,signature}`. `manifestHash=SHA256(JCS(body))`, независимо от вариаций подписи. JCS нужен для однозначного представления подписываемых байтов; повторные JSON-ключи отклонять до потери этой информации обычным `JSON.parse`. [RFC 8785](https://www.rfc-editor.org/rfc/rfc8785.html).

Ключ `ownerSigningKey` из самого манифеста **не устанавливает доверие**: сравнить ownerId с заранее закреплённым владельцем либо сравнить manifestHash с сохранённым доверенным recovery-каталогом. Подпись без независимого якоря не защищает от замены одновременно манифеста, ключа и данных.

Manifest максимум 16 KiB UTF-8, фиксированная глубина схемы, никаких неизвестных полей. Все числа — safe integers. Base64url строго канонический, без padding; сначала ограничить длину строки, затем декодировать и проверить ожидаемую длину. Все length-поля проверяются согласованно **до выделения буфера**. Не допускаются произвольные значения k/n, матрицы, algorithms, URL или имена библиотечных модулей из входа.

### 3. Однозначный RS-профиль

Для независимых совместимых реализаций недостаточно строки «Reed–Solomon».

- Поле GF(256), байтовое polynomial basis; неприводимый полином `0x11d`, примитивный элемент `0x02`.
- Девять различных точек `x_r=r+1`, r=0..8. Матрица V размера 9×3: строка r равна `[1,x_r,x_r²]`, операции в GF(256).
- A — первые три строки V; G = V × inverse(A). Первые три строки G единичные; профиль систематический. Это конкретный предложенный профиль, не заявление побайтовой совместимости со всеми реализациями RFC 5510.
- `shardLength=ceil(ciphertextLength/3)`. Дополнить ciphertext нулями справа до `3*shardLength`, разделить на три последовательных равных блока D0,D1,D2. Для каждой позиции байта j: `shard[r][j]=Σ G[r][c]*Dc[j]`.
- Восстановление: выбрать три **различных проверенных** индекса, инвертировать соответствующие строки G, получить D0,D1,D2, склеить, проверить нулевое дополнение, обрезать до ciphertextLength. Проверить ciphertextHash, затем AES-GCM с прежним AAD и DEK. После успешной проверки tag разобрать закрытый контейнер и точные длины; до этого не выдавать plaintext пользователю/файлу.
- Для этого лимита максимальный ciphertext `4,198,428` байтов, shard `1,399,476` байтов; девять raw shards занимают примерно тройной объём ciphertext. Производственную реализацию кодека следует выбирать по проверяемой спецификации, межреализационным векторам и аудиту; собственная реализация только как экспериментальная и явно так обозначенная.

### 4. Получение фрагментов и манифеста

Каждый запрос связан с ожидаемыми `manifestHash`, индексом и длиной из доверенного каталога. Ответ не может менять алгоритм, индекс, ownerId или ожидаемый хеш. Дубли одного индекса не увеличивают число доступных фрагментов. Проверять SHA-256 сырых shard-байтов по заранее проверенному манифесту; данные неверного размера отбрасывать ещё раньше. Ложный «готово» или ACK узла не считать доказательством хранения.

Подпись под старым корректным манифестом остаётся корректной. Защита от rollback требует сохранённого максимального revision и manifestHash; цепочка previousManifestHash без закреплённого последнего head не доказывает свежесть. Новое устройство, имеющее только старую recovery-копию, не может магически определить наличие более новой скрытой версии. UI должен показывать восстановленную revision и дату последней проверки каталога.

## Ключи, восстановление и метаданные

Recovery-набор владельца должен содержать закреплённый manifestHash, полный манифест, DEK/обёрнутый DEK, версии формата и достаточные данные поиска узлов. Само право читать ciphertext не должно давать ключ. Получение всех shard не помогает при утрате DEK; один DEK без трёх shard тоже недостаточен.

Для первого пилота: отдельный случайный 256-битный recovery secret, которым AES-GCM шифруется recovery-набор под свежим nonce; recovery secret хранится отдельно от зашифрованного набора и чужих устройств. Две независимо доступные защищённые резервные копии проверяются реальным восстановлением на чистом устройстве. Ключ нельзя помещать в URL, invitation, telemetry, логи, crash report или рядом с shard. Парольная защита требует отдельного утверждённого профиля KDF и защиты от офлайн-перебора; обычный человеческий пароль не заменяет случайный recovery secret.

Для резервирования ключа можно создать transient raw DEK, зашифровать recovery-набор и импортировать рабочий CryptoKey как nonextractable. Обнуление JS-массива — best effort: оно не доказывает очистку всех копий памяти. Nonextractable ограничивает экспорт через API, но не мешает коду origin пользоваться ключом. Модель WebCrypto и ограничения безопасности описаны в [W3C Web Cryptography](https://www.w3.org/TR/webcrypto/).

RS-фрагменты файла — не secret sharing ключа. Не размещать DEK под схемой 3-из-9 у тех же недоверенных владельцев: три из них смогут его восстановить. Если нужны доверенные хранители recovery-ключа, это отдельные люди/устройства и отдельная проверенная threshold-схема с собственным порогом доверия.

Узлы могут видеть IP, время, объём, частоту запросов, доступность и факт связи; открытый манифест дополнительно связывает версии/владельца/фрагменты. Использовать независимые случайные locator/capability на каждом узле; маршруты держать в закрытом recovery-каталоге, а не отдавать всем полную топологию. Не публиковать plaintext-хеши: они помогают проверять догадки о содержимом. Новые случайные DEK предотвращают одинаковый ciphertext для повторно сохранённого файла, но не скрывают размер и поведение.

Ротация ключа означает новое шифрование и новый набор фрагментов. Отзыв доступа не стирает уже скачанный plaintext или старые ciphertext+DEK. Команда delete не гарантирует удаления копий на враждебном телефоне. «Криптографическое удаление» требует уничтожения **всех** копий ключа и не доказывает физическое стирание чужих накопителей.

## Доступность, ресурсные пределы и честные статусы

Порог считать по уникальным индексам и реальным местам хранения, не по ACK и не по количеству аккаунтов. В пилоте показывать: «N/9 фрагментов проверено чтением; нужно3», время последней проверки, обнаруженную порчу/недоступность и число независимых устройств, насколько оно известно. Не показывать «защищено66,6%» как общий уровень безопасности.

Для утверждения о переживании любых шести потерь сначала успешно записать и прочитать обратно все девять **различных** shard на девяти предполагаемо независимых устройствах. Даже это доказывает состояние на момент проверки, а не будущую доступность. При меньшем числе копий — «неполное резервирование». Распределять разные операторы/сети/питание/места; мобильный background, эвикция browser storage и общие аварии остаются рисками. Один сервер-индекс не должен быть единственным местом хранения манифеста и locator.

Рекомендуемые ограничения пилота: файл4MiB, максимум9 запланированных ответов/объект, не более3 параллельных загрузок на владельца и2 на узел; timeout20s на попытку, максимум2 попытки на locator, явная отмена; bounded backoff. Входящий stream ограничивается ожидаемой raw длиной, не только Content-Length; избыточные данные прерываются. На узле opt-in по умолчанию выключен, дисковая квота например64MiB на пилот, на отправителя отдельный бюджет, атомарная резервация/запись. Отказ в квоте не должен повреждать уже сохранённые объекты. Порог3 не отменяет ограничений CPU/RAM и справедливости запросов.

Проверка восстановления должна идти после фиксации конкретного generation/objectId/revision; отменённая старая операция не может записать plaintext, изменить каталог или обновить UI новой операции. Переименование/замена файла во время async шифрования не должны менять подписываемый snapshot. Автоматическое восстановление из HTML/скриптов запрещено: только явное действие владельца.

## Значимые тесты до реализации

1. **Все84 комбинации:** для каждого выбора3 из9 восстановить тот же бинарный файл побайтно. Длины0,1,2,3,4, около границ блоков и4MiB; случайные данные, нулевые байты, Unicode-имя, несовпадающий MIME. Не ограничиваться первыми тремя systematic shards.
2. **Ровно6 стираний и7 стираний:** любой набор6 недоступен —3 оставшихся восстанавливают; при7 недоступных возвращается «недостаточно», без частичного файла/ложного success.
3. **Шесть атакующих:** подмена байта, укорочение/удлинение, нулевой shard, чужой objectId/revision/index, поздний старый shard. После проверки хешей остаются3 честных; результат точный. Отдельно смешанная порча+молчание с суммарно6 и затем7 непригодных.
4. **Подмена доверия:** заменить shard и заявленный хеш, затем всё тело манифеста и ключ подписи. Без pinned owner/manifestHash восстановление должно быть запрещено. Изменение каждого header-поля/descriptor/signature отклоняется.
5. **Индексы:** `[0,0,0]` — один фрагмент; `[0,0,1]` — два. Отрицательные,9,дробные/строковые индексы, повторные JSON-ключи, неизвестный профиль/k/n, лишние поля — отказ до декодирования.
6. **Конфиденциальность:** все9 shard не содержат исходного файла/имени/MIME в явном виде; неправильный DEK и tag отклоняются. Это проверка реализации, не математическое доказательство AES. Проверить известные AES-GCM векторы и совместимость Node/WebCrypto браузеров; byte-level RS векторы независимо вычисленной реализации.
7. **Nonce/ключи:** две записи одного файла дают разные ключи/nonce/ciphertext; повтор отправки не вызывает повторное шифрование с прежним DEK/nonce. Искусственно нарушенный генератор/повтор ключа в тестовом режиме вызывает остановку, если приложение обещает контролировать такое нарушение.
8. **Rollback:** revision2 закреплена локально, сервер отдаёт подписанную revision1 — отказ. На чистом устройстве со старой backup нельзя показывать доказанную свежесть; проверяется явная маркировка версии.
9. **Recovery:** восстановить на чистом устройстве по набору и отдельному secret; неверный/утерянный secret не раскрывает данные. Ключ отсутствует в сетевых shard/манифестах/URL/логах. Тесты пересоздания/очистки IndexedDB демонстрируют необходимость резервной копии.
10. **Crash/cancel/concurrency:** отключение питания до commit, между write и receipt, параллельное добавление, повторная доставка и отмена загрузки. Частичные записи не считаются долговечными; старые callbacks не пишут в новую операцию.
11. **Ресурсы:** malicious length 2^53, negative/NaN, oversized manifest/base64, stream без Content-Length, медленная бесконечная передача, flood одним узлом. Ограничения проверяются до дорогой криптографии/выделения памяти; остальные участники сохраняют свой бюджет.
12. **Честность отчёта:** девять locator на одном физическом устройстве не обозначаются как девять независимых узлов; подписанные ACK без readback не считаются проверенными копиями; успешное чтение вчера не выглядит «доступно сейчас».

Перед работой с чувствительными пользовательскими данными нужны независимая проверка реализации/формата, тестируемое обновление клиента и проверенный процесс восстановления. До этого результат следует представлять как пилот зашифрованного распределённого хранения с конкретно проверенными отказами, а не как универсальную защиту информации.

## Дополнительная проверка появившегося прототипа

После подготовки предложения прочитаны `public/erasure.mjs`, `public/storage-fabric.mjs` и `tests/core/storage-fabric.test.mjs`. Это более узкий, иной формат: immutable random object id, локальный owner-trusted manifest без подписи/ревизий, raw file под AES-GCM, RS(9,3), recovery под PBKDF2-SHA-256/600000 + AES-GCM. Не следует выдавать предложенный выше полный формат за уже реализованный.

Дополнительный исполняемый read-only эксперимент восстановил зашифрованный случайный файл длиной1003 байта через все84 тройки. Проверка доверенного owner-manifest стоит перед декодированием, а GCM перед выдачей plaintext. Ошибки восстановления или обхода конфиденциальности в этом ограниченном просмотре не найдены. Пропуск подписи допустим для данного ограниченного API **только** пока доверие приходит из локального owner-record или успешно расшифрованного recovery-набора, а не из manifest внутри недоверенного shard.

Два конкретных замечания:

1. `passwordKey` принимает `aaaaaaaaaaaa`: успешно созданы и разблокированы recovery-данные. Проверка длины12–256 символов не проверяет энтропию. Атакующий с recovery-файлом может офлайн проверять догадки; нет сетевого ограничения попыток. Для обещания защиты особо чувствительного файла это неприемлемый default. Рекомендация — генерировать случайный recovery secret256бит, вынести его отдельно от recovery-файла; ручной слабый пароль не называть достаточной защитой. PBKDF2 усложняет каждую попытку, но не превращает предсказуемую строку в случайный ключ.
2. `validateShard` сравнивает JSON.stringify двух манифестов. Полностью эквивалентный объект с изменённым порядком ключей отклонён как `Untrusted manifest`. При независимом сериализаторе/переформатировании это ломает восстановление исправных фрагментов. Сравнивать строго провалидированные поля в фиксированном порядке и массив hashes либо канонические байты, сохранив проверку каждого значения. Нельзя исправлять это удалением проверки доверенного манифеста.

Дополнительные пробелы тестов перед увеличением охвата: предельные4MiB, действительно oversized cipherLength/shard stream (имя текущего теста упоминает oversized, но соответствующего assert нет), recovery ciphertext/salt/nonce tampering, неправильный **существующий** AES-ключ вместо только null, сохранение неэкспортируемых ключей через реальную IndexedDB и восстановление на другом устройстве. Регулярный readback/placement, quotas и durable remote receipts ещё не доказываются этим криптографическим модулем.

## Исправления прототипа после read-only review

Проверено владельцем реализации: вместо пользовательского12-символьного пароля теперь принимается случайный256-битный код (64hex); UI генерирует его WebCrypto. Сравнение манифестов канонизировано по полям. Добавлены max4MiB/overflow/tampered-recovery tests. В реальном браузере выполнены перенос3фрагментов WebRTC, импорт recovery на другомorigin, остановка исходного сервера, перезагрузка и восстановление с совпадающимSHA256. Это всё ещё испытание на одномMac, не физическая отказоустойчивость9узлов и не независимый аудит.
