🌊

Rails5.1のCookie暗号化・署名をNode.jsで再現する

2022/02/27に公開

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

https://github.com/rails/rails/blob/v5.1.7/actionpack/lib/action_dispatch/middleware/cookies.rb#L568-L596

鍵を生成している処理の中身はこうなっている。

def generate_key(salt, key_size = 64)
  OpenSSL::PKCS5.pbkdf2_hmac_sha1(@secret, salt, @iterations, key_size)
end

https://github.com/rails/rails/blob/v5.1.7/activesupport/lib/active_support/key_generator.rb

暗号化しつつ、文字列を整形している処理はこうなっている。

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

https://github.com/rails/rails/blob/v5.1.7/activesupport/lib/active_support/message_encryptor.rb#L94-L109

digestの作成処理はこうなっている。

def generate_digest(data)
  require "openssl" unless defined?(OpenSSL)
  OpenSSL::HMAC.hexdigest(OpenSSL::Digest.const_get(@digest).new, @secret, data)
end

https://github.com/rails/rails/blob/v5.1.7/activesupport/lib/active_support/message_verifier.rb#L129-L132

これらを整理すると、発行される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の発行処理はここにある。

https://github.com/rails/rails/blob/v5.1.7/actionpack/lib/action_dispatch/middleware/session/abstract_store.rb#L24-L28

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