Я получил хорошие отзывы от некоторых пользователей Reddit, которые указали на несколько ошибок реализации. Вы можете прочитать ветку здесь. Если вы начинаете с малого или совсем не знакомы с шифрованием, вы все равно можете многому научиться в этой статье, но не используйте код, который вы найдете здесь, в производственной системе. Без лишних слов…

Мы собираемся создать программу CLI, которая позволит нам сжимать и зашифровать файл с помощью пароля, а затем расшифровывать и распаковывать этот файл, используя тот же пароль. Мы будем делать это полностью в Node, без внешних зависимостей.

В целом план заключается в следующем:

  1. Прочтите открытый текст.
  2. Сожмите это.
  3. Зашифруйте это.
  4. Добавьте данные, используемые в процессе шифрования (которые понадобятся для расшифровки позже).
  5. Запишите зашифрованный текст в файл.

Затем нам нужно будет повторить эти шаги в обратном порядке:

  1. Прочтите какой-нибудь зашифрованный текст.
  2. Вытащите данные шифрования.
  3. Расшифруй это.
  4. Распакуйте его.
  5. Запишите открытый текст в файл.

Что мы будем изучать

  • Как работать с Node streams.
  • Как писать собственные потоки.
  • Как использовать некоторые криптографические функции.
  • Немного о шифровании AES.

Звучит неплохо? Давайте начнем.

Если вы просто хотите увидеть исходный код, он находится на github здесь.

Часть 0: Подготовка нашего проекта

Сначала давайте создадим каталог и создадим в нем два файла: index.js и file.txt. Наш каталог должен выглядеть так:

├── index.js
└── file.txt

В file.txt поместим немного текста (я использовал абзац из baconipsum):

Spicy jalapeno bacon ipsum dolor amet fugiat fatback ut flank dolor in ea, aute buffalo duis. T-bone occaecat sunt nisi commodo pig. Beef ullamco prosciutto irure cow dolore. Reprehenderit chicken ut, pork chop venison consectetur quis in. Ut pig duis aliqua.

Часть 1. Узловые потоки - краткое руководство

Чтение файлов

В Node код вашего приложения выполняется в одном потоке. Stdlib обеспечивает доступ к API, которые запускают процессы ввода-вывода в отдельных потоках, управляемых системой, и при необходимости вызывают обратные вызовы на уровне приложения.

Например, если вы хотите прочитать файл, вы можете сделать это синхронно следующим образом:

const fs = require(‘fs’);
const fileContents = fs.readFileSync(‘./file.txt’);
console.log(fileContents);

Это полностью работает, но блокирует и загружает все в память, что не идеально. Представьте, если бы file.txt было несколько гигабайт! С другой стороны, потоки - это мощный инструмент, который позволяет нам писать программы, работающие с небольшими объемами данных в асинхронном режиме. Это позволяет нашим программам использовать гораздо больше памяти и использовать их для обработки других запросов.

Давайте перепишем это, используя поток чтения:

const fs = require('fs');
const readStream = fs.createReadStream('./file.txt');
readStream.on('data', (chunk) =>{
  console.log(chunk.toString('utf8'));
});

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

const fs = require('fs');
const readStream = fs.createReadStream('./file.txt');
readStream.pipe(process.stdout);

Это выполняет то же самое, что и приведенный выше код, но с меньшим количеством строк. В Node (если вы его не измените) console.log записывает в process.stdout, и поскольку process.stdout является потоком, мы можем указать ему распечатать каждый фрагмент данных по мере его получения из потока чтения.

Запись файлов

Давайте расширим этот код, чтобы создать новый файл. Для этого нам понадобится новый метод: createWriteStream.

const fs = require('fs');
const readStream = fs.createReadStream('./file.txt');
const writeStream = fs.createWriteStream('./newfile.txt');
readStream.on('data', (chunk) => {
  writeStream.write(chunk);
});

Здесь мы вызываем метод write в потоке записи с фрагментом данных, который мы читаем из потока чтения. Но опять же, это немного неуклюже. Вместо того, чтобы вызывать метод write, давайте сделаем то, что мы делали раньше, и направим поток чтения прямо в поток записи!

