Rails5.1のCookie暗号化・署名をNode.jsで再現する
RailsからNode.jsへと既存アプリケーションを移し替える場面があるかもしれない。
その時に、同じ形式のCookieを扱わないとセッションが維持できなくなってしまう。
Railsで作成されるCookieは暗号化・署名が行われている。
なので、それと同じ形式で暗号化・署名を行ってCookieを参照・発行すればセッションが維持できるはず。
ここでは、Rails5.1と同じCookieの暗号化・署名をNode.jsで再現する方法を確認してみる。
EncryptedCookieJar
Rails5.1で暗号化されたCookieを作成しているのは、恐らくこのあたり。
class EncryptedCookieJar < AbstractCookieJar # :nodoc:
include SerializedCookieJars
def initialize(parent_jar)
super
if ActiveSupport::LegacyKeyGenerator === key_generator
raise "You didn't set secrets.secret_key_base, which is required for this cookie jar. " \
"Read the upgrade documentation to learn more about this new config option."
end
secret = key_generator.generate_key(request.encrypted_cookie_salt || "")[0, ActiveSupport::MessageEncryptor.key_len]
sign_secret = key_generator.generate_key(request.encrypted_signed_cookie_salt || "")
@encryptor = ActiveSupport::MessageEncryptor.new(secret, sign_secret, digest: digest, serializer: ActiveSupport::MessageEncryptor::NullSerializer)
end
private
def parse(name, encrypted_message)
deserialize name, @encryptor.decrypt_and_verify(encrypted_message)
rescue ActiveSupport::MessageVerifier::InvalidSignature, ActiveSupport::MessageEncryptor::InvalidMessage
nil
end
def commit(options)
options[:value] = @encryptor.encrypt_and_sign(serialize(options[:value]))
raise CookieOverflow if options[:value].bytesize > MAX_COOKIE_SIZE
end
end
鍵を生成している処理の中身はこうなっている。
def generate_key(salt, key_size = 64)
OpenSSL::PKCS5.pbkdf2_hmac_sha1(@secret, salt, @iterations, key_size)
end
暗号化しつつ、文字列を整形している処理はこうなっている。
def _encrypt(value)
cipher = new_cipher
cipher.encrypt
cipher.key = @secret
# Rely on OpenSSL for the initialization vector
iv = cipher.random_iv
cipher.auth_data = "" if aead_mode?
encrypted_data = cipher.update(@serializer.dump(value))
encrypted_data << cipher.final
blob = "#{::Base64.strict_encode64 encrypted_data}--#{::Base64.strict_encode64 iv}"
blob << "--#{::Base64.strict_encode64 cipher.auth_tag}" if aead_mode?
blob
end
digestの作成処理はこうなっている。
def generate_digest(data)
require "openssl" unless defined?(OpenSSL)
OpenSSL::HMAC.hexdigest(OpenSSL::Digest.const_get(@digest).new, @secret, data)
end
これらを整理すると、発行されるCookieは次のような形式になる。
JWTと同じ雰囲気で、"--"区切りでPayloadとSignature部分に分解できる。
そして、Payload相当の部分には暗号化されたデータが入っている。
この形式に従って、Node.jsで参照・発行する方法を確認してみる。
Node.jsでRailsのCookieを参照・発行する
粛々とNode.jsで実装を再現していくだけ。
Saltに関しては同じ値のデフォルト値が設定されているらしく、設定で上書きしていなければ同じ値で良いはず。
const crypto = require('crypto');
const secret_key_base = '013fd51746165dfb5973dff132d672d4bc8bac2c08bb852b9d4bb8e1057e5f84f3771df79596076f2c3c8379087d9bfca6a367b3921daa59b6883dc659b71e43';
const encrypted_signed_cookie_salt = 'signed encrypted cookie';
const encrypted_cookie_salt = 'encrypted cookie';
function create_digest(data) {
const sign_key = crypto.pbkdf2Sync(secret_key_base, encrypted_signed_cookie_salt, 1000, 64, 'sha1');
const hmac = crypto.createHmac('sha1', sign_key);
hmac.update(data);
return hmac.digest('hex');
}
function decrypt_data(encrypted_data, iv) {
const key = crypto.pbkdf2Sync(secret_key_base, encrypted_cookie_salt, 1000, 32, 'sha1');
const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
let decrypted_data = decipher.update(encrypted_data);
decrypted_data = Buffer.concat([decrypted_data, decipher.final()]);
return decrypted_data.toString();
}
function encrypt_data(decrypted_data) {
const key = crypto.pbkdf2Sync(secret_key_base, encrypted_cookie_salt, 1000, 32, 'sha1');
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
let encrypted_data = cipher.update(decrypted_data);
encrypted_data = Buffer.concat([encrypted_data, cipher.final()]);
return [encrypted_data, iv];
}
function create_cookie(decrypted_data) {
const [encrypted_data, iv] = encrypt_data(decrypted_data);
const data = Buffer.from(encrypted_data.toString('base64') + '--' + iv.toString('base64')).toString('base64');
const digest = create_digest(data);
return data + '--' + digest;
}
function console_cookie(cookie) {
const [data, digest] = cookie.split('--');
const [encrypted_data, iv] = Buffer.from(data, 'base64').toString().split('--').map(v => Buffer.from(v, 'base64'));
console.log('----------');
console.log('create_digest', digest, digest === create_digest(data));
console.log('decrypt_data', decrypt_data(encrypted_data, iv));
}
console_cookie('SG1wc1ZpYWxDdHFFa1pvdDkyTktaOG1Ic0JKNFRIQW1TUTloRUlaZ1VGbVhuQWRIdUdQVVhmNUN4RzE4M0dscWNpZVFsWEhUTmZ0ai92VXJiT3k4ZyszRzV3eGI1dHNjSVhJTzZJdFdMcTgwV1BhU1dVaGg5b1l5UktoRUtMdk42WVBkcGEvVGtMKzgvemlOSU1lZzM2UzdGT004d0psSHFaKzZ5bjFWdy9QWUR4b05CLy91YThCV3UwL2p2K0xsLS1SYlhxQU1lZjJvT2lRcHJnQ1hkcWV3PT0=--1b7db0f205624feab55c7260bd8db16ea2201848');
console_cookie(create_cookie('HELLO WORLD'));
Railsで発行されたCookieの参照と、Node.jsでCookieの発行を試す。
意図したJSONが入っているのが確認でき、Node.jsで作成したCookieも参照時と同じ処理でDigestが一致していることを確認できた。
$ node cookie.js
----------
create_digest 1b7db0f205624feab55c7260bd8db16ea2201848 true
decrypt_data {"session_id":"49bba8ffbffa71ba5b62ac20f47712fc","_csrf_token":"3sOHi+i56SYRPfnNbPiDE0HkfWxIVYEQm9UggOwWUMc=","user_id":"my-user-id"}
----------
create_digest 84c93ea540dc74c22ecc376022cd88d4f77d7d70 true
decrypt_data HELLO WORLD
あとは、必要に応じてExpressのMiddleware化するなどして、アプリケーションに組み込めば良さそう。
Expressに組み込んでみる
セッションIDの発行処理はここにある。
ExpressのMiddlewareを作成し、アプリケーションに組み込むと次のような実装になる。
const crypto = require('crypto');
const express = require('express');
const cookieParser = require('cookie-parser');
const onHeaders = require('on-headers');
const app = express();
const port = 3000;
class Session {
constructor(data) {
if (typeof data === 'object' && data !== null) {
for (const prop in data) {
if (!(prop in this)) {
this[prop] = data[prop];
}
}
}
if (!this.session_id) {
this.session_id = this.generate_session_id();
}
}
generate_session_id() {
return crypto
.randomBytes(16)
.toString('hex');
}
get_hash() {
return crypto
.createHash('sha1')
.update(JSON.stringify(this), 'utf8')
.digest('hex');
}
}
function create_digest(data, secrets) {
const { secret_key_base, encrypted_signed_cookie_salt } = secrets;
const sign_key = crypto.pbkdf2Sync(secret_key_base, encrypted_signed_cookie_salt, 1000, 64, 'sha1');
const hmac = crypto.createHmac('sha1', sign_key);
hmac.update(data);
return hmac.digest('hex');
}
function decrypt_data(encrypted_data, iv, secrets) {
const { secret_key_base, encrypted_cookie_salt } = secrets;
const key = crypto.pbkdf2Sync(secret_key_base, encrypted_cookie_salt, 1000, 32, 'sha1');
const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
let decrypted_data = decipher.update(encrypted_data);
decrypted_data = Buffer.concat([decrypted_data, decipher.final()]);
return decrypted_data.toString();
}
function encrypt_data(decrypted_data, secrets) {
const { secret_key_base, encrypted_cookie_salt } = secrets;
const key = crypto.pbkdf2Sync(secret_key_base, encrypted_cookie_salt, 1000, 32, 'sha1');
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
let encrypted_data = cipher.update(decrypted_data);
encrypted_data = Buffer.concat([encrypted_data, cipher.final()]);
return [encrypted_data, iv];
}
function create_cookie_value(decrypted_data, secrets) {
const [encrypted_data, iv] = encrypt_data(decrypted_data, secrets);
const data = Buffer.from(encrypted_data.toString('base64') + '--' + iv.toString('base64')).toString('base64');
const digest = create_digest(data, secrets);
return data + '--' + digest;
}
function get_cookie_data(value, secrets) {
const [data, _] = value.split('--');
const [encrypted_data, iv] = Buffer.from(data, 'base64').toString().split('--').map(v => Buffer.from(v, 'base64'));
const decrypted_data = decrypt_data(encrypted_data, iv, secrets);
return decrypted_data;
}
function verify_cookie_value(value, secrets) {
const [data, digest] = value.split('--');
return digest === create_digest(data, secrets);
}
function create_session(data) {
return new Session(data);
}
function get_session(value, secrets) {
try {
if (value) {
if (verify_cookie_value(value, secrets)) {
const data = get_cookie_data(value, secrets);
return create_session(JSON.parse(data));
}
}
} catch (e) {
console.log(e);
}
return null;
}
function should_set_cookie(session, original_session_hash) {
if (!session) {
return false;
}
if (session.get_hash() === original_session_hash) {
return false;
}
return true;
}
function session_handler(options) {
const { name, secret, cookie_options } = options;
const secrets = {
secret_key_base: secret,
encrypted_signed_cookie_salt: 'signed encrypted cookie',
encrypted_cookie_salt: 'encrypted cookie',
};
return (req, res, next) => {
const value = req.cookies[name];
const original_session = get_session(value, secrets);
if (original_session) {
req.session = original_session;
req.original_session_hash = original_session.get_hash();
} else {
req.session = create_session();
req.original_session_hash = null;
}
onHeaders(res, () => {
const session = req.session;
const original_session_hash = req.original_session_hash;
if (should_set_cookie(session, original_session_hash)) {
const data = JSON.stringify(session);
const value = create_cookie_value(data, secrets);
res.cookie(name, value, cookie_options);
}
});
next();
};
}
// -------------------------------------------------- //
app.use(cookieParser());
app.use(session_handler({
name: 'mysession',
secret: '013fd51746165dfb5973dff132d672d4bc8bac2c08bb852b9d4bb8e1057e5f84f3771df79596076f2c3c8379087d9bfca6a367b3921daa59b6883dc659b71e43',
}));
app.get('/', (req, res) => {
res.json(req.session);
});
app.get('/login', (req, res) => {
req.session.user_id = new Date().getTime();
res.send(req.session);
});
app.get('/logout', (req, res) => {
req.session = create_session();
res.json(req.session);
});
app.listen(port, () => {
console.log(`Example app listening on port ${port}`);
});
まとめ
過去バージョンのRailsではデータ本体がJSONではなく、rubyのMarshalを使ってシリアライズされていたらしい。
その場合は、さらにNode.jsでシリアライズ処理を書く必要があり、相当めんどくさいかもしれない。
後から気づいたが、CSRF対策用のトークンが入っている。
CSRF機能を有効にしている場合は、それも必要かもしれない。。。
また、Rails5.2以上を使っている場合も、若干内部処理が変わってる雰囲気があるので、もしかしたら考慮すべき点が増えているかもしれない。
フレームワーク毎にCookieの形式を勝手に決めるのではなく、標準化された形式があれば気軽にフレームワークを変えられて良いなと思う。
Discussion