express-sessionのコードを読んで、sessionのキーの基本的な扱いを理解する。

express-sessionで、Cookieのキーから、サーバ(webアプリ)側で保存されるキーをどのように引いているのか気になって、コードを読んでみた。

TL;DR

Cookieのセッションキーはメッセージ認証コード(MAC)が付与されたものになっていて、セッションのIDの検証に使われている。

具体的にはcookieのセッションのキーのうち、最初のドットより前の値が、redisなどに保存したときのキーになっている。 ドットより後ろは、ドットより前の値をsecretでHMAC-SHA-256をかけてbase64エンコードして末尾の=を落としたものなっている。

express-sessionのコードを読む。

index.jsを開いて、secretを使っているところを追っていくと、 ここでcookieのidを生成してそうな箇所が早々に見つかる。

    // get the session ID from the cookie
    var cookieId = req.sessionID = getcookie(req, name, secrets);

このfunctionの引数、nameはcookieに保存するセッションキーの名前で、デフォルトだと、connect.sidが入り、secretsは、設定したsecretが入る。

で、getcookieを追ってみると以下が見つかる。

function getcookie(req, name, secrets) {
  var header = req.headers.cookie;
  var raw;
  var val;

  // read from cookie header
  if (header) {
    var cookies = cookie.parse(header);

    raw = cookies[name];

    if (raw) {
      if (raw.substr(0, 2) === 's:') {
        val = unsigncookie(raw.slice(2), secrets);

        if (val === false) {
          debug('cookie signature invalid');
          val = undefined;
        }
      } else {
        debug('cookie unsigned')
      }
    }
  }
  (中略)
  return val;
}

rawにcookieに保存したセッションキーの値が入っている。 接頭語は's:'になっている前提の様子で、接頭語を落としたものに対して、 unsigncookieが実行されている。

ちなみに、実際のcookieに保存されているセッションIDを確認してみると以下のような値が見つかった。

connect.sid=s%3AW_oN_9SKkHUnFk-pFmZLSMqw49vrEAAP.bj7P2F6jPx2v9D5%2Fm%2FhFSmFnZPa1U9U2v7iJYPEYO2I

値はURLデコードしておく。

s:W_oN_9SKkHUnFk-pFmZLSMqw49vrEAAP.bj7P2F6jPx2v9D5/m/hFSmFnZPa1U9U2v7iJYPEYO2I

そうすると、接頭語s:とマッチするので、これを除くと、

W_oN_9SKkHUnFk-pFmZLSMqw49vrEAAP.bj7P2F6jPx2v9D5/m/hFSmFnZPa1U9U2v7iJYPEYO2I

となり、これが

unsigncookie(raw.slice(2), secrets);

のひとつめの引数となる。

で、次に、unsigncookieを追うと以下が見つかった。

/**
 * Verify and decode the given `val` with `secrets`.
 *
 * @param {String} val
 * @param {Array} secrets
 * @returns {String|Boolean}
 * @private
 */
function unsigncookie(val, secrets) {
  for (var i = 0; i < secrets.length; i++) {
    var result = signature.unsign(val, secrets[i]);

    if (result !== false) {
      return result;
    }
  }

  return false;
}

ループは複数のsecretsに複数の値が設定できるようにしているだけなので実質以下である。

var signature = require('cookie-signature')

(中略)

signature.unsign(val, secret[0]);

cookie-signatureをさらに読む必要がありそう。

cookie-signatureを試してみる。

とりあえず、以下で、cookie-signatureを呼んでunsignしてみる。

var cookie = require('cookie-signature');
var val = 'W_oN_9SKkHUnFk-pFmZLSMqw49vrEAAP.bj7P2F6jPx2v9D5/m/hFSmFnZPa1U9U2v7iJYPEYO2I';
console.log(cookie.unsign(val, secret));

その結果、以下の値が得られた。

W_oN_9SKkHUnFk-pFmZLSMqw49vrEAAP

結論としては、この値がセッションキーである。 入力された値と比較するとドット以降を切り落とした値と一致し、この値はredisのキーからsess:を除いた値とも一致する。

sess:W_oN_9SKkHUnFk-pFmZLSMqw49vrEAAP

sess:は、connect-redisのprefixの初期値であるので、sess:を除いた値がセッションキーである。

cookie-signatureを読む。

usignの実装を読む。cookie-signatureのunsignを探すと以下が見つかった。あとで関係するsignも合わせて載せておく。

exports.sign = function(val, secret){
  if ('string' != typeof val) throw new TypeError("Cookie value must be provided as a string.");
  if ('string' != typeof secret) throw new TypeError("Secret string must be provided.");
  return val + '.' + crypto
    .createHmac('sha256', secret)
    .update(val)
    .digest('base64')
    .replace(/\=+$/, '');
};

exports.unsign = function(val, secret){
  if ('string' != typeof val) throw new TypeError("Signed cookie string must be provided.");
  if ('string' != typeof secret) throw new TypeError("Secret string must be provided.");
  var str = val.slice(0, val.lastIndexOf('.'))
    , mac = exports.sign(str, secret);

  return sha1(mac) == sha1(val) ? str : false;
};

unsignでは、引数のvalからstrを経てmacを求めている。 strは、valの最初のドットまでの文字列を切り出して、 macは、strにsignした文字列としている。

signは、strにHMAC-SHA-256をかけてBASE64エンコードした文字列をドットでつないでいる。名前の通り署名しているような感じか。

で、このmacとvalを評価して一致すればOK、一致しなければNGということになる。

大体わかったが、最後の評価がsha1ハッシュ値の比較をしているのがちょっとよくわからない。 sha1で比較文字列で短くして評価しようとしている?普通に比較したほうが早くないのか?

hmacは、secretがハッシュ長より短い場合は、ゼロパディングされるので、sha256のハッシュ長である256bit =32byteを設定するのが良さそう。長い分にはハッシュ取るだけなのでよい。

32byteのキーを生成するのは以下でいいと思う。HEXかけてるから2倍の64byteの文字列が出力されるけど。*1

node -e "console.log(require('crypto').randomBytes(32).toString('hex'));"

*1:バイナリ256bit(256進数)をHEXの16bit(16進数)で表現しようとすると、256 = 16 * 16なので、データは2倍になる