const fs = require(‘fs’);
const readStream = fs.createReadStream(‘./file.txt’);
const writeStream = fs.createWriteStream(‘./newfile.txt’);
readStream.pipe(writeStream);

Намного лучше.

Конвейер, помимо того, что он более краток, обрабатывает как запись в поток, так и закрытие, или ending, поток, который запускает событие «по завершении», которое можно прослушать и отреагировать на него.

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

Сжатие

Вместо того, чтобы просто записывать то же содержимое в новый файл, давайте сжимаем этот файл по мере его записи. Для этого нам понадобится еще один модуль Node: zlib. zlib имеет несколько схем сжатия и распаковки, но мы собираемся использовать gzip, стандартный алгоритм сжатия, который очень хорошо сжимает контент.

Чтобы создать поток gzip в Node, нам нужно потребовать модуль zlib, а затем создать поток gzip:

const fs = require(‘fs’);
const zlib = require(‘zlib’);
const readStream = fs.createReadStream(‘./file.txt’);
const gzipStream = zlib.createGzip();
const writeStream = fs.createWriteStream(‘./newfile.txt’);
readStream
  .pipe(gzipStream)
  .pipe(writeStream);

Вот и все! Мы написали программу, которая в основном говорит: «Прочтите фрагмент данных, передайте этот фрагмент в поток gzip для сжатия, затем запишите этот сжатый фрагмент в новый файл. Делайте это до тех пор, пока из исходного файла не закончатся куски для чтения ».

Давайте посмотрим на newfile.txt:

1f8b 0800 0000 0000 0013 b590 d151 0331
0c44 ffa9 620b 0857 0425 1028 40a7 d325
e26c cbb1 2518 ba47 09b4 c097 343b 3bda
b73a 77e5 6f7c 50a1 2ecd b012 5b83 f619
159b 151b a02a 8e3d 2e4a 39c8 d370 2072
2dd4 8e3f 8b36 089d 40e1 8235 f69d 8a61
0b9d 0bde 9e57 6b02 6326 e13c 30a3 399a
4e05 5bad b619 ba5e 16bc 88ec 8852 a872
2ac3 266b b81b 74c4 90b4 7efd 06c9 8257
e943 aed2 3619 eae0 abf2 212d 794e e836
8e14 ace3 5332 215b 6493 29ec e231 704b
9ce4 5cf0 eef7 c807 1ea8 e82d 68c1 f93f
7ff0 f403 304e c86c 6201 0000

Классные двоичные данные. Не очень читаемый, но он намного меньше (~ 42% меньше)!

> ls -lh
354B file.txt
204B newfile.txt

Следующий шаг - зашифровать этого плохого парня.

Часть 2: Шифрование

Мы собираемся использовать алгоритм шифрования AES, а именно AES-256. AES - это алгоритм с симметричным ключом, то есть один и тот же ключ используется как для шифрования, так и для дешифрования данных. Это один из самых популярных и широко используемых алгоритмов шифрования. Существует три варианта (размера блока) AES: 128, 192 и 256. Мы будем использовать 256-й вариант AES, так как он наиболее безопасен. Если вы хотите узнать больше, это отличный комикс об AES: Руководство по расширенному стандарту шифрования.

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

Есть две функции для создания шифров: createCipher и createCipheriv. Прежде чем мы поймем, какой из них следует выбрать, давайте быстро узнаем о векторах инициализации. Вектор инициализации - это криптографически безопасное псевдослучайное число, которое гарантирует, что при одном и том же открытом тексте и пароле (или ключе) не будет создан один и тот же зашифрованный текст [1].

createCipher менее безопасен, чем createCipheriv, поскольку он автоматически генерирует вектор инициализации на основе пароля, который использовался для создания шифра, и из-за внутренней механики генерации этого вектора инициализации он уязвим для определенных типов атак. (Подробнее читайте в createCipher документах). Учитывая это, мы собираемся использовать createCipheriv.

Изучая документацию для createCipheriv, нам нужно указать три вещи:

  1. Алгоритм. (Мы уже выбрали это: AES-256).
  2. Ключ шифрования.
  3. Вектор инициализации.

Как выбрать ключ и вектор инициализации?

Генерация ключа шифрования

