Деривация симметричного ключа из общего секрета

В контексте криптографии на стороне клиента в JavaScript библиотека SJCL предоставляет набор низкоуровневых примитивов, на базе которых строятся протоколы обмена ключами и симметричное шифрование. Одним из ключевых этапов является преобразование общего секрета, полученного, например, через ECDH, в устойчивый симметричный ключ, пригодный для AES.


Представление общего секрета в SJCL

Результатом выполнения протокола Диффи—Хеллмана на эллиптических кривых в SJCL является значение типа sjcl.bitArray. Это не строка и не бинарный массив в привычном смысле, а специализированная структура, оптимизированная для криптографических операций.

Пример получения общего секрета:

const alice = sjcl.ecc.elGamal.generateKeys(256);
const bob = sjcl.ecc.elGamal.generateKeys(256);

const secretAlice = alice.sec.dh(bob.pub);
const secretBob = bob.sec.dh(alice.pub);

secretAlice и secretBob совпадают и представляют общий секрет, но использовать его напрямую как ключ AES недопустимо.


Почему общий секрет нельзя использовать напрямую

Сырой результат ECDH имеет ряд проблем:

  • отсутствует равномерное распределение битов в прикладном смысле
  • возможна утечка структуры кривой
  • длина не соответствует требованиям AES (128/192/256 бит)
  • отсутствует соль и контекст привязки

Поэтому требуется этап деривации ключа (KDF — Key Derivation Function).


PBKDF2 в SJCL

В SJCL реализована функция PBKDF2 через sjcl.misc.pbkdf2, которая может использоваться для преобразования общего секрета в ключевой материал.

Базовая форма:

sjcl.misc.pbkdf2(password, salt, iterations, keyLength, prf)

Где:

  • password — исходный секрет (sjcl.bitArray)
  • salt — дополнительная энтропия
  • iterations — число итераций
  • keyLength — длина ключа в битах
  • prf — хэш-функция (обычно HMAC-SHA256)

Преобразование ECDH секрета в AES ключ через PBKDF2

Типичный подход:

const sharedSecret = alice.sec.dh(bob.pub);

const salt = sjcl.random.randomWords(4, 0);

const aesKey = sjcl.misc.pbkdf2(
    sharedSecret,
    salt,
    10000,
    256,
    sjcl.misc.hmac
);

Полученный aesKey уже может использоваться в AES-256.


Использование результата в AES

SJCL использует объект sjcl.cipher.aes, который принимает ключ в формате bitArray.

const aes = new sjcl.cipher.aes(aesKey);

const ciphertext = sjcl.mode.ccm.encrypt(
    aes,
    sjcl.codec.utf8String.toBits("secret message"),
    iv,
    [],
    128
);

Роль соли в деривации

Соль выполняет несколько критических функций:

  • предотвращает атаки по предвычислению (rainbow tables)
  • делает каждый сеансовый ключ уникальным
  • связывает ключ с конкретным контекстом соединения

В протоколах ECDH соль часто формируется как:

  • случайные байты
  • идентификаторы сторон
  • nonce протокола
  • публичные ключи участников

HKDF-подобный подход в SJCL

Хотя в SJCL отсутствует полноценная реализация HKDF, аналогичная схема может быть построена вручную с использованием SHA-256.

Общая структура HKDF:

  1. Extract
  2. Expand

Extract

const prk = sjcl.misc.hmac(
    sjcl.hash.sha256,
    salt
).encrypt(sharedSecret);

Expand

const info = sjcl.codec.utf8String.toBits("session-key");

const okm = sjcl.misc.hmac(
    sjcl.hash.sha256,
    prk
).encrypt(info);

Итоговый okm используется как ключ AES.


Использование SHA-256 напрямую

Более простой вариант без PBKDF2:

const hash = sjcl.hash.sha256.hash(sharedSecret.concat(salt));

const aesKey = hash.slice(0, 8); // 256 бит = 8 слов SJCL

Этот подход менее устойчив, но применяется в легковесных протоколах.


Контроль длины ключа

AES требует строго фиксированных размеров:

  • 128 бит → 4 слова
  • 192 бит → 6 слов
  • 256 бит → 8 слов

SJCL оперирует 32-битными словами, поэтому преобразование выглядит как:

const key256 = sjcl.bitArray.clamp(aesKey, 256);

Частые ошибки при деривации ключей

Использование ECDH секрета напрямую

Приводит к слабой диффузии и потенциальной утечке структуры данных.

Отсутствие соли

Делает систему уязвимой к повторным атакам при одинаковых сессиях.

Недостаточное число итераций PBKDF2

Снижает стоимость перебора ключа при атаке.

Игнорирование контекста

Ключ должен быть привязан к конкретной сессии, иначе возможны replay-атаки.


Практический сценарий: полный цикл ECDH → AES

// генерация ключевых пар
const A = sjcl.ecc.elGamal.generateKeys(256);
const B = sjcl.ecc.elGamal.generateKeys(256);

// общий секрет
const shared = A.sec.dh(B.pub);

// соль сессии
const salt = sjcl.random.randomWords(4, 0);

// деривация ключа
const key = sjcl.misc.pbkdf2(
    shared,
    salt,
    15000,
    256,
    sjcl.misc.hmac
);

// AES контекст
const aes = new sjcl.cipher.aes(key);

// шифрование
const iv = sjcl.random.randomWords(4, 0);

const encrypted = sjcl.mode.ccm.encrypt(
    aes,
    sjcl.codec.utf8String.toBits("payload"),
    iv,
    [],
    128
);

Связь деривации и устойчивости протокола

Корректная деривация симметричного ключа определяет стойкость всей системы поверх ECDH. Даже при идеальной реализации обмена ключами, отсутствие корректного KDF превращает общий секрет в слабое место.

В SJCL это особенно критично из-за ориентации библиотеки на клиентский JavaScript, где окружение потенциально недоверенное и подвержено анализу.


Промежуточные представления и совместимость

SJCL активно использует bitArray как универсальный формат, что позволяет:

  • передавать данные между hash / cipher / KDF без преобразований
  • избегать ошибок кодирования UTF-8 / Base64
  • минимизировать накладные расходы

Однако при интеграции с внешними системами часто требуется:

const hex = sjcl.codec.hex.fromBits(key);
const base64 = sjcl.codec.base64.fromBits(key);

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

Общая схема деривации симметричного ключа в SJCL сводится к цепочке:

  1. ECDH → общий секрет (bitArray)
  2. Добавление соли и контекста
  3. KDF (PBKDF2 / HMAC-SHA256 / кастомный HKDF)
  4. Усечение до нужной длины
  5. Использование в AES

Эта цепочка обеспечивает криптографическую изоляцию между протоколом обмена ключами и симметричным шифрованием, исключая прямую зависимость AES-ключа от структуры эллиптической кривой.