Доброго времени суток, друзья! В этом туториале мы рассмотрим Web Cryptography API: интерфейс шифрования данных на стороне клиента. Данный туториал основан на этой статье. Предполагается, что вы немного знакомы с шифрованием.
Что конкретно мы будем делать? Мы напишем простой сервер, который будет принимать зашифрованные данные от клиента и возвращать их ему по запросу. Сами данные будут обрабатываться на стороне клиента.
Сервер будет реализован на Node.js с помощью Express, клиент — на JavaScript. Для стилизации будет использоваться Bootstrap.
Код проекта находится здесь.
Поиграть с кодом можно здесь.
Если вам это интересно, прошу следовать за мной.
Подготовка
Создаем директорию
crypto-tut
:mkdir crypto-tut
Заходим в нее и инициализируем проект:
cd crypto-tut
npm init -y
Устанавливаем
express
:npm i express
Устанавливаем
nodemon
:npm i -D nodemon
Редактируем
package.json
:"main": "server.js",
"scripts": {
"start": "nodemon"
},
Структура проекта:
crypto-tut
--node_modules
--src
--client.js
--index.html
--style.css
--package-lock.json
--package.json
--server.js
Содержание
index.html
:<head>
<!-- Bootstrap CSS -->
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css" integrity="sha384-JcKb8q3iqJ61gNV9KGb8thSsNjpSL0n8PARn9HuZOnIxN0hoP+VmmDGMN5t9UJ0Z" crossorigin="anonymous">
<link rel="stylesheet" href="style.css">
<script src="client.js" defer></source>
</head>
<body>
<div class="container">
<h3>Web Cryptography API Tutorial</h3>
<input type="text" value="Hello, World!" class="form-control">
<div class="btn-box">
<button class="btn btn-primary btn-send">Send message</button>
<button class="btn btn-success btn-get" disabled>Get message</button>
</div>
<output></output>
</div>
</body>
Содержание
style.css
:h3,
.btn-box {
margin: .5em;
text-align: center;
}
input,
output {
display: block;
margin: 1em auto;
text-align: center;
}
output span {
color: green;
}
Сервер
Приступаем к созданию сервера.
Открываем
server.js
.Подключаем express и создаем экземпляры приложения и маршрутизатора:
const express = require('express')
const app = express()
const router = express.Router()
Подключаем middleware (промежуточный слой между запросом и ответом):
// разбор запроса
app.use(express.json({
type: ['application/json', 'text/plain']
}))
// подключение роутера
app.use(router)
// директория со статическими файлами
app.use(express.static('src'))
Создаем переменную для хранения данных:
let data
Обрабатываем получение данных от клиента:
router.post('/secure-api', (req, res) => {
// получаем данные из тела запроса
data = req.body
// выводим данные в терминал
console.log(data)
// закрываем соединение
res.end()
})
Обрабатываем отправку данных клиенту:
router.get('/secure-api', (req, res) => {
// данные отправляются в формате JSON,
// после чего соединение автоматически закрывается
res.json(data)
})
Запускаем сервер:
app.listen(3000, () => console.log('Server ready'))
Выполняем команду
npm start
. В терминале появляется сообщение «Server ready». Открываем http://localhost:3000
:На этом с сервером мы закончили, переходим к клиентской части приложения.
Клиент
Здесь начинается самое интересное.
Открываем файл
client.js
.Для шифрования данных будет использоваться симметричный алгоритм AES-GCM. Такие алгоритмы позволяют использовать один и тот же ключ для шифрования и расшифровки.
Создаем функцию генерации симметричного ключа:
// https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/generateKey
const generateKey = async () =>
window.crypto.subtle.generateKey({
name: 'AES-GCM',
length: 256,
}, true, ['encrypt', 'decrypt'])
Перед шифрованием данные необходимо закодировать в поток байтов. Это легко сделать с помощью класса TextEncoder:
// https://developer.mozilla.org/en-US/docs/Web/API/TextEncoder
const encode = data => {
const encoder = new TextEncoder()
return encoder.encode(data)
}
Далее, нам нужен вектор исполнения (вектор инициализации, initialization vector, IV), представляющий собой случайную или псевдослучайную последовательность символов, которую добавляют к ключу шифрования для повышения его безопасности:
// https://developer.mozilla.org/en-US/docs/Web/API/Crypto/getRandomValues
const generateIv = () =>
// https://developer.mozilla.org/en-US/docs/Web/API/AesGcmParams
window.crypto.getRandomValues(new Uint8Array(12))
После создания вспомогательных функций, мы можем реализовать функцию шифрования. Данная функция должна возвращать шифр и IV для того, чтобы шифр можно было впоследствии декодировать:
const encrypt = async (data, key) => {
const encoded = encode(data)
const iv = generateIv()
const cipher = await window.crypto.subtle.encrypt({
name: 'AES-GCM',
iv
}, key, encoded)
return {
cipher,
iv
}
}
После шифрования данных с помощью SubtleCrypto, они представляют собой буферы необработанных двоичных данных. Это не лучший формат для передачи и хранения. Давайте это исправим.
Данные, обычно, передаются в формате JSON и хранятся в базе данных. Поэтому имеет смысл упаковать данные в портируемый формат. Одним из способов это сделать является конвертация данных в строки в формате base64:
// https://developers.google.com/web/updates/2012/06/How-to-convert-ArrayBuffer-to-and-from-String
const pack = buffer => window.btoa(
String.fromCharCode.apply(null, new Uint8Array(buffer))
)
После получения данных необходимо выполнить обратный процесс, т.е. преобразовать строки в кодировке base64 в буферы необработанных двоичных данных:
// https://developers.google.com/web/updates/2012/06/How-to-convert-ArrayBuffer-to-and-from-String
const unpack = packed => {
const string = window.atob(packed)
const buffer = new ArrayBuffer(string.length)
const bufferView = new Uint8Array(buffer)
for (let i = 0; i < string.length; i++) {
bufferView[i] = string.charCodeAt(i)
}
return buffer
}
Остается расшифровать полученные данные. Однако, после расшифровки нам необходимо декодировать поток байтов в исходный формат. Это можно сделать с помощью класса TextDecoder:
// https://developer.mozilla.org/en-US/docs/Web/API/TextDecoder
const decode = byteStream => {
const decoder = new TextDecoder()
return decoder.decode(byteStream)
}
Функция расшифровки представляет собой инверсию функции шифрования:
// https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/decrypt
const decrypt = async (cipher, key, iv) => {
const encoded = await window.crypto.subtle.decrypt({
name: 'AES-GCM',
iv
}, key, cipher)
return decode(encoded)
}
На данном этапе содержимое
client.js
выглядит так:const generateKey = async () =>
window.crypto.subtle.generateKey({
name: 'AES-GCM',
length: 256,
}, true, ['encrypt', 'decrypt'])
const encode = data => {
const encoder = new TextEncoder()
return encoder.encode(data)
}
const generateIv = () =>
window.crypto.getRandomValues(new Uint8Array(12))
const encrypt = async (data, key) => {
const encoded = encode(data)
const iv = generateIv()
const cipher = await window.crypto.subtle.encrypt({
name: 'AES-GCM',
iv
}, key, encoded)
return {
cipher,
iv
}
}
const pack = buffer => window.btoa(
String.fromCharCode.apply(null, new Uint8Array(buffer))
)
const unpack = packed => {
const string = window.atob(packed)
const buffer = new ArrayBuffer(string.length)
const bufferView = new Uint8Array(buffer)
for (let i = 0; i < string.length; i++) {
bufferView[i] = string.charCodeAt(i)
}
return buffer
}
const decode = byteStream => {
const decoder = new TextDecoder()
return decoder.decode(byteStream)
}
const decrypt = async (cipher, key, iv) => {
const encoded = await window.crypto.subtle.decrypt({
name: 'AES-GCM',
iv
}, key, cipher)
return decode(encoded)
}
Теперь реализуем отправку и получение данных.
Создаем переменные:
// поле для ввода сообщения, которое будет зашифровано
const input = document.querySelector('input')
// контейнер для вывода результатов
const output = document.querySelector('output')
// ключ
let key
Шифрование и отправка данных:
const encryptAndSendMsg = async () => {
const msg = input.value
// шифрование
key = await generateKey()
const {
cipher,
iv
} = await encrypt(msg, key)
// упаковка и отправка
await fetch('http://localhost:3000/secure-api', {
method: 'POST',
body: JSON.stringify({
cipher: pack(cipher),
iv: pack(iv)
})
})
output.innerHTML = `Сообщение <span>"${msg}"</span> зашифровано.<br>Данные отправлены на сервер.`
}
Получение и расшифровка данных:
const getAndDecryptMsg = async () => {
const res = await fetch('http://localhost:3000/secure-api')
const data = await res.json()
// выводим данные в консоль
console.log(data)
// распаковка и расшифровка
const msg = await decrypt(unpack(data.cipher), key, unpack(data.iv))
output.innerHTML = `Данные от сервера получены.<br>Сообщение <span>"${msg}"</span> расшифровано.`
}
Обработка нажатия кнопок:
document.querySelector('.btn-box').addEventListener('click', e => {
if (e.target.classList.contains('btn-send')) {
encryptAndSendMsg()
e.target.nextElementSibling.removeAttribute('disabled')
} else if (e.target.classList.contains('btn-get')) {
getAndDecryptMsg()
}
})
На всякий случай перезапускаем сервер. Открываем
http://localhost:3000
. Нажимаем на кнопку «Send message»:Видим данные, полученные сервером, в терминале:
{
cipher: 'j8XqWlLIrFxyfA2easXkJTLLIt9x8zLHei/tTKI=',
iv: 'F8doVULJzbEQs3M1'
}
Нажимаем на кнопку «Get message»:
Видим те же самые данные, полученные клиентом, в консоли:
{
cipher: 'j8XqWlLIrFxyfA2easXkJTLLIt9x8zLHei/tTKI=',
iv: 'F8doVULJzbEQs3M1'
}
Web Cryptography API открывает перед нами интересные возможности по защите конфиденциальной информации на стороне клиента. Еще один шаг в сторону бессерверной веб-разработки.
Поддержка данной технологии на сегодняшний день составляет 96%:
Надеюсь, статья вам понравилась. Благодарю за внимание.
ystr
Вы про PKIJS в курсе? Или просто было интересно велосипед пописать?
Gariks
Зачем тащить в проект лишнюю зависимость, если ты в состоянии написать нужную тебе реализацию?