0
<< предыдущая заметкаследующая заметка >>
12 января 2025
Для скучных программистов: немного о шифровании на фронтендах

Некоторые вещи мне удобно записывать для себя в дневник как в блокнот. Но вдруг кому-то тоже будет интересно? Современные криптографические методы (из легкодоступных в браузерных приложениях) — это эллиптические кривые семейства 25519 и хороший AES с длинными ключами. Обычно по надежности (а иногда и скорости) они уделывают все эти старинные RSA, который используется до сих пор, например, в PGP. А если завтра квантовый компьютер, а мы невыспавшиеся и не одеты?

AES позволяет надежно шифровать данные и расшифровывать обратно симметрично (одним тем же паролем, плюс еще придется к шифрованным данным запомнить пару коротких констант). AES давно уже встроен аппаратно в любой браузер, и доступен для JS-разработчика как метод crypto. Ниже я накидал две простые функции, как его использовать:

показать
// Шифруем данные data (текст или бинарные) паролем password с помощью максимально сильного AES
// Результат — объект с шифровкой (encrypted), и две инициализационные константы salt, iv, из тоже надо сохранить дял расшифровки
async function AESenc(data, password) {
  if(typeof data === 'string') data = new TextEncoder().encode(data);
  if(typeof password === 'string') password = new TextEncoder().encode(password);
  // Генерация ключа из пароля
  const keyMaterial = await crypto.subtle.importKey( 'raw', password, 'PBKDF2', false, ['deriveKey'] );
  // Генерация ключа шифрования AES-GCM
  const salt = crypto.getRandomValues(new Uint8Array(32));
  const key = await crypto.subtle.deriveKey(
      { name: 'PBKDF2', salt, iterations: 100000, hash: 'SHA-512' },
      keyMaterial, { name: 'AES-GCM', length: 256 }, false, ['encrypt']
  );
  // Генерация случайного IV
  const iv = crypto.getRandomValues(new Uint8Array(12));
  // Шифрование
  const encrypted = await crypto.subtle.encrypt( { name: 'AES-GCM', iv }, key, data );
  // Возвращаем зашифрованные данные, соль и IV
  return { encrypted: new Uint8Array(encrypted), salt, iv };
}

// Расшифровка обратно
async function AESdec(data, password, salt, iv) {
  if(typeof data === 'string') data = new TextEncoder().encode(data);
  if(typeof password === 'string') password = new TextEncoder().encode(password);
  // Генерация ключа из пароля
  const keyMaterial = await crypto.subtle.importKey( 'raw', password,'PBKDF2', false, ['deriveKey'] );
  // Восстановление ключа шифрования AES-GCM
  const key = await crypto.subtle.deriveKey(
        { name: 'PBKDF2', salt, iterations: 100000, hash: 'SHA-512' },
        keyMaterial, { name: 'AES-GCM', length: 256 }, false, ['decrypt']
  );
  // Расшифровка
  const decrypted = await crypto.subtle.decrypt( { name: 'AES-GCM', iv }, key, data );
  return new Uint8Array(decrypted); // new TextDecoder().decode(decrypted);
}

Но с AES всё понятно давно. А что касается математики эллиптических кривых 25519, она пришла на смену старым методам сравнительно недавно и очень хорошо себя показала. Во-первых, она крайне надежна среди всего зоопарка. Во-вторых, очень быстра. В-третьих, компактна и красива. Например, старые ключи RSA занимали текстовые файл на несколько строк, а публичный ключ 25519 всего одну строчку, и этого скромного размера более чем достаточно. Вот например мой:

ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIF8ZJDFjcdno13Rez/ebX5/i77YJRg3ODXuQbZgbt2nD lleo@lleo.me

Для JS-разработки существует библиотека noble: https://github.com/paulmillr/noble-ed25519 Её можно использовать и в nodejs-проектах и даже на веб-фронтенде — в полной версии (непакованная) она занимает всего 100кб. Для сравнения: библиотечища PGP openpgp.min.js весит более 600кб, и это дико пакованная.

Что умеет библиотека 25519? Вот например:

1. Сгенерировать новый приватный ключ. А к нему — публичный. Приватный вы храните в тайне, публичный показываете всем.

2. Цифровая подпись. Своим приватным ключом вы можете подписать любые данные. Получатель, зная лишь ваш публичный ключ, всегда сможет проверить, что данные подписали именно вы.

3. Асимметричное шифрование данных для конкретного получателя, зная его публичный ключ (чтобы расшифровать эти данные смог лишь человек, обладающий приватной частью этого ключа). Тут интересный принцип. Старый алгоритм RSA позволял просто взять и зашифровать данные чужим публичным ключом. Алгоритмы 25519 работают чуть сложнее — они используют принцип «общего секрета». Это некий «общий» пароль для А и Б, который нигде не хранится, потому что в любой момент его можно вычислить, причем двумя способами: либо зная публичный ключ А и приватный Б, либо, наоборот, приватный А и публичный Б. Хитрая математика все равно вычислит «общий пароль» одинаково. Поэтому как работает шифрование данных для абонента для его публичного ключа? Первым делом генерируется временный ключ. Он позже будет забыт. С его помощью (используя приватную часть ключа и публичный ключ получателя) вычисляется общий пароль. Остаётся этим паролем тупо и симметрично зашифровать данные (при помощи того же мощного AES), а в комплект к шифровке добавить публичную часть временного ключа. Данные отправляются, все временные ключи и пароль отправитель уничтожает. Теперь никто, кроме получателя, не сможет это расшифровать обратно. А он сможет — добавит к публичному ключу свой приватный и легко вычислит, каким был общий пароль, которым шифрованы данные. И столь же симметрично их расшифрует.