Ключ должен быть размером блока (в битах). Итак, учитывая, что мы используем AES-256, нам нужен 256-битный или 32-байтовый ключ. Мы можем сделать это несколькими способами. Самым простым было бы попросить криптомодуль выдать вам 32 случайных байта:

const KEY = crypto.randomBytes(32);
KEY // <Buffer 60 6f 9b 16 52 72 6c 32 54 67 17 18 1b db e7 0b ee 64 80 ee d8 f4 98 f8 d2 58 b8 23 82 06 cd 15>
KEY.length // 32

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

const hash = crypto.createHash(‘sha256’);
hash.update(‘mySup3rC00lP4ssWord’);
const KEY = hash.digest();
KEY // <Buffer ee b6 af 01 b3 1f 1f 01 a6 2f 14 92 2c 5c 80 54 ad 6d 51 cb 99 8c 28 f0 56 a7 ec 08 61 a6 aa ef>
KEY.length // 32

Криптографически безопасная хеш-функция имеет три фактора, которые полезны для генерации ключа для нашего шифра:

  1. Это односторонний процесс, а это значит, что с учетом хэша очень сложно отменить его и выяснить, что произошло.
  2. Он производит фиксированную выходную длину. Для sha256 он всегда будет создавать 32-байтовый буфер, который как раз и является размером, который нам нужен для нашего шифра AES-256.
  3. Это детерминировано. То есть хеш-функция всегда будет создавать один и тот же хеш для одного и того же открытого текста.

Давайте обернем эту функциональность во вспомогательную функцию:

function getCipherKey(password) {
  return crypto.createHash('sha256').update(password).digest();
}

Это позволит нам легко получить ключ шифрования для любого пароля.

Вы можете спросить себя: «Если для пароля генерируется такой же хеш, неужели его шифрование не слабое?». Вот тут-то и пригодится вектор инициализации.

Создание вектора инициализации

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

const initVect = crypto.randomBytes(16);

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

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

А как насчет вектора инициализации? Это было сгенерировано случайным образом, поэтому никто этого не знает. Как оказалось, вектор инициализации не нужно держать в секрете; ключ защищает зашифрованные данные, тогда как использование случайного вектора инициализации гарантирует, что информация не просочится через сам зашифрованный текст. Таким образом, он не должен быть зашифрован открытым текстом и может быть просто отправлен «в открытом виде». Обычно это делается путем добавления вектора инициализации в начало зашифрованного текста.

Если бы мы имели дело со строками, мы могли бы просто сделать что-то вроде:

В основном это то, что мы хотим, но это не сработает, если мы хотим зашифровать что-то большое, например фильм или очень большой текстовый документ.

Однако у нас есть средство для работы с очень большими файлами: потоки! Но как нам что-то добавить в поток?

Отслеживание ввода шифра

Легко: мы создаем собственный поток appender. Node упрощает это, предоставляя доступ ко всем базовым потокам. Вкратце (из документации):

В Node.js есть четыре типа потоков:

Readable - потоки, из которых можно читать данные (например fs.createReadStream ()).

Возможность записи - потоки, в которые можно записывать данные (например, fs.createWriteStream ()).

Дуплекс - потоки, которые доступны для чтения и записи (например, net.Socket).

Преобразование - дуплексные потоки, которые могут изменять или преобразовывать данные по мере их записи и чтения (например, zlib.createDeflate ()).

Поскольку мы будем изменять данные, нам нужно будет использовать поток преобразования.

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

Сборка функции шифрования

Давайте объединим все это вместе и объединим в функцию:

Мы можем запустить эту функцию, передав ей путь к файлу, который вы хотите зашифровать, и пароль:

encrypt({ file: './file.txt', password: 'dogzrgr8' });

Часть 3: Расшифровка

Чтобы расшифровать файл, нам нужно сделать все, что мы сделали, чтобы зашифровать его, но в обратном порядке. Нам потребуется:

  1. Прочтите файл.
  2. Получите вектор инициализации.
  3. Расшифровать зашифрованный текст
  4. Распакуйте его.
  5. Запишите открытый текст в файл.

Чтение зашифрованного текста

