Безопасное хранение ключевого материала в браузере

Основная проблема клиентской криптографии в веб-приложениях заключается не в генерации ключей, а в их безопасном жизненном цикле: создании, использовании, хранении и уничтожении. Браузерная среда по своей природе не предоставляет постоянного защищённого хранилища, аналогичного аппаратным модулям безопасности на сервере, поэтому любая стратегия хранения ключевого материала должна учитывать модель угроз: XSS-атаки, компрометацию расширений, утечки через локальные API и доступ к данным со стороны пользователя или вредоносного скрипта.

Web Crypto API (crypto.subtle) предоставляет механизмы, позволяющие минимизировать риски, но не устраняет их полностью. Ключевая концепция заключается в том, что секретные ключи должны по возможности оставаться неэкспортируемыми и использоваться только внутри криптографического контекста браузера.


Непосредственное создание ключей и их неэкспортируемость

Базовый способ создания ключевого материала — использование crypto.subtle.generateKey.

const key = await crypto.subtle.generateKey(
  {
    name: "AES-GCM",
    length: 256
  },
  true,
  ["encrypt", "decrypt"]
);

Второй параметр true/false определяет, можно ли экспортировать ключ. Именно здесь закладывается фундамент безопасности:

  • extractable: false — ключ нельзя извлечь в виде raw-байтов или JSON
  • extractable: true — ключ можно сохранить, но он становится уязвимым при утечке

Для безопасного хранения критичных секретов используется:

extractable: false

Такой ключ невозможно сериализовать через exportKey, а значит он не может быть украден через стандартные JS-инъекции, если не происходит активное использование API в момент атаки.


Ограничения хранения ключей в браузере

Даже если ключ создан как неэкспортируемый, возникает практическая задача его сохранения между сессиями. Web Crypto API не предоставляет встроенного persistent storage для ключей. Это означает:

  • ключи живут только в памяти процесса браузера
  • после перезагрузки страницы они теряются
  • IndexedDB не умеет хранить CryptoKey напрямую в удобном виде (хотя поддержка частичная и зависит от браузера)

Следовательно, безопасное хранение сводится к одной из стратегий:

  1. хранение неэкспортируемого ключа в памяти + восстановление через derivation
  2. хранение зашифрованного (wrapped) ключа в IndexedDB
  3. использование ключей, производных от пароля пользователя
  4. использование аппаратной защиты (если доступна на платформе)

Хранение ключей через IndexedDB

IndexedDB — единственный стандартный механизм браузера, пригодный для долговременного хранения бинарных данных. Однако хранение CryptoKey имеет ограничения:

const db = indexedDB.open("crypto-db", 1);

Некоторые браузеры позволяют сохранять CryptoKey напрямую, но это не универсально и не гарантируется спецификацией для всех типов ключей.

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


Обёртывание ключей (Key Wrapping)

Web Crypto API поддерживает операции wrapKey и unwrapKey, позволяющие шифровать ключи другим ключом.

Пример схемы

  • генерируется основной ключ данных (DEK — Data Encryption Key)
  • генерируется ключ-обёртка (KEK — Key Encryption Key)
  • DEK шифруется KEK и сохраняется в IndexedDB
  • при восстановлении DEK расшифровывается
const wrappedKey = await crypto.subtle.wrapKey(
  "jwk",
  dataKey,
  wrappingKey,
  { name: "AES-GCM", iv: iv }
);

И восстановление:

const unwrappedKey = await crypto.subtle.unwrapKey(
  "jwk",
  wrappedKey,
  wrappingKey,
  { name: "AES-GCM", iv: iv },
  {
    name: "AES-GCM"
  },
  false,
  ["encrypt", "decrypt"]
);

Ключевая идея

Даже если IndexedDB будет скомпрометирована, злоумышленник получит только зашифрованный ключ, но не сможет использовать его без KEK.


Производные ключи от пароля пользователя

Один из наиболее распространённых подходов — генерация ключа из пользовательского пароля через PBKDF2.

const baseKey = await crypto.subtle.importKey(
  "raw",
  new TextEncoder().encode(password),
  "PBKDF2",
  false,
  ["deriveKey"]
);

const key = await crypto.subtle.deriveKey(
  {
    name: "PBKDF2",
    salt,
    iterations: 310000,
    hash: "SHA-256"
  },
  baseKey,
  {
    name: "AES-GCM",
    length: 256
  },
  false,
  ["encrypt", "decrypt"]
);

Особенности:

  • ключ не хранится вообще
  • восстановление требует пароль
  • стойкость зависит от сложности пароля и числа итераций

Этот подход широко используется в end-to-end шифровании в веб-приложениях.


Разделение ключей: подход “session-only secrets”

В ряде систем используется модель, при которой ключ:

  • генерируется при входе пользователя
  • хранится только в памяти
  • уничтожается при закрытии вкладки