4. Возникает интересная проблема: ну хорошо, а как зашифровать таким способом данные для нескольких получателей, не дублируя сами данные? PGP на старом RSA в этом смысле работал проще: если можно шифровать что-то, зная лишь публичник адресата, то данные шифруются единым паролем, а сам пароль шифруется много раз для каждого из адресатов и прикладываются к данным по числу получателей. Данные не дублируются, а шифрованных паролей можно напихать для каждого, они же относительно короткие (спойлер: в RSA не очень). С x25519 это явно не сработает, поэтому схема усложняется. Мы генерируем единый пароль для шифровки данных (пароль любого типа, не обязательно ключ 25519). А далее — как в предыдущем пункте, с той лишь разницей, что шифруем не данные, а сам пароль. То есть — генерируем временные ключи: столько временных ключей 25519, сколько у нас получателей, и для каждого шифруем пароль. К общей посылке у нас добавляется по числу получателей: 32 байта публичного временного ключа + 48 байт шифрованный пароль + 32 байта сам публичник получателя, чтоб он разобрался, какой из паролей шифрован для него, и ему не пришлось раскрывать данные подряд всеми чужими паролями, пока не увидит, что информация теперь расшифровалась чуть более осмысленно. Итого — чуть больше 100 байт на получателя. Кажется, снова лучше, чем у PGP.

5. Да, еще есть неочевидный нюанс, что генерится пара ключей обычно в формате Edwards (ed25519), а использовать для поиска общего секрета можно только ключи формата Montgomery (x25519), но это уже сущие мелочи, это делается легкой конвертацией ключа в одну строчку.

показать код
// не забудем подключить процедуру
if(typeof nobleCurves == 'undefined') return alert("Load library before: noble-curves.js");

prn( 'Наши данные' );
    const txt = "Hello, Ed25519 forever";
        prn( `txt`, txt );
    const message = new TextEncoder().encode(txt);
        prn( `txt HEX`, message );

prn( 'Генерим пару ключей А (Edwards)' );
        const privateA = nobleCurves.ed25519.utils.randomPrivateKey();
        prn( 'privateA (edwards)', privateA );
    const publicA = nobleCurves.ed25519.getPublicKey(privateA);
        prn( 'publicA (edwards)', publicA );

prn( 'Генерим пару ключей B (Edwards)' );
    const privateB = nobleCurves.ed25519.utils.randomPrivateKey();
        prn( 'privateB', privateB );
    const publicB = nobleCurves.ed25519.getPublicKey(privateB);
        prn( 'publicB', publicB );

prn( 'ПОДПИСЬ. Подписываем данные ключом privateA' );
    const signatureA1 = nobleCurves.ed25519.sign(message, privateA);
        prn( 'signatureA1', signatureA1 );
        prn( 'Проверка подписи ключом publicA', nobleCurves.ed25519.verify(signatureA1, message, publicA) );

prn( 'ШИФРОВАНИЕ. Сперва конвентируем формат Edwards в Montgomery' );
    const privateAm = nobleCurves.ed25519_edwardsToMontgomeryPriv(privateA);
        prn( 'privateAm (montgomery)', privateAm );
    const publicAm = nobleCurves.ed25519_edwardsToMontgomeryPub(publicA);
        prn( 'publicAm (montgomery)', publicAm );
    const privateBm = nobleCurves.ed25519_edwardsToMontgomeryPriv(privateB);
        prn( 'privateBm (montgomery)', privateBm );
    const publicBm = nobleCurves.ed25519_edwardsToMontgomeryPub(publicB);
        prn( 'publicBm (montgomery)', publicBm );

prn( 'Генерация общих секретов x25519 для Montgomery' );
        const sharedAm = nobleCurves.x25519.getSharedSecret(privateAm, publicBm);
        prn('A для B (privateAm, publicBm)', sharedAm);
        const sharedBm = nobleCurves.x25519.getSharedSecret(privateBm, publicAm);
        prn('B для A (privateBm, publicAm)', sharedBm);
        prn('Совпадают ли', sharedAm.toString() === sharedBm.toString() );

prn( 'Шифрование встроенным AES браузера' );
    // const data = new Uint8Array([0x11, 0x00, 0xFF, 0x00, 0xFF, 0x00, 0xFF, 0x00, 0xFF, 0x22 ]); // Бинарные
    const data = txt;
        prn('data',data);
    const { encrypted, salt, iv } = await AESenc(data, sharedAm);
        prn('iv',iv);
        prn('salt',salt);
        prn('encrypted',encrypted);
    const decrypted = await AESdec(encrypted, sharedAm, salt, iv);
        prn('decrypted',decrypted);
        prn('decrypted',new TextDecoder().decode(decrypted) );

Файлик: cryptotest.js
Временная копия библиотеки: noble-curves.js
Теперь вы можете нарисовать прямо в браузере свой собственный pgp-протокол, если конечно начальство позволит.

<< предыдущая заметка следующая заметка >>
пожаловаться на эту публикацию администрации портала
архив понравившихся мне ссылок

Комментарии к этой заметке скрываются - они будут видны только вам и мне.

Оставить комментарий