Note (EN): This is the normative crypto-architecture document, currently available in Russian only; an English translation is planned. Any divergence between this document and the code is a bug.
Статус: черновик, нормативное ТЗ фазы 1. После реализации и аудита станет публичным whitepaper. Любое отклонение реализации от этого документа — баг: либо чинится код, либо осознанно обновляется документ.
Модель угроз (инварианты I1–I6) ведётся в репозитории приложения Hypervault и будет опубликована вместе с ним.
- Zero-knowledge, offline-first — все операции локальны, секреты не покидают устройство (I1).
- Никакой самописной криптографии — только примитивы из проверенных библиотек (Tink / BouncyCastle / libsodium-биндинг; выбор фиксируется в фазе 1 отдельным ADR).
- Все случайные значения — только из CSPRNG (
SecureRandom). - Секреты в памяти — только зануляемые
ByteArray, никогдаString. - Версионирование формата: каждый файл несёт версию схемы, миграции только вперёд.
мастер-пароль (S1) Secret Key SK (S2, 128 бит, CSPRNG,
│ генерируется при создании vault)
│ Argon2id │
▼ │
MUK (256 бит) ─────────────┤
▼
KEK = HKDF-SHA-256(MUK ‖ SK,
salt = hkdfSalt, info = "hypervault/kek/v1")
│ AES-256-GCM (wrap)
▼
DEK (256 бит, CSPRNG, один на vault)
│ AES-256-GCM (body)
▼
payload хранилища (S4, S6)- MUK (Master Unlock Key) — Argon2id от мастер-пароля.
- SK (Secret Key) — вторая компонента энтропии: файл хранилища, украденный без устройства (A5), нерасшифровываем даже при слабом мастер-пароле (I2). Хранится: (а) в приватном хранилище приложения, зашифрованным ключом Android Keystore; (б) в Recovery Kit пользователя.
- KEK — ключ обёртки; меняется при смене мастер-пароля.
- DEK — ключ данных. Прослойка DEK/KEK (envelope) нужна для: (а) второй обёртки того же DEK ключом Keystore для биометрии (фаза 3); (б) быстрых перезаписей тела без пересчёта Argon2id. Смена мастер-пароля выполняет полную ротацию DEK (см. §5).
| Примитив | Выбор | Параметры |
|---|---|---|
| KDF | Argon2id (RFC 9106) | m ≥ 64 MiB, t ≥ 3, p = 4; калибруются на устройстве до ~1000 мс, но не ниже минимумов; сохраняются в заголовке |
| KDF (FIPS-профиль сборки, позже) | PBKDF2-HMAC-SHA-256 | ≥ 600 000 итераций (OWASP 2023+) |
| Расширение ключа | HKDF-SHA-256 (RFC 5869) | отдельные info-строки на каждое назначение ключа |
| AEAD | AES-256-GCM (NIST SP 800-38D) | nonce 96 бит CSPRNG, уникален на каждую запись; заголовок файла — AAD |
| Соль Argon2id | 256 бит CSPRNG | генерируется при создании vault, хранится в заголовке |
| Идентификаторы | UUIDv4 из CSPRNG |
Лимит NIST на случайные GCM-nonce (2^32 шифрований одним ключом) недостижим для файла хранилища; контролируется счётчиком записей в заголовке — при приближении к порогу форсируется ротация DEK.
[магия "HVLT"] [версия u16]
[заголовок, plaintext, участвует в AAD:
kdf_id, argon2_m, argon2_t, argon2_p, kdf_salt,
hkdf_salt, cipher_id, write_counter,
wrapped_dek = AES-GCM(KEK): {nonce, ciphertext, tag}]
[body: AES-GCM(DEK, AAD = заголовок): {nonce, ciphertext, tag}]- Payload перед шифрованием сериализуется kotlinx-serialization (CBOR).
- Метаданные записей (названия, URL) — внутри body, снаружи нет ничего, кроме крипто-параметров (актив S6).
- Подмена любого байта заголовка или body → ошибка аутентификации GCM (I4).
- Запись файла атомарна:
tmp-файл → fsync → atomic rename(I6).
| Событие | Действия |
|---|---|
| Создание vault | CSPRNG: SK, DEK, соли; ввод мастер-пароля → MUK → KEK → wrap DEK; показ Recovery Kit |
| Разблокировка паролем | Argon2id → MUK → KEK → unwrap DEK; MUK и KEK зануляются сразу после unwrap; в памяти остаётся только DEK |
| Разблокировка биометрией | Копия DEK хранится зашифрованной ключом Keystore (StrongBox при наличии, setUserAuthenticationRequired(true), setUserAuthenticationParameters(0, BIOMETRIC_STRONG), CryptoObject, setInvalidatedByBiometricEnrollment(true)). DEK вместо KEK: доступен в сессии без пересчёта KDF, модель доверия идентична (оба открывают body при компрометации TEE). Только API 30+; при смене пароля (ротация DEK) обёртка устаревает и отключается при первой неудачной попытке. Компромисс доверия к TEE документируется публично |
Блокировка (таймаут / фон / onTrimMemory / kill) |
Зануление DEK и всех производных; состояние UI сбрасывается (I5) |
| Смена мастер-пароля | Новые соли и DEK, полная перешифровка body (ротация DEK включена в смену пароля). Причины: body привязан AAD к полному заголовку — склейка старого заголовка с новым body (splicing, чтобы старый пароль открывал новые данные) невозможна; заодно обесценивается ранее утёкший KEK. Стоимость перешифровки десятков КБ незаметна на фоне ~1 c Argon2id |
| Ротация DEK | Совмещена со сменой пароля; отдельно — по счётчику записей |
| Смена биометрии в ОС | Keystore-ключ инвалидируется системой → биометрический вход отключается до входа паролем |
| Включение PIN | CSPRNG: pinSalt, pinGuard (32 байта). pinGuard хранится зашифрованным ключом Keystore (StrongBox). pinKey = Argon2id(PIN, pinSalt); wrapKey = HKDF(pinKey ‖ pinGuard, info="hypervault/pin/v1"); хранится wrappedDEK = AES-GCM(wrapKey, DEK) + счётчик неудач = 0 |
| Разблокировка по PIN | pinKey = Argon2id(PIN), wrapKey = HKDF(‖ pinGuard из Keystore), unwrap DEK. Неверный PIN → AEAD-ошибка, счётчик++. После N=5 неудач: удаление pinGuard из Keystore + wrapped-блоба → PIN необратимо отключён, остаётся мастер-пароль |
| Duress-PIN | Отдельный PIN, обёрнутый тем же способом, но с флагом duress. При совпадении введённого PIN с duress: (1) разблокировка обычным DEK; (2) удаление записей с destroyUnderDuress + перезапись файла; (3) промоут duress-слота в обычный — введённый PIN становится единственным PIN, прежний обычный слот затирается, признак duress снимается (перестановка боксов без KDF/DEK: правдоподобность §5a); (4) UI показывает рабочее хранилище без стёртых записей. Оба PIN-пути выполняют Argon2id — время ответа не различает нормальный/duress PIN |
PIN — удобный фактор, не корень доверия. Мастер-пароль + Secret Key остаются единственным способом, стойкость которого не зависит от железа. Свойства PIN-обёртки:
- Украденный файл без устройства:
pinGuardв Keystore недоступен → PIN-обёртку нельзя даже начать перебирать (сильнее, чем просто слабый PIN). - Украденное устройство: перебор PIN ограничен счётчиком (N=5), после
чего
pinGuardудаляется из Keystore необратимо. Argon2id удорожает каждую из немногих попыток. - Честный предел: root в момент разблокировки читает DEK из памяти (угроза A4) — как и для пароля/биометрии.
Duress-PIN — функция для сценария принуждения (A1/A7 расширенные). Стирает
только записи с флагом destroyUnderDuress, необратимо, без подтверждения
(подтверждение выдало бы факт стирания принуждающему). После срабатывания
введённый duress-PIN промоутится в обычный, а прежний обычный PIN исчезает:
у хранилища остаётся ровно один PIN, повторный вход тем же PIN не вызывает
второго стирания и не создаёт подозрений при проверке принуждающим. Бэкапы
уже отключены (фаза 0), поэтому восстановление стёртого невозможно.
Документируется риск случайного срабатывания.
- Экспоненциальная задержка после неудачных попыток (в дополнение к стоимости Argon2id).
- Счётчик попыток — в зашифрованном Keystore-стейте, а не в plain SharedPreferences.
- Опция «стереть vault после N неудач» (по умолчанию выключена).
Кошелёк не вводит новых секретов в иерархию ключей хранилища: его корневой секрет — энтропия BIP-39 (S9, 128 бит, CSPRNG), которая хранится как обычное поле зашифрованного payload (та же защита, что S4/S6) и зануляется при блокировке.
Деривация (детерминированная, ничего кроме энтропии не хранится):
энтропия (S9) ──BIP-39──▶ мнемоника (12 слов)
│ PBKDF2-HMAC-SHA512 (2048)
▼
seed (512 бит)
│ BIP-32 (secp256k1)
▼
m/44'/60'/0' (аккаунт, xpub → watch-кошелёк)
│
▼
m/44'/60'/0'/0/i (ключи подписи EVM)- Подпись: детерминированная ECDSA (RFC 6979), low-s, recovery id.
- Транспорт: EIP-4527 (BC-UR поверх анимированных QR):
crypto-hdkey(только публичные данные) → watch-кошелёк;eth-sign-request← watch-кошелёк;eth-signature→ watch-кошелёк. - Сетевого кода нет: приложение остаётся полностью оффлайн, broadcast выполняет watch-кошелёк.
- Приватный ключ выводится из энтропии на время одной подписи и зануляется (ограничение JVM — §8.5 действует и здесь).
- Путь деривации из запроса ограничен префиксом m/44'/60' — запрос не может запросить подпись ключом вне EVM-дерева.
- Duress: кошелёк с флагом destroyUnderDuress уничтожается вместе с помеченными записями (правдоподобность сохраняется).
Whitepaper (этот документ после фазы 1), исходный код крипто-ядра
(модуль crypto), тестовые векторы формата HV1, отчёты внешних аудитов.
- Библиотека примитивов: Tink vs libsodium (Lazysodium) vs BC — критерий: Argon2id + зануляемые байтовые API + перспектива FIPS-профиля.
- CBOR vs protobuf для payload.
- Выделять ли крипто-ядро в отдельный gradle-модуль
crypto(рекомендация: да, чистый Kotlin без Android API — проще открывать код и аудировать). - XChaCha20-Poly1305 как альтернативный cipher_id формата (nonce 192 бита).
- Защита ключей в памяти (фаза 2): секреты вне ART-кучи (moving GC копирует
ByteArray, оставляя незанулённые копии) —
sodium_malloc+mlock+ guard pages +sodium_memzero; KEK/DEK между операциями держать обёрнутыми эфемерным ключом из большого prekey-буфера (схема OpenSSH): защита от частичных утечек памяти (Spectre-класс, cold boot, Heartbleed-класс). Честный предел: от root с полным дампом памяти — только удорожание. - Вынос unwrap DEK внутрь Keystore/StrongBox (AES-GCM в TEE, ключ не покидает железо) — оценить производительность на реальном размере vault.
Шеринг записей и файлов «только получателю»: асимметричный запечатанный
конверт с подписью отправителя. Обмен ключами — out-of-band (QR/строка),
серверов нет (I1, I3). Полное обоснование — docs/adr/ADR-002-secure-sharing.md.
При первом шеринге лениво генерируются две долговременные пары (CSPRNG), хранятся в body HV1 (шифруются DEK, зануляются при блокировке):
idX— X25519 (шифрование к пользователю);idEd— Ed25519 (подпись пользователя).
Публичные половины — несекретный share-адрес. Контакты {label, xPub, edPub}
(публичные ключи) хранятся в payload; секретом не являются.
| Назначение | Выбор | Параметры |
|---|---|---|
| Согласование ключа | X25519 (RFC 7748) | эфемерная пара отправителя на каждый конверт |
| Подпись | Ed25519 (RFC 8032) | над (context ‖ itemKind ‖ item) |
| Вывод ключа конверта | HKDF-SHA-256 | info = "hypervault/share/v1", salt = ephPub ‖ recipient.xPub |
| AEAD | AES-256-GCM | nonce 96 бит CSPRNG; заголовок конверта — AAD |
inner (CBOR, шифруется):
senderEdPub (32) ‖ signature (64) ‖ itemKind (u8) ‖ item (bytes)
[магия "HVS1"] [версия u16]
[заголовок (plaintext, в AAD): ephemeralXPub (32), nonce (12)]
[body = AES-256-GCM(key, AAD = заголовок): {ciphertext, tag}]
key = HKDF(ss, salt = ephemeralXPub ‖ recipient.xPub, info="hypervault/share/v1")
ss = X25519(eph.priv, recipient.xPub) (у отправителя)
= X25519(idX.priv, ephemeralXPub) (у получателя)- Перехватчик видит только
ephemeralXPub, nonce и шифртекст — ни содержимого, ни личности отправителя (sign-then-encrypt). - Вскрыть может только владелец
idX.priv; подмена байта → ошибка GCM (I4). - Получатель проверяет Ed25519-подпись и сверяет
senderEdPubс контактами. - Транспорт
.hvs— шифртекст, идёт через недоверенный канал (share-sheet).
- MITM при out-of-band обмене ключами → сверка отпечатка вторым каналом (safety-number, как в Signal); сервера доверия нет намеренно.
- Replay конверта → дедуп по id при импорте.
- Размер
.hvsраскрывает примерный объём данных; факт передачи виден каналу — скрытие трафика вне модели.