🌱 初めに
Node.jsのCryptoを触って暗号化について勉強したことをこの記事にまとめました。
理解に苦しんでいる方が、この記事を読んでなんとなくイメージができるようになれば嬉しいです。
サンプルコードはGitHub🐱で公開しています。
♻️ 環境
- Node.js: v12.20.0
🗂 最終的なディレクトリ構成
これだけです。
- node_modules
- package.json
- package-lock.json
- server.js
- README.md
✨ プロジェクト作成
mkdir security
cd security
npm init -y
以下の設定でで新しいnodeプロジェクトが作られます。
{
"name": "security",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC"
}
必要なパッケージをインストール
npm i express cookie-parser url-safe-base64 moment
以下がインストールされます。
補足: Cryptoについて
can't find crypto
などのエラーが出る場合はcryptoをインストールします。
npm i crypto
ただしCryptoは最近のnodejsならビルドインされているはずです。
このパッケージはもうサポートされていないので気をつけてください。
ファイルを置く
ルート直下に server.js
を作成します。
'use strict'
const express = require('express')
const cookieParser = require('cookie-parser')
const { encode, decode } = require('url-safe-base64')
const crypto = require('crypto')
const moment = require('moment')
const KEY = '12345abcde12345abcde12345abcde12' // 32Byte
const IV = '12345abcde12345a' // 16Byte
const ALG = 'aes256'
const ENCODING = 'base64'
const app = express()
/**
* サーバ起動
*/
app.use(cookieParser())
const server = app.listen(3000, function () {
console.log('🚀 app started. port:' + server.address().port)
})
/**
* encryptする
*/
app.get('/encrypt', async function (req, res, next) {
const text = req.query.text
res.send(encrypt(text))
})
/**
* decryptする
*/
app.get('/decrypt', async function (req, res, next) {
const encrypted_text = req.query.encrypted_text
res.send(decrypt(encrypted_text))
})
/**
* トークン発行
*/
app.get('/authorize', async function (req, res, next) {
const payload = { user_id: req.query.user_id }
const token = await encryptWithBase64UrlSafe(JSON.stringify(payload))
res.cookie('my_token ', token, {
expires: moment()
.add(5, 'minutes')
.toDate()
})
res.send(token)
})
/**
* トークン検証
*/
app.get('/verify', async function (req, res, next) {
const token = req.cookies.my_token
const decrypted = await decryptWithBase64UrlSafe(token)
const decoded = JSON.parse(decrypted)
res.json({ user_id: decoded.user_id })
})
/**
*暗号化
*/
const encrypt = text => {
const cipher = crypto.createCipheriv(ALG, Buffer.from(KEY), Buffer.from(IV))
let encryptedText = cipher.update(text, 'utf8', ENCODING)
encryptedText += cipher.final(ENCODING)
return encryptedText
}
/**
*複合
*/
const decrypt = encryptedText => {
const decipher = crypto.createDecipheriv(
ALG,
Buffer.from(KEY),
Buffer.from(IV)
)
let decryptedText = decipher.update(encryptedText, ENCODING, 'utf8')
decryptedText += decipher.final('utf8')
return decryptedText
}
/**
* 暗号化 + base64で包む
*/
const encryptWithBase64UrlSafe = async x => {
const encrypted = encrypt(x)
return encode(encrypted)
}
/**
* base64のデコード + 複合
*/
const decryptWithBase64UrlSafe = async x => {
const decoded = decode(x)
return decrypt(decoded)
}
🏃♀️ 実行手順
以下でサーバを起動します。
node server.js
ターミナルに🚀 app started. port:3000
と表示されたらOKです。
🔐 暗号化する
クエリパラメータ text
に暗号化したい文字列を入れて、cURLやブラウザでリクエストします。
curl 'http://localhost:3000/encrypt?text=apple'
実行すると暗号化されたテキストが返却されます。
yZzoNKpuTZS0YfvsBxSOMQ==
🔓 複合する
クエリパラメータencrypted_text
に複合したい文字列を入れて、cURLでリクエストします。
※URLセーフではないので、ブラウザではできません。
curl 'http://localhost:3000/decrypt?encrypted_text=yZzoNKpuTZS0YfvsBxSOMQ=='
実行すると複合されたテキストが返却されます。
apple
🎫 URLセーフなトークンを発行する
URLセーフな暗号を作ってトークン認証っぽく使ってみます。
クエリパラメータにuser_idを入れてリクエストすると、URLセーフなトークンが発行されます。
※ここからはCookieを使うのでブラウザで実行してください。
http://localhost:3000/authorize?user_id=1
画面にトークンの値が返却されます。
また、my_tokenという名前でトークンがcookieに保存されているはずです。
この状態で以下にアクセスします。
http://localhost:3000/verify
トークンの中身が解析された結果が返却されます。
※トークンはcookieに入っているので、クエリパラメータで渡す必要はないです。
※トークンの有効期限は5分にしているので /atuhorize
してから5分以内に /verify
を実行してください。
👩🏫 解説
/encrypt
GETすると以下の関数が呼び出されます。
/**
* encryptする
*/
app.get('/encrypt', async function (req, res, next) {
const text = req.query.text
res.send(encrypt(text))
})
受け取ったtextを実際に暗号しているのは以下の関数です。
/**
*暗号化
*/
const encrypt = text => {
const cipher = crypto.createCipheriv(ALG, Buffer.from(KEY), Buffer.from(IV))
let encryptedText = cipher.update(text, 'utf8', ENCODING)
encryptedText += cipher.final(ENCODING)
return encryptedText
}
このencript関数は、
- cipherインスタンスを作る
- テキストの暗号文を作る
- 返却する
という処理をしています。
↓では一つずつ関数を見ていきます。
■ crypto.createCipheriv(アルゴリズム, キー, 初期値)
Cipherクラスのインスタンスを生成します。
-
アルゴリズム
アルゴリズムとは、どうやって暗号を作るかの方法、暗号化方式のことです。
いろんな種類がありますがAESは「Advanced Encryption Standard」の略で、「先進的暗号化標準」という名前が付けられているだけあってかなり安全みたいです。
参考:暗号化のAES方式とは?ほかの種類との違い・実施方法を解説! -
キー
暗号化する鍵です。複合化する鍵でもあります。バレると複合されてしまうので絶対に晒してはいけません。共通鍵というやつです。
ここではベタ書きしていますが、本来は.envなどの環境設定ファイルに書いたほうがいいです...
文字数は32Byteで設定します。短くても長くてもエラーになります。
参考: 共通鍵暗号方式 -
初期値(iv)
ivはinitialization vectorの略で、日本語では初期値ベクトルと言います。
keyだけでも文字の暗号化はできますが、パターンが毎回同じになってしまいがちです。
そこにランダムな初期値を混ぜ入れたら、より解読されにくい暗号が作れます。
そのランダム初期値がivです。
参考:初期化ベクトルとは?暗号化で知っておくべき基礎知識を解説!
■ cipher.update(テキスト、 入力テキストの文字コード、 出力テキストの文字コード)
cipherインスタンスに、テキストを入れて実際に暗号化する関数です。
'base64'、'hex'、'utf16le'など、渡したエンコードで暗号化してくれます。
cipher.final()
が呼び出されるまで暗号化は完全に完了しません。
例えばいくつかの平文を組み合わせて1つの暗号を作るときは、
let encryptedText = cipher.update(text1, 'utf8', ENCODING)
encryptedText += cipher.update(text2, 'utf8', ENCODING)
encryptedText += cipher.update(text3, 'utf8', ENCODING)
encryptedText += cipher.final(ENCODING)
こんな感じで結合していって最終的に1つの暗号文を作れたりします。
(実用されるかわかりませんが。)
■ cipher.final(出力テキストの文字コード)
最終的な暗号文を作ります。
これが呼ばれたら、もうcipherは使い回しできません。
例えばこうしたら、
let encryptedText = cipher.update(text1, 'utf8', ENCODING)
encryptedText += cipher.update(text2, 'utf8', ENCODING)
encryptedText += cipher.final(ENCODING)
encryptedText += cipher.update(text3, 'utf8', ENCODING) ❌
❌のところでエラーになります。
GET /decrypt
GETすると以下の関数が呼び出されます。
/**
* decryptする
*/
app.get('/decrypt', async function (req, res, next) {
const encrypted_text = req.query.encrypted_text
res.send(decrypt(encrypted_text))
})
受け取ったtextを実際に暗号しているのは以下の関数です。
/**
*複合
*/
const decrypt = encryptedText => {
const decipher = crypto.createDecipheriv(ALG, Buffer.from(KEY), Buffer.from(IV))
let decryptedText = decipher.update(encryptedText, ENCODING, 'utf8')
decryptedText += decipher.final('utf8')
return decryptedText
}
encrypt関数は、
- cipherインスタンスを作る
- 暗号テキストを複合する
- 返却する
という処理をしています。
■ crypto.createCipheriv(アルゴリズム, キー, 初期値)
暗号化する時と同じ。Cipherをインスタンス化している。
■ decipher.update(テキスト, 入力する文字コード, 出力する文字コード)
このupdateも暗号化する時と同じですが、文字コードに注意が必要です。
暗号化するときは utf-8
を入力し、base64
で出力しました。
なので、
複合化するときは base64
を入力し、utf-8
で出力します。
■ cipher.final(出力テキストの文字コード)
暗号化する時と同じ。複合テキストの最終出力をしてcipherを終了しています。
/authorize
GETすると以下の関数が呼び出されます。
/**
* トークン発行
*/
app.get('/authorize', async function (req, res, next) {
const payload = { user_id: req.query.user_id }
const token = await encryptWithBase64UrlSafe(JSON.stringify(payload))
res.cookie('my_token ', token, {
expires: moment()
.add(5, 'minutes')
.toDate()
})
res.send(token)
})
■ const payload = { user_id: req.query.user_id }
URLのクエリストリングuser_idを取得しオブジェクトにしています。
例えば/authorize?user_id=1でリクエストしたら、user_idには1が代入されます。
■ encryptWithBase64UrlSafe(JSON.stringify(payload))
上記で取得した {user_id: 1}
を文字列にして encryptWithBase64UrlSafe()
に渡してトークンを取得しています。
encryptWithBase64UrlSafe()の実装は以下です。
/**
* 暗号化 + base64で包む
*/
const encryptWithBase64UrlSafe = async x => {
const encrypted = encrypt(x)
return encode(encrypted)
}
/encryptを実行した時と同じようにテキストを暗号化しています。
それをさらにencode()
で base64セーフな形の文字列にして返却しています。
base64セーフにしなかった場合、トークンは以下のような形なのですが、
yZzoNKpuTZS0YfvsBxSOMQ==
末尾についているイコール(=
)はURLでは使用できないのでトークンとして使いにくいです。
なのでこれをurl-safe-base64のライブラリ関数を使って変換すると・・・
yZzoNKpuTZS0YfvsBxSOMQ..
このようにイコールがドットになってURLとしても使える文字列になります。
■ res.cookie()
res.cookie()でブラウザのcookie領域にトークンを保存しています。
res.cookie('my_token ', token, {
expires: moment().add(5, 'minutes').toDate()
})
res.cookie()の引数は
- 保存する値のキー
- 保存する値
- オプション
です。
オプションのexpires
には 現在時刻+5分を設定しています。
なので、5分経ったら自然にcookie領域からこのキーバリューは消えます。
cookieはChromeの開発者ツールなどで確認できます
Macなら⌘cmd + option + i
> アプリケーション > 🎨Cookie で見れます。
/verify
GETすると以下の関数が呼び出されます。
/**
* トークン検証
*/
app.get('/verify', async function (req, res, next) {
const token = req.cookies.my_token
const decrypted = await decryptWithBase64UrlSafe(token)
const decoded = JSON.parse(decrypted)
res.json({ user_id: decoded.user_id })
})
■ const token = req.cookies.my_token
/authorizeで設定したcookieを取得しています。
■ decryptWithBase64UrlSafe(token)
/**
* base64のデコード + 複合
*/
const decryptWithBase64UrlSafe = async x => {
const decoded = decode(x)
return decrypt(decoded)
}
urlセーフなbase64文字列をただのbase64文字列にデコードし、複合しています。
■ const decoded = JSON.parse(decrypted)
JSONにパースし、値を取り出しています。
💁♀️ 補足
ログイントークンのユースケース
API /authorize
と /verify
は、ログイントークンとして使われることが多いかなと思います。
処理の流れは以下です。
- ユーザがメールアドレスとパスワードでログインする
- ユーザが存在すれば、API側でトークンを発行しcookieに保存する
- 以降はブラウザからリクエストが飛んでくるたびにこのcookieを検証し、ユーザが誰なのかを判断する
トークンの置き場所はcookieだったり、LocalStorageだったり、Headerだったりします。
(サンプルのコードでは簡単すぎて脆弱なところがあるとは思いますが、)だいたいはこんな感じでログインユーザを管理する仕組みになっていると思います。
参考: ワンタイムパスワード・トークンとは|初心者にもわかりやすく解説!
エラー
-
暗号化でエラー
Cipherインスタンスを作るときの初期値の長さは16Byteでなくてはならない。長さが違うとError: Invalid IV length
というエラーになります。
https://github.com/nodejs/node/issues/6696#issuecomment-218575039
キーも同様です。 -
複合でエラー
もし取り出した値が正しく複合できなかったらエラーになります。
Error: error:0606506D:digital envelope routines:EVP_DecryptFinal_ex:wrong final block length
📘 参考
参考にさせていただきました
- Node.js v18.6.0 documentation
- runebook.dev日本語 Crypto
- セッション認証とトークン認証について調べたことをまとめる
- ASCII文字とURLエンコードの対応表
🔑 終わりに
暗号は奥が深いし認証の種類も複数あるし、どこからどこまで調べたらいいのかわからずネットの海を彷徨いました。
コードを書いて動かして、「こうしたらどうなる?」という実験をしながら調べたら、意外と理解しやすかったです。
そんな気がするだけかな。。
以上