Rails6のセッションクッキーをNode.jsで読み書きする

既存のRails 6のサービスをストラングラーフィグパターンで徐々にNode.jsに移行していこうかなと思ったけど結果的にやらない方向にしたのでお焚き上げしておく。

そもそもRubyでやるにはどうするか。下記を参考に。

require "base64"
require "openssl"
require "json"
require "uri"

def decode(secret_key_base, cookie)
  data, iv, auth_tag = URI.decode_uri_component(cookie).split("--").map(&Base64.method(:strict_decode64))

  cipher = OpenSSL::Cipher.new("aes-256-gcm")
  secret = OpenSSL::PKCS5.pbkdf2_hmac_sha1(secret_key_base, "authenticated encrypted cookie", 1000, cipher.key_len)
  cipher.decrypt
  cipher.key = secret
  cipher.iv = iv
  cipher.auth_tag = auth_tag
  cipher.auth_data = ""

  value = cipher.update(data) + cipher.final
  payload = JSON.parse(value)
  message = Base64.strict_decode64(payload["_rails"]["message"])

  JSON.parse(message)
end

puts decode("a444a9...", "4HmNs...")

これをNode.jsに移植すればよい。

const crypto = require("crypto");

function decode(secretKeyBase, cookie) {
  const [data, iv, authTag] = decodeURIComponent(cookie).split("--").map(v => Buffer.from(v, "base64"));

  const secret = crypto.pbkdf2Sync(secretKeyBase, "authenticated encrypted cookie", 1000, 32, "sha1");
  const cipher = crypto.createDecipheriv("aes-256-gcm", secret, iv);
  cipher.setAuthTag(authTag);

  const value = cipher.update(data, "base64", "utf8") + cipher.final("utf8");
  const payload = JSON.parse(value);
  const message = Buffer.from(payload._rails.message, "base64");

  return JSON.parse(message);
}

console.log(decode("a444a9...", "4HmNs..."));

ついでに逆もやってみる。上記の逆をやればいいのでRubyだとこうなる。

require "base64"
require "openssl"
require "json"
require "uri"

def encode(secret_key_base, session)
  message = Base64.strict_encode64(session.to_json)
  payload = { _rails: { message:, exp: nil, pur: "cookie._hoge_session" } }
  value = payload.to_json

  cipher = OpenSSL::Cipher.new("aes-256-gcm")
  secret = OpenSSL::PKCS5.pbkdf2_hmac_sha1(secret_key_base, "authenticated encrypted cookie", 1000, cipher.key_len)
  iv = cipher.random_iv
  cipher.encrypt
  cipher.key = secret
  cipher.iv = iv
  cipher.auth_data = ""

  data = cipher.update(value) + cipher.final
  auth_tag = cipher.auth_tag
  cookie = [data, iv, auth_tag].map(&Base64.method(:strict_encode64)).join("--")

  URI.encode_uri_component(cookie)
end

puts encode("a444a9...", session_id: "621be...", ...)

同様にNode.jsはこうなる。

const crypto = require("crypto");

function encode(secretKeyBase, session) {
  const message = Buffer.from(JSON.stringify(session)).toString("base64");
  const payload = { _rails: { message, exp: null, pur: "cookie._hoge_session" } };
  const value = JSON.stringify(payload);

  const secret = crypto.pbkdf2Sync(secretKeyBase, "authenticated encrypted cookie", 1000, 32, "sha1");
  const iv = crypto.randomFillSync(Buffer.alloc(12));
  const cipher = crypto.createCipheriv("aes-256-gcm", secret, iv);

  const data = cipher.update(value, "utf8", "base64") + cipher.final("base64");
  const authTag = cipher.getAuthTag();
  const cookie = [data, iv.toString("base64"), authTag.toString("base64")].join("--");

  return encodeURIComponent(cookie);
}

console.log(encode("a444a9...", { session_id: "621be...", ... }));

Rails 7だとデフォルトの暗号化方式が変わるので、それに合わせた変更をするか、当座は引き続きこれまでのを使うようにするか設定する。実際どうするかは先のリンクのどこかに書いてたような気がする。ちなみに上記ではRuby 3.2の記法などを使ってるので3.1以下の場合は各自よろしくやってくれ。

検証としてRemixのミドルウェアっぽい使い方で期待通りに動いてるように見えた。今回は簡単のためNode.jsのcryptoモジュールは同期APIを利用したが、運用コードは非同期APIの方がいいだろう。