Мы увидели, насколько просто читать данные из файла с помощью потока записи. Однако есть небольшой поворот в том, что нам здесь нужно сделать. Наш файл зашифрованного текста - это не просто зашифрованный открытый текст; к файлу также добавлен вектор инициализации. Нам нужно отделить IV от остального зашифрованного текста. Учитывая, что поток имеет дело с фрагментами файла, как мы можем узнать, что первый фрагмент данных содержит наш вектор инициализации и только вектор инициализации? Точно так же, как мы узнаем, что второй кусок - это начало нашего зашифрованного текста?

Согласно readStream документам, createReadStream принимает два аргумента: путь и options. С помощью аргумента options мы можем указать потоку, где нужно start и end. Таким образом, вместо использования одного потока мы можем использовать два потока: один для initVect, а другой для зашифрованного текста.

// First, create a stream which will read the init vect from the file.
const readIv = fs.createReadStream(filePath, { end: 15 });
// Then, wait to get the initVect.
let initVect;
readIv.on('data', (chunk) => {
  initVect = chunk;
});
// Once we’ve got the initialization vector, we can decrypt
// the file.
readIv.on('close', () => {
  // start decrypting the cipher text…
});

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

Подобно тому, что мы сделали для чтения только вектора инициализации, нам нужно создать поток, который начнет чтение после вектора инициализации:

// inside the 'close' callback for the read initVect stream.
const readStream = fs.createReadStream(filePath, { start: 16 });

Теперь, когда у нас есть доступ к вектору инициализации, паролю и зашифрованному тексту, мы готовы начать расшифровку нашего файла.

Расшифровка

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

const cipherKey = getCipherKey(password);
const decipher = crypto.createDecipheriv('aes256', cipherKey, initVect);

Давайте направим это в наш поток чтения:

readStream
  .pipe(decipher);

Декомпрессия

Следующим шагом будет распаковка файла. Точно так же, как мы создали поток gzip с помощью метода createGzip, мы будем использовать его обратный: createUnzip.

const unzip = zlib.createUnzip();

Давайте также добавим это в наш поток трубы:

readStream
  .pipe(decipher)
  .pipe(unzip);

Пишу снова

И последнее, но не менее важное: давайте создадим новый поток записи, чтобы мы могли записать наш расшифрованный, распакованный файл.

// Add an extension so it doesn’t overwrite any files and
// so we can identify it.
const writeStream = fs.createWriteStream(filePath + '.unenc');

Давайте добавим это к нашему потоку каналов.

readStream
  .pipe(decipher)
  .pipe(unzip)
  .pipe(writeStream);

Сборка функции дешифрования

Давайте соберем все вместе, обернем в функцию и поместим в файл:

touch decrypt.js

Внутри decrypt.js:

Часть 4: еще кое-что

Я сказал, что это будет программа CLI, поэтому давайте добавим еще один файл:

touch aes.js

Давайте сосредоточимся на обработке двух команд:

node aes.js encrypt ./file.txt myPassword
node aes.js decrypt ./file.txt.enc myPassword

Эти две команды имеют одинаковые аргументы в одной и той же позиции. Нам просто нужно знать, хотим ли мы зашифровать или расшифровать какой-либо файл. Внутри aes.js:

// import our two functions
const encrypt = require(‘./encrypt’);
const decrypt = require(‘./decrypt’);
// pull the mode, file and password from the command arguments.
const [ mode, file, password ] = process.argv.slice(2);
if (mode === ‘encrypt’) {
  encrypt({ file, password });
}
if (mode === ‘decrypt’) {
  decrypt({ file, password });
}

Вы должны иметь возможность запускать команды, которые мы изначально намеревались поддерживать.

Что дальше?

Можно многое добавить, улучшить и расширить.

Шифрование Моара

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

Обработка ошибок

Если вы введете неправильный пароль, приложение выйдет из строя с загадочным сообщением. Мы могли бы добавить некоторую обработку ошибок для каждого из потоков, чтобы точно знать, на каком этапе произошел сбой.

Алгоритмы параметризованного шифрования

Алгоритмы шифрования Node поддерживаются всем, что доступно в openssl, и их очень много (189, если быть точным, вы можете увидеть их все, введя openssl list-cipher-algorithms в свой терминал). Мы могли бы позволить пользователю выбирать, какой алгоритм он хотел бы использовать. Для этого также потребуется выбрать правильный вектор инициализации и размер ключа.

Веб-сервис?

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

Спасибо за прочтение!