let sessionKey;

async function initSessionKey() {
  sessionKey = await crypto.subtle.generateKey(
    { name: "AES-GCM", length: 256 },
    false,
    ["encrypt", "decrypt"]
  );
}

function clearKey() {
  sessionKey = null;
}

Этот подход минимизирует поверхность атаки, но не решает проблему восстановления данных после перезагрузки.


Использование асимметричных ключей для защиты хранения

Гибридные схемы часто используют RSA-OAEP или ECDH для защиты симметричных ключей.

Пример:

  • публичный ключ доступен клиенту
  • приватный ключ хранится в защищённой форме (или на сервере)
  • симметрический ключ шифруется публичным ключом
const encryptedKey = await crypto.subtle.encrypt(
  { name: "RSA-OAEP" },
  publicKey,
  rawKeyBuffer
);

Такая схема используется в:

  • защищённой синхронизации данных
  • E2E мессенджерах
  • системах с восстановлением доступа через сервер

Угрозы хранения в браузере

Даже при использовании Web Crypto API необходимо учитывать реальные векторы атак:

1. XSS (Cross-Site Scripting)

Наиболее критическая угроза. Если злоумышленник выполняет JS-код в контексте страницы:

  • он может использовать crypto.subtle напрямую
  • может инициировать операции шифрования/расшифрования
  • может перехватывать данные до шифрования

Неэкспортируемость ключей не защищает от использования API.


2. Компрометация расширений

Браузерные расширения с широкими правами могут:

  • читать DOM
  • перехватывать сетевой трафик
  • вмешиваться в выполнение JS

3. Утечка через память

Хотя Web Crypto API хранит ключи в изолированных объектах:

  • длительное удержание ключей увеличивает риск утечки через ошибки реализации
  • утечки возможны через side-channel атаки в редких сценариях

Практическая модель безопасного хранения

На практике используется комбинация подходов:

Базовый безопасный паттерн

  • ключи генерируются как extractable: false
  • используются только в памяти
  • при необходимости хранения — применяются wrapKey
  • IndexedDB хранит только зашифрованные данные

Усиленная модель (zero-trust client storage)

  • KEK выводится из пароля пользователя (PBKDF2)

  • DEK используется для данных

  • DEK хранится только в wrapped-виде

  • IndexedDB хранит:

    • wrapped DEK
    • salt
    • параметры derivation

Модель с серверной поддержкой

  • клиент хранит только временные ключи
  • восстановление ключей происходит через серверную аутентификацию
  • сервер хранит encrypted backup ключей

Работа с жизненным циклом ключей

Критически важным аспектом является уничтожение ключей:

sessionKey = null;

Однако этого недостаточно, поскольку:

  • garbage collector не гарантирует мгновенное освобождение памяти
  • ссылки могут сохраняться в closures

Более строгие системы:

  • перезаписывают буферы (если доступно через ArrayBuffer)
  • ограничивают область видимости ключей
  • используют короткоживущие объекты

Особенности браузерных реализаций

Поведение Web Crypto API может отличаться:

  • Chrome: более стабильная поддержка IndexedDB CryptoKey
  • Firefox: строгие ограничения на экспортируемость
  • Safari: некоторые ограничения на wrapKey/unwrapKey

Поэтому переносимость требует:

  • проверки crypto.subtle возможностей
  • fallback-стратегий хранения (например, только wrapped JSON)

Рекомендации архитектурного уровня

Безопасное хранение ключевого материала в браузере строится вокруг нескольких принципов:

  • минимизация времени жизни ключа
  • отказ от экспортируемости без необходимости
  • использование wrap/unwrap вместо прямого хранения
  • разделение ключей по уровням (DEK/KEK)
  • исключение хранения plaintext ключей в persistent storage
  • защита от XSS как основной приоритет безопасности

Типовые анти-паттерны

  • хранение ключей в localStorage
  • сериализация CryptoKey через JSON
  • использование слабых паролей без PBKDF2/Argon2-подобных схем
  • длительное удержание ключей в глобальных объектах
  • доверие к клиентскому окружению как к защищённому

Итоговая модель безопасности

В браузерной криптографии безопасность хранения ключей не является свойством одного API. Она формируется архитектурой:

  • Web Crypto API обеспечивает криптографические операции
  • IndexedDB обеспечивает хранение зашифрованных артефактов
  • wrapKey/unwrapKey формируют слой защиты ключей
  • PBKDF2 связывает ключи с пользовательской аутентификацией
  • модель угроз определяет допустимый уровень компрометации

В результате безопасное хранение ключевого материала в браузере представляет собой не точку хранения, а управляемый жизненный цикл криптографических объектов внутри ограниченного и изолированного исполнения JavaScript.