Кеширование ключей и управление памятью

Ключевой объект Web Crypto API — CryptoKey — не является «сырым» байтовым ключом в привычном смысле. Это управляемая средой выполнения структура, привязанная к контексту безопасности браузера или Worker’а. Именно поэтому управление памятью и кэшированием ключей требует понимания его жизненного цикла.

CryptoKey может быть:

  • неизвлекаемым (non-extractable) — ключ нельзя экспортировать в открытом виде;
  • извлекаемым (extractable) — допускается экспорт через exportKey;
  • временным (session-bound) — существует только в памяти;
  • сериализуемым через structured clone (например, для IndexedDB или postMessage).

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


Принципы кэширования CryptoKey в оперативной памяти

Кэширование ключей в памяти используется для минимизации дорогих операций импорта (importKey) и генерации (generateKey), особенно в высоконагруженных приложениях: шифрование сообщений, токенизация, потоковая обработка данных.

Типовая схема кэша:

  • ключ идентифицируется по метаданным (алгоритм, usage, id);
  • хранится в Map или WeakMap;
  • используется повторно без повторного импорта.

Пример структуры кэша:

const keyCache = new Map();

function getCacheKey(name, algo, usages) {
  return `${name}:${algo}:${usages.join(',')}`;
}

Использование:

async function getKey() {
  const cacheKey = getCacheKey("enc-key", "AES-GCM", ["encrypt", "decrypt"]);

  if (keyCache.has(cacheKey)) {
    return keyCache.get(cacheKey);
  }

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

  keyCache.set(cacheKey, key);
  return key;
}

WeakMap как инструмент автоматического освобождения памяти

WeakMap позволяет привязать ключи к объектам без препятствования сборщику мусора. Это полезно при ассоциации CryptoKey с DOM-объектами, сессиями или временными контекстами.

const sessionKeyMap = new WeakMap();

function attachKey(session, key) {
  sessionKeyMap.set(session, key);
}

function getKey(session) {
  return sessionKeyMap.get(session);
}

Преимущество:

  • отсутствие утечек памяти;
  • автоматическое удаление при исчезновении объекта session.

Проблема утечек памяти при долгоживущих кэшах

Использование обычного Map без политики очистки приводит к накоплению CryptoKey, особенно при динамическом создании ключей (например, per-user, per-tab, per-request).

Типичные причины утечек:

  • отсутствие ограничений на размер кэша;
  • кэширование ключей с уникальными параметрами без дедупликации;
  • хранение ключей для закрытых сессий;
  • отсутствие TTL (time-to-live).

LRU-кэш для CryptoKey

Практический подход — ограниченный по размеру LRU-кэш (Least Recently Used):

class LRUCache {
  constructor(limit = 50) {
    this.limit = limit;
    this.map = new Map();
  }

  get(key) {
    if (!this.map.has(key)) return null;

    const value = this.map.get(key);
    this.map.delete(key);
    this.map.set(key, value);
    return value;
  }

  set(key, value) {
    if (this.map.has(key)) {
      this.map.delete(key);
    }

    this.map.set(key, value);

    if (this.map.size > this.limit) {
      const oldest = this.map.keys().next().value;
      this.map.delete(oldest);
    }
  }
}

LRU особенно важен при:

  • криптографических API в браузерных редакторах;
  • потоковом шифровании;
  • мультиарендных приложениях.

Сериализация и хранение ключей вне памяти

CryptoKey можно сохранять через:

  • IndexedDB;
  • structured clone (postMessage);
  • Service Worker cache (ограниченно и косвенно).

Экспортируемые ключи:

const raw = await crypto.subtle.exportKey("raw", key);

Сохранение в IndexedDB:

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

store.put(key, "aes-key-1");

Особенность: при восстановлении ключа необходимо использовать importKey, что делает кэширование особенно важным.


Стратегия «горячих» и «холодных» ключей

В реальных приложениях ключи делятся на уровни доступа:

Горячие (hot keys):

  • находятся в памяти;
  • используются часто;
  • кэшируются в Map/LRU;
  • минимальная задержка доступа.

Холодные (cold keys):

  • хранятся в IndexedDB;
  • поднимаются при необходимости;
  • требуют importKey.

Переход:

async function resolveKey(id) {
  const cached = keyCache.get(id);
  if (cached) return cached;

  const stored = await idbGet(id);
  const key = await crypto.subtle.importKey(
    "raw",
    stored,
    "AES-GCM",
    true,
    ["encrypt", "decrypt"]
  );

  keyCache.set(id, key);
  return key;
}

Управление временем жизни ключей

Web Crypto API не предоставляет встроенного TTL, поэтому управление сроком жизни реализуется вручную.

Подходы:

1. Метки времени

const cache = new Map();

function setKey(id, key) {
  cache.set(id, {
    key,
    ts: Date.now()
  });
}

2. Очистка по таймеру

setInterval(() => {
  const now = Date.now();

  for (const [id, entry] of cache) {
    if (now - entry.ts > 300000) {
      cache.delete(id);
    }
  }
}, 60000);

Особенности сборки мусора CryptoKey

CryptoKey:

  • может участвовать в garbage collection;
  • но его внутренние ресурсы освобождаются только при отсутствии ссылок;
  • не всегда ведёт себя как обычный JS-объект из-за привязки к криптографическому контексту.

Практический вывод:

  • удержание ссылки = удержание криптографического материала в памяти;
  • утечка кэша = потенциальная утечка чувствительных данных.

Безопасность при кэшировании ключей

Кэширование криптографических ключей всегда повышает риск:

  • компрометации памяти процесса;
  • утечки через XSS;
  • доступа через расширения браузера;
  • долгого присутствия ключей в heap snapshot.

Меры снижения риска:

  • минимизация времени жизни ключей;
  • использование non-extractable ключей там, где возможно;
  • изоляция Worker’ов;
  • очистка кэша при logout;
  • разделение кэша по контекстам (tab/session/user).

Изоляция кэша по контексту выполнения

Для сложных приложений (например, SPA с мультисессиями) кэш должен быть изолирован:

class KeyStore {
  constructor() {
    this.cache = new Map();
  }

  set(sessionId, key) {
    this.cache.set(sessionId, key);
  }

  get(sessionId) {
    return this.cache.get(sessionId);
  }

  clearSession(sessionId) {
    this.cache.delete(sessionId);
  }
}

В Worker-среде это особенно важно, так как память разделена логически, но не физически между задачами.


Оптимизация количества ключей

Переизбыток ключей часто возникает из-за:

  • генерации ключа на каждый запрос;
  • отсутствия нормализации идентификаторов;
  • хранения ключей на уровне компонентов UI.

Рациональная стратегия:

  • один ключ на домен безопасности (tenant);
  • один ключ на тип операции;
  • переиспользование ключей через derivation (HKDF, PBKDF2).

Итоговая архитектурная модель кэширования

Типичная зрелая схема выглядит так:

  • LRU-кэш в памяти для горячих ключей;
  • IndexedDB как долговременное хранилище;
  • WeakMap для привязки к временным объектам;
  • TTL-очистка;
  • строгая сегментация по контекстам.

Такая модель снижает:

  • задержки криптографических операций;
  • количество импортов ключей;
  • нагрузку на Web Crypto backend;
  • риск утечек памяти при масштабировании.